initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
<template>
<button
ref="buttonRef"
:class="['animated-button', btnClass, { 'is-loading': loading, 'is-success': success, 'is-disabled': disabled || loading }]"
:disabled="disabled || loading"
@click="handleClick"
@mousedown="createRipple"
>
<div class="button-content" :class="{ 'opacity-0': loading || success }">
<slot></slot>
</div>
<!-- Loading State -->
<div v-if="loading" class="button-overlay">
<LottiePlayer
path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/79a4fc375bae.json"
width="40px"
height="40px"
:loop="true"
/>
</div>
<!-- Success State -->
<div v-if="success" class="button-overlay">
<LottiePlayer
path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/11999b7bb57c.json"
width="30px"
height="30px"
:loop="false"
/>
</div>
<span v-for="ripple in ripples" :key="ripple.id" class="ripple" :style="ripple.style"></span>
</button>
</template>
<script setup>
import { ref, reactive } from 'vue';
import LottiePlayer from './LottiePlayer.vue';
const props = defineProps({
btnClass: { type: String, default: 'btn btn-primary' },
loading: { type: Boolean, default: false },
success: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
});
const emit = defineEmits(['click']);
const buttonRef = ref(null);
const ripples = reactive([]);
const handleClick = (e) => {
if (props.loading || props.success || props.disabled) return;
emit('click', e);
};
const createRipple = (event) => {
if (props.disabled || props.loading || props.success) return;
const button = buttonRef.value;
if (!button) return;
const rect = button.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = event.clientX - rect.left - size / 2;
const y = event.clientY - rect.top - size / 2;
const id = Date.now();
ripples.push({
id,
style: {
width: `${size}px`,
height: `${size}px`,
top: `${y}px`,
left: `${x}px`
}
});
setTimeout(() => {
const index = ripples.findIndex(r => r.id === id);
if (index > -1) ripples.splice(index, 1);
}, 600);
};
</script>
<style scoped>
.animated-button {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
border: none;
outline: none;
}
.button-content {
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.button-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.ripple {
position: absolute;
background: rgba(255, 255, 255, 0.4);
border-radius: 50%;
transform: scale(0);
animation: ripple-animation 0.6s linear;
pointer-events: none;
}
@keyframes ripple-animation {
to {
transform: scale(4);
opacity: 0;
}
}
.is-loading, .is-success {
pointer-events: none;
}
.opacity-0 {
opacity: 0;
}
/* Base button styles if not provided by framework */
.btn {
padding: 0.6rem 1.5rem;
border-radius: 12px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div ref="container" :style="{ width: props.width, height: props.height }" class="lottie-container"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import lottie from 'lottie-web';
const props = defineProps({
path: { type: String, required: true },
loop: { type: Boolean, default: true },
autoplay: { type: Boolean, default: true },
width: { type: String, default: '100%' },
height: { type: String, default: '100%' },
play: { type: Boolean, default: true },
speed: { type: Number, default: 1 }
});
const container = ref(null);
let animation = null;
const initAnimation = () => {
if (animation) {
animation.destroy();
}
if (!container.value) return;
animation = lottie.loadAnimation({
container: container.value,
renderer: 'svg',
loop: props.loop,
autoplay: props.autoplay,
path: props.path
});
if (props.speed !== 1) {
animation.setSpeed(props.speed);
}
};
onMounted(() => {
initAnimation();
});
onUnmounted(() => {
if (animation) {
animation.destroy();
}
});
watch(() => props.path, () => {
initAnimation();
});
watch(() => props.play, (newVal) => {
if (animation) {
if (newVal) {
animation.play();
} else {
animation.stop();
}
}
});
defineExpose({
play: () => animation?.play(),
stop: () => animation?.stop(),
pause: () => animation?.pause(),
setSpeed: (speed) => animation?.setSpeed(speed),
goToAndPlay: (value, isFrame) => animation?.goToAndPlay(value, isFrame)
});
</script>
<style scoped>
.lottie-container {
overflow: hidden;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<transition
:name="transitionName"
mode="out-in"
>
<div :key="routeKey" class="route-wrapper">
<slot></slot>
</div>
</transition>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
routeKey: { type: String, required: true }
});
const transitionName = ref('route-fade');
</script>
<style scoped>
.route-wrapper {
width: 100%;
height: 100%;
}
/* Fast, lightweight fade transition — keeps navigation feeling instant */
.route-fade-enter-active {
transition: opacity 0.12s ease-out;
}
.route-fade-leave-active {
transition: opacity 0.08s ease-in;
}
.route-fade-enter-from {
opacity: 0;
}
.route-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,124 @@
<script setup>
import { useNavigate } from '../../composables/Core/useNavigate'
const props = defineProps({
to: {
type: [String, Object],
default: null
},
fallback: {
type: String,
default: 'Home'
},
text: {
type: String,
default: 'Back'
},
className: {
type: String,
default: ''
}
})
const { navigate } = useNavigate()
const goBack = () => {
if (props.to) {
if (typeof props.to === 'string') {
navigate({ page: props.to })
} else {
navigate(props.to)
}
} else if (window.history.length > 1) {
window.history.back()
} else {
navigate({ page: props.fallback })
}
}
</script>
<template>
<button @click="goBack" class="back-button-premium" :class="className" type="button">
<div class="icon-container">
<i class="fas fa-chevron-left"></i>
</div>
<span v-if="text" class="back-text">{{ text }}</span>
</button>
</template>
<style scoped>
.back-button-premium {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--bg-card, rgba(255, 255, 255, 0.8));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.05));
padding: 6px 14px;
border-radius: 50px;
color: var(--text-primary, #333);
font-weight: 600;
font-size: 14px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
outline: none;
}
.back-button-premium:hover {
transform: translateX(-3px);
background: var(--bg-primary, #fff);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
border-color: var(--border-color, rgba(0, 0, 0, 0.1));
}
.back-button-premium:active {
transform: translateX(-1px) scale(0.97);
}
.icon-container {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--bg-tertiary, #f8f9fa);
color: var(--text-primary, #1a1a1a);
border-radius: 50%;
font-size: 10px;
transition: all 0.3s ease;
}
.back-button-premium:hover .icon-container {
background: var(--text-primary, #1a1a1a);
color: var(--bg-primary, #fff);
}
.back-text {
letter-spacing: -0.2px;
}
/* Dark mode support */
:global(.dark-mode) .back-button-premium {
background: rgba(30, 32, 38, 0.8);
border-color: rgba(255, 255, 255, 0.15);
color: #fff;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
}
:global(.dark-mode) .icon-container {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
:global(.dark-mode) .back-button-premium:hover {
background: rgba(55, 60, 75, 1);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
:global(.dark-mode) .back-button-premium:hover .icon-container {
background: #fff;
color: #1a1a1a;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<Teleport to="body">
<!-- modal -->
<div v-if="modelValue" class="modal fade show d-block" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<!-- HEADER -->
<div v-if="showHeader" class="modal-header">
<h4 class="modal-title">{{ modalTitle }}</h4>
<h4 class="cursor-pointer" @click="$emit('update:modelValue', false)">×</h4>
</div>
<!-- BODY -->
<div class="modal-body">
<slot />
<!-- If string, render as text; if component/VNode, render directly -->
<template v-if="isString">
<div v-html="body"></div>
</template>
<template v-else-if="body">
<component :is="body" />
</template>
</div>
<!-- FOOTER -->
<div class="modal-footer w-100">
<slot name="footer" />
<button v-if="footerClose && !$slots.footer" class="btn btn-primary w-100 py-2 rounded-3 shadow-sm fw-bold" @click="$emit('update:modelValue', false)">
OK
</button>
</div>
</div>
</div>
</div>
<!-- backdrop -->
<div v-if="modelValue" class="modal-backdrop fade show"></div>
</Teleport>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: Boolean,
modalTitle: [String, Boolean],
body: [String, Object], // support string or VNode/component
footerClose: { type: Boolean, default: true }
})
const showHeader = computed(() => props.modalTitle !== false)
const isString = computed(() => typeof props.body === 'string')
</script>
<style scoped>
.modal {
z-index: 30001;
}
.modal-content {
max-height: 85vh;
display: flex;
flex-direction: column;
}
.modal-body {
overflow-y: auto;
flex: 1;
}
.modal-backdrop {
z-index: 30000;
}
.modal-dialog {
z-index: 30002;
margin-top: 5vh;
}
.modal-title {
margin: 0;
font-weight: 700;
}
.modal-header h4:last-child {
font-size: 24px;
line-height: 1;
color: #999;
transition: color 0.2s;
}
.modal-header h4:last-child:hover {
color: #333;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div
class="card-custom"
:id="id"
:style="cardStyle"
:class="[
`shadow-${shadow}`,
{ 'is-premium': isPremium, 'no-padding': noPadding }
]"
>
<div v-if="title" class="card-header-custom">
<h4 class="card-title">{{ title }}</h4>
<div v-if="$slots.headerAction" class="header-action">
<slot name="headerAction" />
</div>
</div>
<div class="card-body-custom">
<slot />
</div>
<div v-if="$slots.footer" class="card-footer-custom">
<slot name="footer" />
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, default: '' },
id: { type: String, default: '' },
cardStyle: { type: String, default: '' },
isPremium: { type: Boolean, default: true },
noPadding: { type: Boolean, default: false },
shadow: { type: String, default: 'md', validator: (v) => ['none', 'sm', 'md', 'lg'].includes(v) },
})
</script>
<style scoped>
.card-custom {
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 16px;
overflow: hidden;
transition: all 0.3s ease;
height: 100%;
display: flex;
flex-direction: column;
}
.card-header-custom {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.05));
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary, #1e1e1e);
}
.card-body-custom {
padding: 1.5rem;
flex-grow: 1;
}
.no-padding .card-body-custom {
padding: 0;
}
.card-footer-custom {
padding: 1.25rem 1.5rem;
border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.05));
background: var(--bg-secondary, rgba(0, 0, 0, 0.02));
}
/* Premium / Glassmorphism */
.is-premium {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
}
:global(.dark-mode) .is-premium {
background: rgba(30, 41, 59, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
}
.is-premium:hover {
border-color: var(--accent-color, rgba(83, 61, 234, 0.3));
transform: translateY(-2px);
}
/* Shadows */
.shadow-none { box-shadow: none; }
.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
.shadow-md { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); }
.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); }
</style>

View File

@@ -0,0 +1,115 @@
<template>
<BaseModal
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
:modalTitle="false"
>
<div class="confirm-modal-body p-4 text-center">
<div v-if="variant === 'danger'" class="icon-circle bg-soft-danger text-danger mb-3 mx-auto">
<i class="fas fa-exclamation-triangle fa-2x"></i>
</div>
<div v-else-if="variant === 'warning'" class="icon-circle bg-soft-warning text-warning mb-3 mx-auto">
<i class="fas fa-exclamation-circle fa-2x"></i>
</div>
<div v-else class="icon-circle bg-soft-primary text-primary mb-3 mx-auto">
<i class="fas fa-info-circle fa-2x"></i>
</div>
<h3 class="fw_8 mb-2">{{ title }}</h3>
<p class="text-muted mb-0">{{ message }}</p>
</div>
<template #footer>
<div class="d-flex w-100 gap-2 mb-2 px-2">
<button type="button" class="btn btn-light flex-fill rounded-pill py-2 fw_6" @click="handleCancel">
{{ cancelText }}
</button>
<button type="button" :class="confirmBtnClass" class="btn flex-fill rounded-pill py-2 fw_6" @click="handleConfirm">
{{ confirmText }}
</button>
</div>
</template>
</BaseModal>
</template>
<script setup>
import { computed } from 'vue';
import BaseModal from './BaseModal.vue';
const props = defineProps({
modelValue: { type: Boolean, required: true },
title: { type: String, default: 'Confirm Action' },
message: { type: String, default: 'Are you sure you want to proceed?' },
confirmText: { type: String, default: 'Confirm' },
cancelText: { type: String, default: 'Cancel' },
variant: { type: String, default: 'info' } // danger, warning, info
});
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);
const confirmBtnClass = computed(() => {
if (props.variant === 'danger') return 'btn-danger shadow-danger';
if (props.variant === 'warning') return 'btn-warning shadow-warning';
return 'btn-primary shadow-primary';
});
const handleCancel = () => {
emit('cancel');
emit('update:modelValue', false);
};
const handleConfirm = () => {
emit('confirm');
emit('update:modelValue', false);
};
</script>
<style scoped>
.confirm-modal-body {
min-height: 180px;
display: flex;
flex-direction: column;
justify-content: center;
}
.icon-circle {
width: 70px;
height: 70px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.confirm-modal-body:hover .icon-circle {
transform: scale(1.1);
}
.bg-soft-danger { background-color: rgba(231, 76, 60, 0.1); }
.bg-soft-warning { background-color: rgba(243, 156, 18, 0.1); }
.bg-soft-primary { background-color: rgba(52, 152, 219, 0.1); }
.shadow-danger {
background-color: #e74c3c !important;
border-color: #e74c3c !important;
color: white !important;
box-shadow: 0 4px 14px rgba(231, 76, 60, 0.4);
}
.shadow-warning {
background-color: #f39c12 !important;
border-color: #f39c12 !important;
color: white !important;
box-shadow: 0 4px 14px rgba(243, 156, 18, 0.4);
}
.shadow-primary {
background-color: #3498db !important;
border-color: #3498db !important;
color: white !important;
box-shadow: 0 4px 14px rgba(52, 152, 219, 0.4);
}
:global(.dark-mode) .icon-circle {
filter: brightness(1.2);
}
</style>

View File

@@ -0,0 +1,423 @@
<template>
<div
class="dropzone-container"
:class="{ 'is-dragging': isDragging, 'has-error': !!error }"
@dragenter.prevent="isDragging = true"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="onDrop"
>
<div v-if="files.length === 0" class="dropzone-placeholder" @click="triggerFileInput">
<div class="icon-wrapper">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/b1ef4a83a864.svg" class="cloud-icon" style="width: 48px; height: 48px; opacity: 0.5;" />
</div>
<p class="main-text">Click to upload or drag and drop</p>
<p class="sub-text">SVG, PNG, JPG or GIF (max. {{ maxSizeMB }}MB)</p>
</div>
<div v-else class="previews-grid">
<div v-for="(file, index) in files" :key="index" class="preview-card" :class="{ 'is-main': index === 0 }">
<label v-if="index === 0" class="main-photo-badge">Main Photo</label>
<img :src="file.preview" :alt="file.name" class="preview-img" />
<div class="preview-overlay">
<button v-if="index !== 0" @click.stop="setAsMain(index)" class="set-main-btn" title="Set as main photo">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/8fad7e49d466.svg" style="width: 18px; height: 18px; filter: brightness(0) invert(1);" />
</button>
<button @click.stop="removeFile(index)" class="remove-btn" title="Remove image">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/b6dc254166b4.bin" style="width: 16px; height: 16px; filter: brightness(0) invert(1);" />
</button>
</div>
<div v-if="file.uploading" class="progress-bar">
<div class="progress-fill" :style="{ width: file.progress + '%' }"></div>
</div>
<div v-if="file.error" class="error-badge" :title="file.error">!</div>
</div>
<div class="add-more-card" @click="triggerFileInput" title="Add more images">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/8d3954387e14.svg" style="width: 24px; height: 24px; opacity: 0.6;" />
</div>
</div>
<input
ref="fileInput"
type="file"
multiple
class="hidden-input"
:accept="accept"
@change="onFileChange"
/>
<transition name="fade">
<div v-if="error" class="error-message">{{ error }}</div>
</transition>
</div>
</template>
<script setup>
import { ref, onUnmounted, watch } from 'vue';
const props = defineProps({
files: {
type: Array,
default: () => []
},
accept: {
type: String,
default: 'image/*'
},
maxSizeMB: {
type: Number,
default: 10
}
});
const emit = defineEmits(['update:files', 'removed']);
const isDragging = ref(false);
const fileInput = ref(null);
const files = ref([]);
const error = ref(null);
// Initialize internal files with prop on mount
files.value = [...props.files];
// Sync internal files with prop when it explicitly changes reference or length
watch(() => props.files, (newFiles) => {
if (newFiles !== files.value) {
// Preserve internal hashes if they are not in the new prop yet
// This allows the child to keep the 'hashkey' it just received while the parent is still updating
files.value = newFiles.map((newF, index) => {
const existingF = files.value[index];
if (existingF && existingF.file === newF.file && !newF.hashkey && existingF.hashkey) {
return { ...newF, hashkey: existingF.hashkey };
}
return { ...newF };
});
}
}, { deep: false });
const triggerFileInput = () => {
fileInput.value.click();
};
const validateFile = (file) => {
if (file.size > props.maxSizeMB * 1024 * 1024) {
return { valid: false, error: `File ${file.name} is too large. Max ${props.maxSizeMB}MB.` };
}
const acceptedTypes = props.accept.split(',').map(t => t.trim());
const isAccepted = acceptedTypes.some(type => {
if (type.endsWith('/*')) {
return file.type.startsWith(type.replace('*', ''));
}
return file.type === type;
});
if (!isAccepted && props.accept !== '*/*') {
return { valid: false, error: `File ${file.name} has invalid type.` };
}
return { valid: true };
};
const addFiles = (newFiles) => {
error.value = null;
const filteredFiles = Array.from(newFiles).map(file => {
const validation = validateFile(file);
if (!validation.valid) {
error.value = validation.error;
return null;
}
return {
file,
name: file.name,
preview: URL.createObjectURL(file),
uploading: false,
progress: 0,
error: null,
hashkey: null
};
}).filter(f => f !== null);
files.value = [...files.value, ...filteredFiles];
emit('update:files', files.value);
};
const onFileChange = (e) => {
addFiles(e.target.files);
};
const onDrop = (e) => {
isDragging.value = false;
addFiles(e.dataTransfer.files);
};
const removeFile = (index) => {
const removedFile = files.value[index];
if (removedFile.preview) {
URL.revokeObjectURL(removedFile.preview);
}
files.value.splice(index, 1);
emit('update:files', files.value);
if (removedFile.hashkey) {
emit('removed', removedFile.hashkey);
}
};
const setAsMain = (index) => {
if (index === 0) return;
const mainFile = files.value.splice(index, 1)[0];
files.value.unshift(mainFile);
emit('update:files', files.value);
};
onUnmounted(() => {
files.value.forEach(file => {
if (file.preview) URL.revokeObjectURL(file.preview);
});
});
defineExpose({
clear: () => {
files.value.forEach(file => {
if (file.preview) URL.revokeObjectURL(file.preview);
});
files.value = [];
emit('update:files', []);
},
setFileStatus: (index, status) => {
if (files.value[index]) {
files.value[index] = { ...files.value[index], ...status };
emit('update:files', [...files.value]);
}
}
});
</script>
<style scoped>
.dropzone-container {
width: 100%;
min-height: 180px;
border: 2px dashed #e2e8f0;
border-radius: 16px;
background: #f8fafc;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
padding: 20px;
}
.dropzone-container:hover {
border-color: #3b82f6;
background: #eff6ff;
}
.dropzone-container.is-dragging {
border-color: #3b82f6;
background: #dbeafe;
transform: scale(1.01);
}
.dropzone-container.has-error {
border-color: #ef4444;
background: #fef2f2;
}
.dropzone-placeholder {
text-align: center;
color: #64748b;
}
.icon-wrapper {
margin-bottom: 12px;
color: #94a3b8;
}
.cloud-icon {
width: 48px;
height: 48px;
}
.main-text {
font-weight: 600;
color: #1e293b;
margin-bottom: 4px;
}
.sub-text {
font-size: 0.85rem;
color: #94a3b8;
}
.previews-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
width: 100%;
}
.preview-card {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
background: #fff;
}
.preview-card.is-main {
border: 3px solid #3b82f6;
transform: scale(1.02);
z-index: 2;
}
.main-photo-badge {
position: absolute;
top: 8px;
left: 8px;
background: #2563eb !important;
color: white !important;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
z-index: 20;
box-shadow: 0 4px 6px rgba(37, 99, 235, 0.4);
letter-spacing: 0.05em;
}
.preview-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 8px;
z-index: 25;
}
.preview-card:hover .preview-overlay {
opacity: 1;
transform: translateY(2px);
}
.remove-btn, .set-main-btn {
color: white !important;
border: none;
border-radius: 50%;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
}
.remove-btn {
background: #ef4444 !important;
}
.set-main-btn {
background: #2563eb !important;
}
.remove-btn svg, .set-main-btn svg {
display: block;
pointer-events: none;
}
.remove-btn:hover, .set-main-btn:hover {
transform: scale(1.1);
filter: brightness(1.1);
}
.add-more-card {
aspect-ratio: 1;
border: 2px dashed #cbd5e1;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
background: #fff;
transition: all 0.2s;
}
.add-more-card:hover {
border-color: #3b82f6;
color: #3b82f6;
background: #f1f5f9;
}
.progress-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: rgba(255, 255, 255, 0.3);
}
.progress-fill {
height: 100%;
background: #3b82f6;
transition: width 0.3s;
}
.error-badge {
position: absolute;
top: 4px;
right: 4px;
background: #ef4444;
color: white;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 12px;
}
.error-message {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
background: #ef4444;
color: white;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
white-space: nowrap;
}
.hidden-input {
display: none;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<img :src="processedSrc" :alt="alt" @error="handleError" v-bind="$attrs" />
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
src: { type: String, default: "" },
fallback: { type: String, default: "https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/1c00e9b46132.bin" },
alt: { type: String, default: "" },
});
const processedSrc = computed(() => {
if (!props.src) return props.fallback;
if (Array.isArray(props.src)) return props.src[0];
if (
props.src.startsWith("http") ||
props.src.startsWith("/") ||
props.src.startsWith("data:")
) {
return props.src;
}
// Assume it's a hash if it's long and doesn't have common URL markers
if (
props.src.length > 20 &&
!props.src.includes(".") &&
!props.src.includes("/")
) {
return `/RequestData/File/${props.src}`;
}
return props.src;
});
const handleError = (e) => {
e.target.src = props.fallback;
};
</script>

View File

@@ -0,0 +1,184 @@
<template>
<div :class="['form-group mb-3', { 'has-error': error }]">
<label v-if="label" :for="id" class="form-label d-flex align-items-center">
{{ label }}
<span v-if="required" class="text-danger ms-1">*</span>
</label>
<div
class="input-group-container"
:class="[
`variant-${variant}`,
{ 'is-premium': isPremium, 'is-invalid': error, 'is-disabled': disabled }
]"
>
<div class="input-wrapper">
<input
:type="type"
:id="id"
:name="id"
:class="['form-control-custom', inputClass]"
:placeholder="placeholder"
:value="modelValue"
:required="required"
:disabled="disabled"
:list="datalistId"
@input="$emit('update:modelValue', $event.target.value)"
/>
<div class="input-addons">
<!-- Icon/span append -->
<IconImage
v-if="icon"
:src="icon"
:width="iconWidth"
:height="iconHeight"
:id="`imgspan${id}`"
/>
<span v-else-if="spanClass" :class="spanClass" :id="`${id}-span`"></span>
</div>
</div>
</div>
<div v-if="hint && !error" class="form-hint mt-1">{{ hint }}</div>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconImage from '../IconImage.vue'
const props = defineProps({
label: { type: String, default: '' },
type: { type: String, default: 'text' },
id: { type: String, required: true },
placeholder: { type: String, default: '' },
modelValue: { type: [String, Number], default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
hint: { type: String, default: '' },
isPremium: { type: Boolean, default: true },
variant: { type: String, default: 'default', validator: (v) => ['default', 'glass', 'soft'].includes(v) },
inputClass: { type: String, default: '' },
spanClass: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: '20px' },
iconHeight: { type: [String, Number], default: '20px' },
datalistId: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.form-group {
width: 100%;
}
.form-label {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary, #1e1e1e);
}
.input-group-container {
position: relative;
transition: all 0.2s ease;
}
.input-wrapper {
display: flex;
align-items: center;
position: relative;
}
.form-control-custom {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 12px;
color: var(--text-primary, #1e1e1e);
font-size: 1rem;
transition: all 0.2s ease;
}
:global(.dark-mode) .form-control-custom {
background: var(--bg-secondary, #1a1c22);
}
.form-control-custom:focus {
outline: none;
border-color: var(--accent-color, #533dea);
box-shadow: 0 0 0 3px var(--accent-soft, rgba(83, 61, 234, 0.2));
}
/* Glass Variant */
.variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
:global(.dark-mode) .variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.variant-glass .form-control-custom:focus {
background: rgba(255, 255, 255, 0.6);
border-color: var(--accent-color, #533dea);
}
:global(.dark-mode) .variant-glass .form-control-custom:focus {
background: rgba(255, 255, 255, 0.1);
}
/* Soft Variant */
.variant-soft .form-control-custom {
background: var(--bg-tertiary, #f0f2f5);
border: none;
}
/* Premium Styles */
.is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
:global(.dark-mode) .is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
/* States */
.is-invalid .form-control-custom {
border-color: #ef4444;
}
.is-disabled .form-control-custom {
opacity: 0.6;
cursor: not-allowed;
}
.input-addons {
position: absolute;
right: 1rem;
display: flex;
align-items: center;
pointer-events: none;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-muted, #a0a0a0);
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<button
:class="[
'btn-custom',
`btn-${variant}`,
`btn-${size}`,
{ 'is-loading': loading, 'is-disabled': disabled || loading }
]"
:id="id"
:style="buttonStyle"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<div class="btn-content">
<LoadingSpinner v-if="loading" size="sm" color="currentColor" class="me-2" />
<slot>
{{ text }}
</slot>
</div>
</button>
</template>
<script setup>
import LoadingSpinner from '@/Components/LoadingSpinner.vue'
defineProps({
text: { type: String, default: '' },
variant: { type: String, default: 'primary', validator: (v) => ['primary', 'secondary', 'outline', 'danger', 'glass', 'text'].includes(v) },
id: { type: String, default: '' },
buttonStyle: { type: String, default: '' },
loading: { type: Boolean, default: false },
size: { type: String, default: 'md', validator: (v) => ['sm', 'md', 'lg'].includes(v) },
disabled: { type: Boolean, default: false },
})
defineEmits(['click'])
</script>
<style scoped>
.btn-custom {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
border-radius: 12px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
cursor: pointer;
position: relative;
overflow: hidden;
gap: 0.5rem;
}
.btn-content {
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
/* Sizes */
.btn-sm { padding: 0.5rem 1rem; font-size: 0.875rem; }
.btn-md { padding: 0.75rem 1.5rem; font-size: 1rem; }
.btn-lg { padding: 1rem 2rem; font-size: 1.125rem; }
/* Variants */
.btn-primary {
background: linear-gradient(135deg, var(--accent-color, #4f46e5) 0%, #3730a3 100%);
color: white;
box-shadow: 0 4px 6px -1px var(--accent-soft, rgba(79, 70, 229, 0.4));
}
.btn-primary:hover:not(.is-disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px var(--accent-soft, rgba(79, 70, 229, 0.5));
}
.btn-secondary {
background: var(--bg-tertiary, #f0f2f5);
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-secondary {
background: var(--bg-tertiary, #2d3748);
color: white;
}
.btn-secondary:hover:not(.is-disabled) {
background: var(--border-color, #e2e8f0);
}
:global(.dark-mode) .btn-secondary:hover:not(.is-disabled) {
background: #3d4a5d;
}
.btn-outline {
background: transparent;
border-color: var(--border-color, rgba(0, 0, 0, 0.1));
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-outline {
border-color: rgba(255, 255, 255, 0.2);
color: white;
}
.btn-outline:hover:not(.is-disabled) {
background: var(--accent-soft, rgba(0, 0, 0, 0.05));
border-color: var(--accent-color, rgba(0, 0, 0, 0.4));
}
:global(.dark-mode) .btn-outline:hover:not(.is-disabled) {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.4);
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover:not(.is-disabled) {
background: #dc2626;
}
.btn-glass {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-glass {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
}
.btn-glass:hover:not(.is-disabled) {
background: rgba(255, 255, 255, 0.5);
}
:global(.dark-mode) .btn-glass:hover:not(.is-disabled) {
background: rgba(255, 255, 255, 0.15);
}
.btn-text {
background: transparent;
color: var(--text-secondary, #717171);
}
.btn-text:hover:not(.is-disabled) {
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-text:hover:not(.is-disabled) {
color: white;
}
/* States */
.is-disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.is-loading {
cursor: wait;
}
.btn-custom:active:not(.is-disabled) {
transform: scale(0.98);
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div :class="['form-check-container mb-3', { 'has-error': error, 'is-switch': isSwitch }]">
<label :for="id" class="checkbox-wrapper">
<input
type="checkbox"
:id="id"
class="checkbox-input"
:checked="isChecked"
:disabled="disabled"
:value="value"
@change="handleChange"
/>
<div class="checkbox-custom" :class="{ 'is-premium': isPremium }">
<div v-if="isSwitch" class="switch-handle"></div>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" class="check-icon"><path d="M20 6 9 17l-5-5"/></svg>
</div>
<span class="checkbox-label" v-if="label">{{ label }}</span>
</label>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
modelValue: { type: [Boolean, Array], default: false },
value: { type: [String, Number, Boolean], default: null },
disabled: { type: Boolean, default: false },
isSwitch: { type: Boolean, default: false },
isPremium: { type: Boolean, default: true },
error: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const isChecked = computed(() => {
if (Array.isArray(props.modelValue)) {
return props.modelValue.includes(props.value)
}
return props.modelValue
})
const handleChange = (event) => {
const checked = event.target.checked
if (Array.isArray(props.modelValue)) {
const newValue = [...props.modelValue]
if (checked) {
newValue.push(props.value)
} else {
const index = newValue.indexOf(props.value)
if (index > -1) newValue.splice(index, 1)
}
emit('update:modelValue', newValue)
} else {
emit('update:modelValue', checked)
}
}
</script>
<style scoped>
.form-check-container {
display: block;
min-height: 1.5rem;
}
.checkbox-wrapper {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
gap: 0.75rem;
}
.checkbox-input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
/* Custom Checkbox Design */
.checkbox-custom {
position: relative;
height: 22px;
width: 22px;
background: var(--bg-secondary, #f8f9fa);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
border-radius: 6px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
}
:global(.dark-mode) .checkbox-custom {
background: var(--bg-tertiary, #24272d);
border-color: rgba(255, 255, 255, 0.1);
}
.is-switch .checkbox-custom {
width: 44px;
border-radius: 20px;
}
.checkbox-input:checked ~ .checkbox-custom {
background: var(--accent-color, #533dea);
border-color: var(--accent-color, #533dea);
box-shadow: 0 4px 6px -1px var(--accent-soft, rgba(83, 61, 234, 0.4));
}
.check-icon {
color: white;
opacity: 0;
transform: scale(0.5);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-input:checked ~ .checkbox-custom .check-icon {
opacity: 1;
transform: scale(1);
}
/* Switch Handle */
.switch-handle {
position: absolute;
left: 3px;
top: 3px;
height: 14px;
width: 14px;
background: white;
border-radius: 50%;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-input:checked ~ .checkbox-custom .switch-handle {
left: 25px;
}
/* Premium Effects */
.is-premium {
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.checkbox-wrapper:hover .checkbox-custom {
border-color: var(--accent-color, rgba(83, 61, 234, 0.3));
}
.checkbox-label {
font-size: 0.875rem;
color: var(--text-primary, #1e1e1e);
font-weight: 500;
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
margin-left: 2rem;
}
.checkbox-input:disabled ~ .checkbox-custom {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div>
<label v-if="label" :for="id">
<span v-html="label"></span>
<IconImage
v-if="!disabled"
src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/b6dc254166b4.bin"
:id="`ClearUploadButton-${id}`"
class="clear-upload-btn"
@click="$emit('clear')"
/>
</label>
<div class="input-group mb-3">
<form
:id="id"
:action="uploadUrl"
:style="{ width: computedWidth, pointerEvents: disabled ? 'none' : 'auto' }"
class="dropzone"
:class="{ disabled: disabled }"
>
<slot>
<div class="dz-message">Drop files here or click to upload</div>
</slot>
</form>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconImage from '../IconImage.vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
uploadUrl: { type: String, default: '/File/Upload/Unknown' },
width: { type: String, default: '100%' },
disabled: { type: Boolean, default: false },
})
defineEmits(['files-changed', 'clear'])
const computedWidth = computed(() => props.width || '100%')
</script>
<style scoped>
.clear-upload-btn {
cursor: pointer;
margin-left: 0.5rem;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<InputGroup
:label="label"
type="number"
:id="id"
:placeholder="placeholder"
:model-value="modelValue"
:required="required"
:disabled="disabled"
:input-class="inputClass"
:icon="icon"
:icon-width="iconWidth"
:icon-height="iconHeight"
:datalist-id="datalistId"
v-bind="numberAttrs"
@update:model-value="$emit('update:modelValue', $event)"
/>
</template>
<script setup>
import { computed } from 'vue'
import InputGroup from './InputGroup.vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
placeholder: { type: String, default: '' },
modelValue: { type: [String, Number], default: 0 },
min: { type: [String, Number], default: '' },
max: { type: [String, Number], default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
inputClass: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: '40px' },
iconHeight: { type: [String, Number], default: '40px' },
datalistId: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
const numberAttrs = computed(() => {
const attrs = {}
if (props.min !== '') attrs.min = props.min
if (props.max !== '') attrs.max = props.max
return attrs
})
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div :class="['form-group mb-3', { 'has-error': error }]">
<label v-if="label" :for="id" class="form-label d-flex align-items-center">
{{ label }}
<span v-if="required" class="text-danger ms-1">*</span>
</label>
<div
class="input-group-container"
:class="[
`variant-${variant}`,
{ 'is-premium': isPremium, 'is-invalid': error, 'is-disabled': disabled }
]"
>
<div class="input-wrapper">
<select
:id="id"
:class="['form-control-custom', selectClass]"
:disabled="disabled"
:value="modelValue"
:required="required"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-if="placeholder" value="" disabled selected>{{ placeholder }}</option>
<option
v-for="(opt, index) in formattedOptions"
:key="index"
:value="opt.value"
>
{{ opt.text }}
</option>
</select>
<div class="select-arrow">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div v-if="icon || spanClass" class="input-addons">
<IconImage v-if="icon" :src="icon" :width="iconWidth" :height="iconHeight" />
<span v-else-if="spanClass" :class="spanClass"></span>
</div>
</div>
</div>
<div v-if="hint && !error" class="form-hint mt-1">{{ hint }}</div>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconImage from '../IconImage.vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
modelValue: { type: [String, Number], default: '' },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
hint: { type: String, default: '' },
isPremium: { type: Boolean, default: true },
variant: { type: String, default: 'default' },
selectClass: { type: String, default: '' },
spanClass: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: '20px' },
iconHeight: { type: [String, Number], default: '20px' },
})
defineEmits(['update:modelValue'])
const formattedOptions = computed(() => {
return props.options.map(opt => {
if (typeof opt === 'string' || typeof opt === 'number') {
return { value: opt, text: opt }
}
return opt
})
})
</script>
<style scoped>
.form-group {
width: 100%;
}
.form-label {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary, #1e1e1e);
}
.input-group-container {
position: relative;
transition: all 0.2s ease;
}
.input-wrapper {
display: flex;
align-items: center;
position: relative;
}
.form-control-custom {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 12px;
color: var(--text-primary, #1e1e1e);
font-size: 1rem;
appearance: none;
-webkit-appearance: none;
transition: all 0.2s ease;
cursor: pointer;
}
:global(.dark-mode) .form-control-custom {
background: var(--bg-secondary, #1a1c22);
}
.form-control-custom:focus {
outline: none;
border-color: var(--accent-color, #533dea);
box-shadow: 0 0 0 3px var(--accent-soft, rgba(83, 61, 234, 0.2));
}
.select-arrow {
position: absolute;
right: 1rem;
pointer-events: none;
color: var(--text-muted, #a0a0a0);
display: flex;
align-items: center;
}
/* Glass Variant */
.variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
:global(.dark-mode) .variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
/* Premium Styles */
.is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
:global(.dark-mode) .is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
/* States */
.is-invalid .form-control-custom {
border-color: #ef4444;
}
.is-disabled .form-control-custom {
opacity: 0.6;
cursor: not-allowed;
}
.input-addons {
position: absolute;
right: 2.5rem;
display: flex;
align-items: center;
pointer-events: none;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-muted, #a0a0a0);
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div :class="['form-group mb-3', { 'has-error': error }]">
<label v-if="label" :for="id" class="form-label d-flex align-items-center">
{{ label }}
<span v-if="required" class="text-danger ms-1">*</span>
</label>
<div
class="input-group-container"
:class="[
`variant-${variant}`,
{ 'is-premium': isPremium, 'is-invalid': error, 'is-disabled': disabled }
]"
>
<textarea
:id="id"
:name="id"
:rows="rows"
:class="['form-control-custom', textareaClass]"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
></textarea>
</div>
<div v-if="hint && !error" class="form-hint mt-1">{{ hint }}</div>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
rows: { type: Number, default: 3 },
error: { type: String, default: '' },
hint: { type: String, default: '' },
isPremium: { type: Boolean, default: true },
variant: { type: String, default: 'default' },
textareaClass: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.form-group {
width: 100%;
}
.form-label {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary, #1e1e1e);
}
.input-group-container {
position: relative;
transition: all 0.2s ease;
}
.form-control-custom {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 12px;
color: var(--text-primary, #1e1e1e);
font-size: 1rem;
transition: all 0.2s ease;
resize: vertical;
}
:global(.dark-mode) .form-control-custom {
background: var(--bg-secondary, #1a1c22);
}
.form-control-custom:focus {
outline: none;
border-color: var(--accent-color, #533dea);
box-shadow: 0 0 0 3px var(--accent-soft, rgba(83, 61, 234, 0.2));
}
/* Glass Variant */
.variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
:global(.dark-mode) .variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
/* Premium Styles */
.is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
:global(.dark-mode) .is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
/* States */
.is-invalid .form-control-custom {
border-color: #ef4444;
}
.is-disabled .form-control-custom {
opacity: 0.6;
cursor: not-allowed;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-muted, #a0a0a0);
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<img
v-if="src"
:src="src"
:style="imgStyle"
:id="id"
class="icon-user"
@click="$emit('click', $event)"
/>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
src: { type: String, required: true },
width: { type: [String, Number], default: 30 },
height: { type: [String, Number], default: 30 },
id: { type: String, default: '' },
})
defineEmits(['click'])
const normalize = (val) => {
if (typeof val === 'number') return `${val}px`
if (typeof val === 'string' && /^\d+$/.test(val)) return `${val}px`
return val
}
const imgStyle = computed(() => ({
width: normalize(props.width),
height: normalize(props.height),
}))
</script>

View File

@@ -0,0 +1,42 @@
<template>
<button
:id="idtext"
:value="value"
:class="computedClass"
v-bind="additionalData"
@click="handleClick"
>
<img v-if="img" :src="img" class="btn-icon" />
<slot>{{ content }}</slot>
</button>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
content: { type: String, default: '' },
value: { type: [String, Number], default: '' },
idtext: { type: String, default: '' },
btnClass: { type: String, default: '' }, // custom classes
block: { type: Boolean, default: false },
img: { type: String, default: '' },
additionalData: { type: Object, default: () => ({}) },
onClick: { type: Function, default: null }
})
const computedClass = computed(() => {
return `${props.btnClass} ${props.block ? 'btn-block' : ''}`.trim()
})
function handleClick(event) {
if (props.onClick) props.onClick(event)
}
</script>
<style scoped>
.btn-icon {
margin-right: 0.5rem;
height: 1em;
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<BaseButton v-bind="$props" btn-class="btn btn-danger" />
</template>
<script setup>
import BaseButton from './BaseButton.vue'
defineProps({
content: String,
value: String,
idtext: String,
block: Boolean,
img: String,
additionalData: Object,
onClick: Function
})
</script>

View File

@@ -0,0 +1,18 @@
<template>
<BaseButton v-bind="$props" :btn-class="btnClass || 'btn btn-primary'">
<slot />
</BaseButton>
</template>
<script setup>
import BaseButton from './BaseButton.vue'
defineProps({
content: String,
value: String,
idtext: String,
block: Boolean,
img: String,
btnClass: String,
additionalData: Object,
onClick: Function
})
</script>

View File

@@ -0,0 +1,18 @@
<template>
<BaseButton v-bind="$props" :btn-class="btnClass || 'btn btn-warning'">
<slot />
</BaseButton>
</template>
<script setup>
import BaseButton from './BaseButton.vue'
defineProps({
content: String,
value: String,
idtext: String,
block: Boolean,
img: String,
btnClass: String,
additionalData: Object,
onClick: Function
})
</script>

View File

@@ -0,0 +1,11 @@
<template>
<div class="col" :class="colClass">
<slot />
</div>
</template>
<script setup>
defineProps({
colClass: { type: String, default: '' }
})
</script>

View File

@@ -0,0 +1,21 @@
<template>
<Row :row-class="rowClass" :hidden="hidden" :style="style">
<Col>
<slot name="left" />
</Col>
<Col>
<slot name="right" />
</Col>
</Row>
</template>
<script setup>
import Row from './Row.vue'
import Col from './Col.vue'
defineProps({
rowClass: { type: String, default: '' },
hidden: { type: Boolean, default: false },
style: { type: String, default: '' }
})
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div
class="row"
:class="rowClass"
:style="computedStyle"
v-show="!hidden"
>
<slot />
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
rowClass: { type: String, default: '' },
hidden: { type: Boolean, default: false },
style: { type: String, default: '' }
})
const computedStyle = computed(() => props.style)
</script>

View File

@@ -0,0 +1,130 @@
<template>
<div class="box-search mt-3">
<div class="input-field">
<!-- Left icon - only show one -->
<IconImage v-if="leftIcon" :src="leftIcon" :width="iconWidth" :height="iconHeight" />
<i v-else class="fas fa-search search-icon"></i>
<input
:id="id"
class="search-field value_input"
:placeholder="placeholder"
type="text"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
<i
v-if="modelValue"
class="fas fa-times clear-icon"
:id="`clear-${id}`"
@click="$emit('update:modelValue', ''); $emit('clear')"
></i>
</div>
<!-- Right icon -->
<IconImage
v-if="rightIcon"
:src="rightIcon"
:width="iconWidth"
:height="iconHeight"
:id="`${id}-rightsearchicon`"
/>
</div>
</template>
<script setup>
import IconImage from '../IconImage.vue'
defineProps({
id: { type: String, default: 'search' },
placeholder: { type: String, default: 'Search' },
modelValue: { type: String, default: '' },
leftIcon: { type: String, default: '' },
rightIcon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: 30 },
iconHeight: { type: [String, Number], default: 30 },
})
defineEmits(['update:modelValue', 'clear'])
</script>
<style scoped>
.box-search {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
}
.input-field {
flex: 1;
display: flex;
align-items: center;
background: var(--bg-secondary, #f5f5f5);
border-radius: 12px;
padding: 10px 16px;
gap: 10px;
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
transition: all 0.2s ease;
}
.input-field:focus-within {
border-color: var(--accent-color, #533dea);
box-shadow: 0 0 0 3px var(--accent-soft, rgba(83, 61, 234, 0.1));
}
.search-field {
flex: 1;
border: none;
background: transparent;
font-size: 0.95rem;
color: var(--text-primary, #1e1e1e);
outline: none;
min-width: 0;
}
.search-field::placeholder {
color: var(--text-muted, #a0a0a0);
}
.search-icon {
color: var(--text-muted, #717171);
font-size: 0.9rem;
flex-shrink: 0;
}
.clear-icon {
color: var(--text-muted, #717171);
font-size: 0.85rem;
cursor: pointer;
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.2s;
}
.clear-icon:hover {
opacity: 1;
color: var(--text-primary, #1e1e1e);
}
/* Dark mode support */
:global(.dark-mode) .input-field {
background: var(--bg-secondary, #1a1c22);
border-color: var(--border-color, rgba(255, 255, 255, 0.08));
}
:global(.dark-mode) .search-field {
color: var(--text-primary, #e0e0e0);
}
:global(.dark-mode) .search-icon {
color: var(--text-muted, #b0b0b0);
}
:global(.dark-mode) .clear-icon {
color: var(--text-muted, #b0b0b0);
}
:global(.dark-mode) .clear-icon:hover {
color: var(--text-primary, #e0e0e0);
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div>
<!-- Search bar (optional) -->
<SearchBar
v-if="searchable"
v-model="searchQuery"
:id="searchId"
:placeholder="searchPlaceholder"
:left-icon="searchLeftIcon"
:right-icon="searchRightIcon"
@clear="searchQuery = ''"
/>
<!-- Title + View All header -->
<h3 class="fw_6 d-flex justify-content-between mt-3 align-items-center">
<span>{{ title }}</span>
<a
v-if="viewAllText"
href="javascript:void(0);"
class="small fw_4 text-primary"
@click="$emit('view-all')"
>
{{ viewAllText }}
</a>
</h3>
<!-- Loading State -->
<div v-if="loading" class="mt-3">
<div v-for="i in 3" :key="i" class="d-flex align-items-center gap-3 mb-3 p-2">
<SkeletonBlock width="40px" height="40px" border-radius="12px" />
<div class="flex-grow-1">
<SkeletonText width="60%" height="16px" class="mb-2" />
<SkeletonText width="40%" height="12px" />
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!loading && filteredItems.length === 0" class="text-center py-4">
<i class="fas fa-inbox fa-2x text-muted opacity-50 mb-2"></i>
<p class="text-muted small mb-0">{{ emptyText }}</p>
</div>
<!-- Searchable list items -->
<ul v-if="!loading && filteredItems.length > 0" class="activity-list mt-3 mb-5 px-0">
<SearchableListItem
v-for="(item, index) in filteredItems"
:key="index"
:title="item.title"
:subtitle="formatSubtitle(item)"
:right-text="formatTimestamp(item.timestamp || item.rightText)"
:search-class="item.searchClass || ''"
:icon="item.icon || ''"
:icon-width="item.iconWidth || 30"
:icon-height="item.iconHeight || 30"
@click="$emit('item-click', item, index)"
/>
</ul>
<!-- Optional arrow button list -->
<slot name="arrow-buttons" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import SearchBar from './SearchBar.vue'
import SearchableListItem from './SearchableListItem.vue'
import SkeletonText from '../Skeleton/SkeletonText.vue'
import SkeletonBlock from '../Skeleton/SkeletonBlock.vue'
const props = defineProps({
title: { type: String, default: '' },
viewAllText: { type: String, default: '' },
searchable: { type: Boolean, default: false },
searchId: { type: String, default: 'searchable-list' },
searchPlaceholder: { type: String, default: 'Search' },
searchLeftIcon: { type: String, default: '' },
searchRightIcon: { type: String, default: '' },
emptyText: { type: String, default: 'No recent activity' },
/**
* Array of { title, subtitle?, rightText?, timestamp?, searchClass?, icon?, iconWidth?, iconHeight? }
*/
items: { type: Array, required: true },
loading: { type: Boolean, default: false },
})
defineEmits(['view-all', 'item-click'])
const searchQuery = ref('')
const filteredItems = computed(() => {
if (!searchQuery.value) return props.items
const q = searchQuery.value.toLowerCase()
return props.items.filter(item => {
const text = `${item.title} ${item.subtitle || ''}`.toLowerCase()
return text.includes(q)
})
})
const formatTimestamp = (ts) => {
if (!ts) return ''
try {
const date = new Date(ts)
const now = new Date()
const diffMs = now - date
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
} catch {
return ''
}
}
const formatSubtitle = (item) => {
if (item.subtitle) return item.subtitle
if (item.type === 'transaction') return 'System transaction'
return ''
}
</script>
<style scoped>
.activity-list {
list-style: none;
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<li class="activity-list-item" :class="searchClass" @click="$emit('click', $event)">
<div class="activity-icon-box">
<img
v-if="icon"
:src="icon"
:style="{ width: normalizeSize(iconWidth), height: normalizeSize(iconHeight) }"
class="activity-icon"
/>
<i v-else class="fas fa-circle activity-icon-default"></i>
</div>
<div class="activity-content">
<div class="activity-header">
<h4 class="activity-title">
<a href="javascript:void(0);">
{{ title }}
</a>
</h4>
<span v-if="rightText" class="activity-right-text">{{ rightText }}</span>
</div>
<p v-if="subtitle" class="activity-subtitle">{{ subtitle }}</p>
</div>
</li>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
rightText: { type: String, default: '' },
searchClass: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: 30 },
iconHeight: { type: [String, Number], default: 30 },
})
defineEmits(['click'])
const normalizeSize = (val) => {
if (typeof val === 'number') return `${val}px`
if (typeof val === 'string' && /^\d+$/.test(val)) return `${val}px`
return val
}
</script>
<style scoped>
.activity-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 8px;
border-radius: 12px;
transition: background-color 0.2s ease;
cursor: pointer;
border-bottom: 1px solid var(--bs-border-color-translucent, #ededed);
}
.activity-list-item:last-child {
border-bottom: none;
}
.activity-list-item:hover {
background-color: var(--bs-tertiary-bg, rgba(0, 0, 0, 0.03));
}
.activity-icon-box {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bs-primary-bg-subtle, rgba(13, 110, 253, 0.08));
overflow: hidden;
}
.activity-icon {
object-fit: contain;
border-radius: 4px;
}
.activity-icon-default {
font-size: 12px;
color: var(--bs-secondary-color, #717171);
}
.activity-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.activity-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.activity-title {
font-size: 0.875rem;
font-weight: 600;
line-height: 1.3;
margin: 0;
color: var(--bs-emphasis-color, #1e1e1e);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-title a {
color: inherit;
text-decoration: none;
}
.activity-right-text {
font-size: 0.75rem;
font-weight: 500;
color: var(--bs-primary, #533dea);
white-space: nowrap;
}
.activity-subtitle {
font-size: 0.75rem;
line-height: 1.4;
margin: 0;
color: var(--bs-secondary-color, #717171);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<div id="tableContainer">
<table :id="tableId" class="display">
<thead>
<tr>
<th v-for="(header, i) in computedHeaders" :key="i">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIdx) in filteredData"
:key="rowIdx"
@click="$emit('row-click', row, rowIdx)"
class="searchable-row"
>
<td v-for="(header, colIdx) in computedHeaders" :key="colIdx">
{{ isObjectData ? (row[header] ?? '') : (row[colIdx] ?? '') }}
</td>
</tr>
</tbody>
</table>
<!-- Simple pagination -->
<div v-if="totalPages > 1" class="table-pagination mt-3 d-flex justify-content-between align-items-center">
<button class="btn btn-sm btn-default" :disabled="currentPage <= 1" @click="currentPage--">
Previous
</button>
<span>Page {{ currentPage }} of {{ totalPages }}</span>
<button class="btn btn-sm btn-default" :disabled="currentPage >= totalPages" @click="currentPage++">
Next
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
/**
* Array of objects or array of arrays
*/
data: { type: Array, required: true },
headers: { type: Array, default: () => [] },
tableId: { type: String, default: 'dynamicTable' },
pageLength: { type: Number, default: 10 },
defaultSort: { type: Object, default: () => ({ column: 0, direction: 'asc' }) },
defaultSearch: { type: String, default: '' },
})
defineEmits(['row-click'])
const currentPage = ref(1)
const searchTerm = ref(props.defaultSearch)
const isObjectData = computed(() =>
props.data.length > 0 &&
props.data[0] !== null &&
typeof props.data[0] === 'object' &&
!Array.isArray(props.data[0])
)
const computedHeaders = computed(() => {
if (props.headers.length > 0) return props.headers
if (isObjectData.value) return Object.keys(props.data[0])
return []
})
const sortedData = computed(() => {
const dataCopy = [...props.data]
const { column, direction } = props.defaultSort
dataCopy.sort((a, b) => {
const valA = isObjectData.value ? a[computedHeaders.value[column]] : a[column]
const valB = isObjectData.value ? b[computedHeaders.value[column]] : b[column]
if (valA < valB) return direction === 'asc' ? -1 : 1
if (valA > valB) return direction === 'asc' ? 1 : -1
return 0
})
return dataCopy
})
const filteredData = computed(() => {
let result = sortedData.value
if (searchTerm.value) {
const q = searchTerm.value.toLowerCase()
result = result.filter(row => {
const text = isObjectData.value
? Object.values(row).join(' ')
: row.join(' ')
return text.toLowerCase().includes(q)
})
}
// Pagination
const start = (currentPage.value - 1) * props.pageLength
return result.slice(start, start + props.pageLength)
})
const totalPages = computed(() => {
const total = sortedData.value.length
return Math.max(1, Math.ceil(total / props.pageLength))
})
</script>
<style scoped>
.searchable-row {
cursor: pointer;
}
.searchable-row:hover {
background-color: rgba(0, 0, 0, 0.05);
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<div class="searchable-table-wrapper">
<!-- Toolbar: Search + Density on same line -->
<div class="toolbar-row d-flex align-items-center gap-3 mb-4">
<div class="search-container flex-grow-1">
<SearchBar
v-model="searchModel"
:placeholder="searchPlaceholder"
/>
</div>
<div class="density-container flex-shrink-0">
<TableDensityToggle v-model="densityModel" />
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="mt-2">
<SkeletonTable :rows="skeletonRows" :columns="skeletonColumns" />
</div>
<!-- Error State -->
<div v-else-if="error" class="alert alert-danger">{{ error }}</div>
<!-- Empty State -->
<div v-else-if="empty" class="text-center py-5 no-results">
<slot name="empty-state">
<i :class="[emptyIcon, 'fa-4x text-muted opacity-25 mb-3']"></i>
<h5>{{ emptyTitle }}</h5>
<p class="text-muted">{{ emptyMessage }}</p>
</slot>
</div>
<!-- Table Content -->
<div v-else class="card border-0 shadow-sm rounded-20" :data-table-density="densityModel">
<div class="table-responsive">
<table class="table table-hover align-middle density-table mb-0" :class="tableDensityClass">
<slot name="table"></slot>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import SearchBar from './Search/SearchBar.vue'
import TableDensityToggle from './TableDensityToggle.vue'
import SkeletonTable from './Skeleton/SkeletonTable.vue'
const props = defineProps({
// Search
searchValue: { type: String, default: '' },
searchPlaceholder: { type: String, default: 'Search...' },
// Density
densityValue: { type: String, default: 'comfortable' },
// States
loading: { type: Boolean, default: false },
error: { type: String, default: null },
empty: { type: Boolean, default: false },
// Empty state customization
emptyIcon: { type: String, default: 'fas fa-inbox' },
emptyTitle: { type: String, default: 'No results found' },
emptyMessage: { type: String, default: 'Try adjusting your search criteria' },
// Skeleton config
skeletonRows: { type: Number, default: 8 },
skeletonColumns: { type: Number, default: 6 },
})
const emit = defineEmits(['update:searchValue', 'update:densityValue'])
const searchModel = computed({
get: () => props.searchValue,
set: (val) => emit('update:searchValue', val)
})
const densityModel = computed({
get: () => props.densityValue,
set: (val) => emit('update:densityValue', val)
})
const tableDensityClass = computed(() => {
return {
'density-comfortable': props.densityValue === 'comfortable',
'density-compact': props.densityValue === 'compact',
'density-ultra': props.densityValue === 'ultra-compact'
}
})
</script>
<style scoped>
.searchable-table-wrapper {
width: 100%;
}
.toolbar-row {
flex-wrap: nowrap;
}
.search-container {
min-width: 0;
flex: 1;
max-width: 50%;
}
.density-container {
flex: 1;
max-width: 50%;
}
.no-results {
background: var(--bg-card);
border-radius: 20px;
border: 2px dashed var(--border-color);
}
/* Table Density Styles */
.density-table :deep(thead th) {
transition: padding 0.2s ease;
}
.density-table :deep(tbody td) {
transition: padding 0.2s ease;
}
/* Comfortable - Default */
.density-comfortable :deep(thead th) {
padding: 1rem;
}
.density-comfortable :deep(tbody td) {
padding: 1rem;
}
/* Compact */
.density-compact :deep(thead th) {
padding: 0.625rem 0.75rem;
}
.density-compact :deep(tbody td) {
padding: 0.5rem 0.75rem;
}
.density-compact :deep(.store-thumb),
.density-compact :deep(.product-thumb) {
width: 36px !important;
height: 36px !important;
}
/* Ultra Compact */
.density-ultra :deep(thead th) {
padding: 0.375rem 0.5rem;
font-size: 0.7rem;
}
.density-ultra :deep(tbody td) {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.density-ultra :deep(.store-thumb),
.density-ultra :deep(.product-thumb) {
width: 28px !important;
height: 28px !important;
}
.density-ultra :deep(.btn-icon) {
width: 26px !important;
height: 26px !important;
font-size: 0.7rem;
}
.density-ultra :deep(.small) {
font-size: 0.75rem;
}
.density-ultra :deep(.smallest) {
font-size: 0.65rem;
}
/* Responsive */
@media (max-width: 768px) {
.toolbar-row {
flex-wrap: wrap;
}
.search-container {
max-width: 100%;
width: 100%;
margin-bottom: 0.5rem;
}
.density-container {
width: 100%;
}
}
/* Dark Mode */
:global(.dark-mode) .no-results {
background: var(--bg-card) !important;
border-color: var(--border-color) !important;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="list-bill-view mb-4" @click="$emit('click', $event)">
<IconImage
v-if="icon"
:src="icon"
:width="iconWidth"
:height="iconHeight"
/>
<div class="content">
<h4>
<a href="javascript:void(0);" class="fw_6">{{ title }}</a>
</h4>
<p>{{ subtitle }}</p>
</div>
<i class="fas fa-chevron-right"></i>
</div>
</template>
<script setup>
import IconImage from '../IconImage.vue'
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: 30 },
iconHeight: { type: [String, Number], default: 30 },
})
defineEmits(['click'])
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div>
<ListArrowButton
v-for="(item, index) in items"
:key="index"
:title="item.title"
:subtitle="item.subtitle || ''"
:icon="item.icon || ''"
:icon-width="item.iconWidth || 30"
:icon-height="item.iconHeight || 30"
@click="$emit('item-click', item, index)"
/>
</div>
</template>
<script setup>
import ListArrowButton from './ListArrowButton.vue'
defineProps({
/**
* Array of { title, subtitle?, icon?, iconWidth?, iconHeight? }
*/
items: { type: Array, required: true },
})
defineEmits(['item-click'])
</script>

View File

@@ -0,0 +1,20 @@
<template>
<li>
<a href="javascript:void(0);" @click="$emit('click', $event)">
<div :class="['icon-box', bgColor8 ? 'bg_color_8' : '']" style="background: transparent !important; border: none !important;">
<img :src="icon" />
</div>
<span style="color: var(--text-primary);">{{ title }}</span>
</a>
</li>
</template>
<script setup>
defineProps({
icon: { type: String, required: true },
title: { type: String, default: '' },
bgColor8: { type: Boolean, default: false },
})
defineEmits(['click'])
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div class="mt-5">
<div class="tf-container">
<div class="tf-title d-flex justify-content-between">
<h3 class="fw_6">{{ title }}</h3>
<a
v-if="viewAllText"
href="javascript:void(0);"
class="primary_color fw_6"
@click="$emit('view-all')"
>
{{ viewAllText }}
</a>
</div>
<ul class="box-service mt-3">
<ServiceButton
v-for="(item, index) in items"
:key="index"
:icon="item.icon"
:title="item.title"
:bg-color8="item.bgColor8 || false"
@click="$emit('item-click', item, index)"
@mouseenter="prefetchPage(item.pagename)"
@touchstart.passive="prefetchPage(item.pagename)"
/>
</ul>
</div>
</div>
</template>
<script setup>
import ServiceButton from './ServiceButton.vue'
defineProps({
title: { type: String, default: '' },
viewAllText: { type: String, default: '' },
/**
* Array of { icon, title, bgColor8? }
*/
items: { type: Array, required: true },
})
defineEmits(['view-all', 'item-click'])
const prefetchPage = (pagename) => {
if (pagename && window.$prefetchPage) window.$prefetchPage(pagename);
};
</script>

View File

@@ -0,0 +1,60 @@
<template>
<li @click="$emit('click', $event)" class="side-text-button">
<div class="icon-wrapper-seamless">
<IconImage
v-if="icon"
:src="icon"
:width="iconWidth"
:height="iconHeight"
class="seamless-icon"
/>
</div>
<div class="text-label" style="color: var(--text-primary);">
{{ text }}
</div>
</li>
</template>
<script setup>
import IconImage from '../IconImage.vue'
defineProps({
text: { type: String, required: true },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: 30 },
iconHeight: { type: [String, Number], default: 30 },
})
defineEmits(['click'])
</script>
<style scoped>
.side-text-button {
background: transparent !important;
border: none !important;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
transition: all 0.2s ease;
cursor: pointer;
}
.side-text-button:hover {
background: var(--bg-tertiary) !important;
}
.icon-wrapper-seamless {
display: flex;
align-items: center;
justify-content: center;
}
.seamless-icon {
filter: brightness(1.1) contrast(1.1);
}
.text-label {
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<ul class="mt-3 box-outstanding-service">
<SideTextButton
v-for="(item, index) in items"
:key="index"
:text="item.text"
:icon="item.icon || ''"
:icon-width="item.iconWidth || 30"
:icon-height="item.iconHeight || 30"
@click="$emit('item-click', item, index)"
@mouseenter="prefetchPage(item.pagename)"
@touchstart.passive="prefetchPage(item.pagename)"
/>
</ul>
</template>
<script setup>
import SideTextButton from './SideTextButton.vue'
defineProps({
/**
* Array of { text, icon?, iconWidth?, iconHeight? }
*/
items: { type: Array, required: true },
})
defineEmits(['item-click'])
const prefetchPage = (pagename) => {
if (pagename && window.$prefetchPage) window.$prefetchPage(pagename);
};
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div class="home-skeleton pb-5">
<!-- BalanceBox Skeleton -->
<div class="tf-container">
<SkeletonStats />
</div>
<!-- Grid Services Skeleton -->
<div class="mt-4 px-2">
<div class="tf-container">
<div class="row g-3 justify-content-center">
<div v-for="i in 12" :key="i" class="col-3 text-center mb-4">
<SkeletonBlock width="52px" height="52px" borderRadius="14px" margin="0 auto 10px" />
<SkeletonBlock width="40px" height="10px" margin="0 auto" borderRadius="2px" />
</div>
</div>
</div>
</div>
<!-- Announcements Skeleton -->
<div class="mt-4">
<div class="tf-container">
<SkeletonBlock width="100%" height="110px" borderRadius="20px" />
</div>
</div>
<!-- Management Tools Skeleton -->
<div class="tf-container mt-5">
<div class="d-flex justify-content-between align-items-center mb-3 px-1">
<SkeletonBlock width="160px" height="22px" borderRadius="4px" />
</div>
<div v-for="i in 5" :key="i" class="d-flex align-items-center mb-3 p-3 glass-card rounded-xl border border-light-5">
<SkeletonBlock width="44px" height="44px" borderRadius="12px" margin="0 16px 0 0" />
<div class="flex-grow-1">
<SkeletonBlock width="60%" height="16px" borderRadius="4px" />
</div>
<SkeletonBlock width="24px" height="24px" borderRadius="6px" />
</div>
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
import SkeletonStats from './SkeletonStats.vue';
</script>
<style scoped>
.glass-card {
background: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.rounded-xl {
border-radius: 20px;
}
.border-light-5 {
border-color: rgba(0, 0, 0, 0.03) !important;
}
:global(.dark-mode) .glass-card {
background: #1f2228 !important;
}
:global(.dark-mode) .border-light-5 {
border-color: rgba(255, 255, 255, 0.05) !important;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="product-list-skeleton px-2 pt-3">
<div class="row g-3 justify-content-center">
<div v-for="i in parseInt(count)" :key="i" class="col-6 col-sm-4 col-md-3">
<div class="glass-card rounded-xl overflow-hidden mb-3 border border-light-5 text-start">
<SkeletonBlock width="100%" height="140px" borderRadius="0" :noShimmer="false" />
<div class="p-3">
<SkeletonBlock width="85%" height="14px" margin="0 0 8px 0" borderRadius="3px" />
<SkeletonBlock width="60%" height="10px" margin="0 0 16px 0" borderRadius="2px" />
<div class="d-flex justify-content-between align-items-center">
<SkeletonBlock width="50px" height="12px" borderRadius="3px" />
<SkeletonBlock width="36px" height="36px" borderRadius="12px" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
defineProps({
count: { type: [Number, String], default: 8 }
})
</script>
<style scoped>
.glass-card {
background: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.rounded-xl {
border-radius: 20px;
}
.border-light-5 {
border-color: rgba(0, 0, 0, 0.03) !important;
}
:global(.dark-mode) .glass-card {
background: #1f2228 !important;
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<SkeletonBlock
:width="size"
:height="size"
borderRadius="50%"
:margin="margin"
/>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
defineProps({
size: { type: String, default: '48px' },
margin: { type: String, default: '0' }
})
</script>

View File

@@ -0,0 +1,68 @@
<template>
<div
class="skeleton-block"
:style="{
width: width,
height: height,
borderRadius: borderRadius,
margin: margin
}"
:class="{ 'shimmer': !noShimmer }"
></div>
</template>
<script setup>
defineProps({
width: { type: String, default: '100%' },
height: { type: String, default: '1rem' },
borderRadius: { type: String, default: '4px' },
margin: { type: String, default: '0' },
noShimmer: { type: Boolean, default: false }
})
</script>
<style scoped>
.skeleton-block {
background: #f0f2f5;
position: relative;
overflow: hidden;
display: block;
}
.shimmer::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.2) 20%,
rgba(255, 255, 255, 0.5) 60%,
rgba(255, 255, 255, 0)
);
animation: shimmer 2s infinite;
}
:global(.dark-mode) .skeleton-block {
background: #2d3138 !important;
}
:global(.dark-mode) .shimmer::after {
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.05) 20%,
rgba(255, 255, 255, 0.1) 60%,
rgba(255, 255, 255, 0)
);
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<div class="skeleton-card glass-card p-4 rounded-xl mb-4 text-start">
<div class="d-flex align-items-center mb-3">
<SkeletonAvatar size="56px" margin="0 16px 0 0" />
<div class="flex-grow-1">
<SkeletonBlock width="140px" height="20px" margin="0 0 8px 0" borderRadius="6px" />
<SkeletonBlock width="80px" height="14px" borderRadius="4px" />
</div>
</div>
<div class="divider border-bottom border-light opacity-10 mb-3"></div>
<SkeletonBlock width="100%" height="12px" margin="0 0 8px 0" borderRadius="4px" />
<SkeletonBlock width="100%" height="12px" margin="0 0 8px 0" borderRadius="4px" />
<SkeletonBlock width="60%" height="12px" borderRadius="4px" />
<div class="d-flex justify-content-between align-items-center mt-4 pt-2">
<SkeletonBlock width="100px" height="10px" borderRadius="4px" />
<SkeletonBlock width="90px" height="36px" borderRadius="18px" />
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
import SkeletonAvatar from './SkeletonAvatar.vue';
</script>
<style scoped>
.rounded-xl {
border-radius: 24px;
}
.glass-card {
background: white;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
:global(.dark-mode) .glass-card {
background: #1f2228 !important;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="skeleton-stats glass-card p-4 rounded-xl mb-4 border text-center">
<div class="row align-items-center mb-3">
<div class="col-6 border-right">
<SkeletonBlock width="60px" height="10px" margin="0 auto 8px" borderRadius="3px" />
<SkeletonBlock width="100px" height="28px" margin="0 auto" borderRadius="6px" />
</div>
<div class="col-6">
<SkeletonBlock width="60px" height="10px" margin="0 auto 8px" borderRadius="3px" />
<SkeletonBlock width="130px" height="28px" margin="0 auto" borderRadius="6px" />
</div>
</div>
<div class="d-flex justify-content-center gap-4 mt-4 pt-3 border-top-dashed">
<SkeletonBlock v-for="i in 4" :key="i" width="44px" height="44px" borderRadius="12px" />
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
</script>
<style scoped>
.skeleton-stats {
background: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.rounded-xl {
border-radius: 24px;
}
.border-right {
border-right: 1px solid #e9ecef;
}
.border-top-dashed {
border-top: 1px dashed #e9ecef;
}
:global(.dark-mode) .skeleton-stats {
background: #1f2228 !important;
}
:global(.dark-mode) .border-right,
:global(.dark-mode) .border-top-dashed {
border-color: rgba(255, 255, 255, 0.05) !important;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="skeleton-table rounded-3 overflow-hidden border text-start bg-white">
<div class="table-header bg-light p-3 border-bottom d-flex align-items-center">
<SkeletonBlock v-for="n in parseInt(columns)" :key="n" :width="colWidth" height="16px" margin="0 15px 0 0" borderRadius="4px" />
</div>
<div v-for="row in parseInt(rows)" :key="row" class="table-row p-3 border-bottom d-flex align-items-center">
<SkeletonBlock v-for="n in parseInt(columns)" :key="n" :width="colWidth" height="12px" margin="0 15px 0 0" borderRadius="4px" />
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
defineProps({
rows: { type: [Number, String], default: 6 },
columns: { type: [Number, String], default: 4 },
colWidth: { type: String, default: '18%' }
})
</script>
<style scoped>
.skeleton-table {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.table-row:last-child {
border-bottom: none !important;
}
:global(.dark-mode) .skeleton-table {
background: #1f2228 !important;
}
:global(.dark-mode) .table-header {
background: #2d3138 !important;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div class="skeleton-text-container">
<SkeletonBlock
v-for="line in parseInt(lines)"
:key="line"
:width="getWidth(line)"
height="14px"
margin="0 0 8px 0"
borderRadius="4px"
/>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
const props = defineProps({
lines: { type: [Number, String], default: 1 },
lastWidth: { type: String, default: '70%' }
})
const getWidth = (line) => {
if (parseInt(props.lines) > 1 && line === parseInt(props.lines)) {
return props.lastWidth;
}
return '100%';
}
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="store-list-skeleton tf-container pt-3">
<div v-for="i in parseInt(count)" :key="i">
<SkeletonCard />
</div>
</div>
</template>
<script setup>
import SkeletonCard from './SkeletonCard.vue';
defineProps({
count: { type: [Number, String], default: 4 }
})
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="transaction-list-skeleton tf-container pt-3">
<div v-for="i in parseInt(count)" :key="i" class="d-flex align-items-center mb-3 p-3 glass-card rounded-xl border border-light-5 text-start">
<SkeletonBlock width="44px" height="44px" borderRadius="12px" margin="0 16px 0 0" />
<div class="flex-grow-1">
<SkeletonBlock width="160px" height="16px" margin="0 0 6px 0" borderRadius="4px" />
<SkeletonBlock width="100px" height="12px" borderRadius="3px" />
</div>
<div class="text-end">
<SkeletonBlock width="80px" height="18px" margin="0 0 4px auto" borderRadius="4px" />
<SkeletonBlock width="50px" height="10px" margin="0 0 0 auto" borderRadius="2px" />
</div>
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
defineProps({
count: { type: [Number, String], default: 10 }
})
</script>
<style scoped>
.glass-card {
background: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.rounded-xl {
border-radius: 20px;
}
.border-light-5 {
border-color: rgba(0, 0, 0, 0.03) !important;
}
:global(.dark-mode) .glass-card {
background: #1f2228 !important;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<div class="tf-container">
<div class="tf-balance-box" :style="boxStyle">
<!-- Balance stats -->
<div id="balance_wrapper">
<div class="balance">
<StatsDetailsRow :stats="stats" />
</div>
</div>
<!-- Footer action buttons -->
<WalletFooter
:items="footerItems"
@item-click="(item, idx) => $emit('footer-click', item, idx)"
/>
</div>
</div>
</template>
<script setup>
import StatsDetailsRow from './StatsDetailsRow.vue'
import WalletFooter from './WalletFooter.vue'
defineProps({
/**
* Stats: Array of { title, number, unit, align?, numberId? }
*/
stats: { type: Array, required: true },
/**
* Footer items: Array of { title, icon?, subtitles? }
*/
footerItems: { type: Array, required: true },
boxStyle: { type: String, default: 'border: solid 2px var(--border-color);' },
})
defineEmits(['footer-click'])
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="col br-right">
<div :class="`inner-${align} ${align === 'left' ? 'ps-3' : 'pe-3'}`">
<p>{{ title }}</p>
<h3 :id="numberId">{{ number }}</h3>
<span>{{ unit }}</span>
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, default: '' },
number: { type: [Number, String], default: 0 },
unit: { type: String, default: '' },
align: { type: String, default: 'left', validator: v => ['left', 'right'].includes(v) },
numberId: { type: String, default: '' },
})
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div class="row">
<CardStatsDetails
v-for="(stat, index) in stats"
:key="index"
:title="stat.title"
:number="stat.number"
:unit="stat.unit"
:align="stat.align || 'left'"
:number-id="stat.numberId || ''"
/>
</div>
</template>
<script setup>
import CardStatsDetails from './CardStatsDetails.vue'
defineProps({
/**
* Array of stat objects: { title, number, unit, align?, numberId? }
*/
stats: { type: Array, required: true },
})
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div class="wallet-footer">
<ul class="d-flex justify-content-between align-items-center">
<WalletFooterItem
v-for="(item, index) in items"
:key="index"
:title="item.title"
:icon="item.icon || ''"
:icon-width="item.iconWidth || 30"
:icon-height="item.iconHeight || 30"
:subtitles="item.subtitles || []"
@click="$emit('item-click', item, index)"
/>
</ul>
</div>
</template>
<script setup>
import WalletFooterItem from './WalletFooterItem.vue'
defineProps({
/**
* Array of item objects: { title, icon?, iconWidth?, iconHeight?, subtitles? }
*/
items: { type: Array, required: true },
})
defineEmits(['item-click'])
</script>

View File

@@ -0,0 +1,35 @@
<template>
<li class="wallet-card-item px-3">
<a
class="fw_6 text-center"
href="javascript:void(0);"
@click="$emit('click', $event)"
>
<ul>
<li class="path1">{{ subtitles[0] || '' }}</li>
<li class="path2">{{ subtitles[1] || '' }}</li>
<li class="path3">{{ subtitles[2] || '' }}</li>
<li class="path4">{{ subtitles[3] || '' }}</li>
</ul>
<IconImage v-if="icon" :src="icon" :width="iconWidth" :height="iconHeight" />
{{ title }}
</a>
</li>
</template>
<script setup>
import IconImage from '../IconImage.vue'
defineProps({
title: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: 30 },
iconHeight: { type: [String, Number], default: 30 },
/**
* Array of up to 4 subtitle strings
*/
subtitles: { type: Array, default: () => [] },
})
defineEmits(['click'])
</script>

View File

@@ -0,0 +1,151 @@
<template>
<Teleport to="body">
<div v-if="modelValue" class="modal d-block" tabindex="-1" style="z-index:1055;padding-top:72px;padding-bottom:80px">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content" style="background:var(--bg-card);color:var(--text-primary)">
<div class="modal-header border-0 pb-0">
<div class="d-flex align-items-center gap-2 w-100 me-2">
<i class="fas fa-images" style="color:var(--accent-color)"></i>
<input v-model="query" type="text" class="form-control rounded-pill form-control-sm"
placeholder="Search photos…" style="background:var(--bg-primary);color:var(--text-primary);border-color:rgba(128,128,128,.3)" />
</div>
<button type="button" class="btn-close" @click="$emit('update:modelValue', false)" />
</div>
<div class="modal-body pt-2">
<div v-if="error" class="alert alert-danger alert-dismissible py-2 mb-2">
{{ error }} <button type="button" class="btn-close" @click="error=null"/>
</div>
<!-- skeleton -->
<div v-if="loading && !photos.length" class="row g-2">
<div v-for="n in 9" :key="n" class="col-4">
<div class="ratio ratio-4x3 rounded" style="background:rgba(128,128,128,.15);animation:pulse 1.5s infinite" />
</div>
</div>
<!-- grid -->
<div v-else class="row g-2">
<div v-for="photo in photos" :key="photo.id" class="col-4" style="cursor:pointer" @click="selectPhoto(photo)">
<div class="ratio ratio-4x3 position-relative rounded overflow-hidden">
<img :src="photo.thumb" class="w-100 h-100 object-fit-cover" :alt="photo.title" loading="lazy" />
<div v-if="selecting===photo.id" class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center" style="background:rgba(0,0,0,.45)">
<div class="spinner-border text-light spinner-border-sm" />
</div>
</div>
<small class="text-muted d-block text-truncate mt-1 px-1" style="font-size:.7rem">{{ photo.title }}</small>
</div>
</div>
<!-- empty states -->
<div v-if="!loading && !photos.length && !error && !query.trim()" class="text-center text-muted py-4">
<i class="fas fa-search d-block mb-2" style="font-size:1.5rem;opacity:.5"></i>
Type a product name above to search photos.
</div>
<div v-else-if="!loading && !photos.length && !error && query.trim()" class="text-center text-muted py-4">
<i class="fas fa-image d-block mb-2" style="font-size:1.5rem;opacity:.5"></i>
No photos found for {{ query.trim() }}.
</div>
<!-- sentinel for infinite scroll -->
<div ref="sentinel" class="py-2 text-center">
<div v-if="loadingMore" class="spinner-border spinner-border-sm text-muted" />
</div>
</div>
</div>
</div>
<div class="modal-backdrop show" style="z-index:-1" @click="$emit('update:modelValue', false)" />
</div>
</Teleport>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import axios from 'axios'
const props = defineProps({ modelValue: Boolean, productName: { type: String, default: '' } })
const emit = defineEmits(['update:modelValue', 'photo-selected'])
const photos = ref([])
const query = ref('')
const page = ref(1)
const hasMore = ref(false)
const loading = ref(false)
const loadingMore = ref(false)
const selecting = ref(null)
const error = ref(null)
const sentinel = ref(null)
let observer = null
let debounceTimer = null
watch(() => props.modelValue, async (val) => {
if (val) { query.value = props.productName; await resetAndSearch() }
else { photos.value = []; page.value = 1; observer?.disconnect() }
})
watch(query, () => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => resetAndSearch(), 400)
})
async function resetAndSearch() {
observer?.disconnect()
photos.value = []; page.value = 1; error.value = null
// Don't fire a request with an empty query — the API rejects it (422).
if (!query.value.trim()) { loading.value = false; return }
loading.value = true
await fetchPage(1)
loading.value = false
await nextTick()
setupObserver()
}
async function fetchPage(p) {
try {
const res = await axios.get('/api/products/photo-search', { params: { q: query.value.trim(), page: p } })
if (res.data.success) {
photos.value.push(...res.data.photos)
hasMore.value = res.data.has_more
page.value = p
} else {
error.value = res.data.error || 'Could not load photos. Try again.'
}
} catch (e) {
error.value = e?.response?.data?.error || 'Could not load photos. Try again.'
}
}
async function loadMore() {
if (!hasMore.value || loadingMore.value) return
loadingMore.value = true
await fetchPage(page.value + 1)
loadingMore.value = false
await nextTick()
setupObserver()
}
function setupObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(([e]) => { if (e.isIntersecting) loadMore() }, { threshold: 0.1 })
observer.observe(sentinel.value)
}
async function selectPhoto(photo) {
if (selecting.value) return
selecting.value = photo.id
error.value = null
try {
const res = await axios.post('/api/products/photo-download', { src: photo.src })
if (res.data.success) {
emit('photo-selected', { hashkey: res.data.hashkey, url: res.data.url })
emit('update:modelValue', false)
} else {
error.value = 'Failed to save photo. Try another.'
}
} catch {
error.value = 'Failed to save photo. Try another.'
} finally {
selecting.value = null
}
}
</script>
<style scoped>
@keyframes pulse { 0%,100%{opacity:.6} 50%{opacity:.3} }
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="dropdown table-density-toggle" ref="dropdownRef">
<button
class="btn btn-theme border dropdown-toggle rounded-12 px-3 shadow-sm d-flex align-items-center"
type="button"
@click="toggleDropdown"
@blur="closeDropdown"
style="padding-top: 12px; padding-bottom: 12px;"
>
<i class="fas fa-expand-alt me-2 opacity-50" style="font-size: 0.9rem;"></i>
<span class="fw_6" style="font-size: 0.95rem;">{{ currentLabel }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow border-0 rounded-12 mt-2 py-2" :class="{ show: isOpen }">
<li v-for="option in options" :key="option.value">
<a class="dropdown-item py-2 px-3 d-flex align-items-center"
:class="{ active: modelValue === option.value }"
href="javascript:void(0);"
@mousedown.prevent="selectOption(option.value)">
<div class="icon-box me-3 rounded-circle d-flex align-items-center justify-content-center" :class="modelValue === option.value ? 'bg-primary text-white' : 'bg-light text-muted'">
<i :class="[option.icon, 'smallest']"></i>
</div>
<div class="flex-grow-1">
<div class="small fw_7 d-block">{{ option.label }}</div>
<div class="smallest text-muted d-block opacity-75">{{ option.description }}</div>
</div>
</a>
</li>
</ul>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
const props = defineProps({
modelValue: { type: String, default: 'comfortable' }
});
const emit = defineEmits(['update:modelValue']);
const isOpen = ref(false);
const dropdownRef = ref(null);
const options = [
{ value: 'comfortable', label: 'Comfortable', description: 'Standard row spacing', icon: 'fas fa-grip-lines' },
{ value: 'compact', label: 'Compact', description: 'Reduced row padding', icon: 'fas fa-grip-lines-vertical' },
{ value: 'ultra-compact', label: 'Ultra', description: 'Maximum density', icon: 'fas fa-list' }
];
const currentLabel = computed(() => {
return options.find(o => o.value === props.modelValue)?.label || 'Comfortable';
});
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
};
const closeDropdown = () => {
// Small delay to allow click events to fire first
setTimeout(() => {
isOpen.value = false;
}, 150);
};
const selectOption = (value) => {
emit('update:modelValue', value);
isOpen.value = false;
};
</script>
<style scoped>
.rounded-12 { border-radius: 12px; }
.dropdown-item { transition: all 0.2s; cursor: pointer; }
.dropdown-item.active { background-color: rgba(66, 185, 131, 0.1) !important; color: #42b983 !important; }
:global(.dark-mode) .dropdown-item.active { background-color: rgba(66, 185, 131, 0.2) !important; }
.smallest { font-size: 0.75rem; }
.icon-box { width: 28px; height: 28px; flex-shrink: 0; }
.btn-theme { background: var(--bg-card); color: var(--text-primary); border-color: var(--border-color) !important; }
.dropdown-menu { background: var(--bg-card); min-width: 200px; }
</style>

View File

@@ -0,0 +1,196 @@
<script setup>
import { onMounted } from 'vue';
import { useAnnouncements } from '../composables/useAnnouncements.js';
const { announcements, loading, fetchLatest } = useAnnouncements();
onMounted(() => {
fetchLatest();
});
const getIcon = (type) => {
switch (type) {
case 'success': return 'check-circle';
case 'warning': return 'exclamation-triangle';
case 'danger': return 'exclamation-circle';
default: return 'info-circle';
}
};
const getBannerClass = (type) => {
return `announcement-banner banner-${type}`;
};
</script>
<template>
<div v-if="announcements.length > 0" class="announcements-container">
<div v-for="item in announcements" :key="item.id" :class="getBannerClass(item.type)">
<div class="banner-content">
<div class="banner-header">
<div class="banner-icon">
<i :class="'fas fa-' + getIcon(item.type)"></i>
</div>
<h5 class="banner-title">{{ item.title }}</h5>
</div>
<div v-if="item.photo" class="banner-photo">
<img :src="'/RequestData/File/' + item.photo" alt="Announcement photo" />
</div>
<div class="banner-body">
<p class="banner-description">{{ item.content }}</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.announcements-container {
padding: 10px 15px;
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.announcement-banner {
border-radius: 16px;
padding: 16px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
display: flex;
align-items: flex-start;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
border-left: 6px solid transparent;
background-size: 200% 200%;
animation: gradientShift 5s ease infinite;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.announcement-banner:hover {
transform: translateY(-4px) scale(1.01);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.banner-content {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
z-index: 1;
}
.banner-header {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.banner-icon {
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.banner-photo {
width: 100%;
max-height: 400px;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.02);
margin: 4px 0;
}
.banner-photo img {
max-width: 100%;
width: 100%;
height: auto;
object-fit: contain;
border-radius: inherit;
}
.banner-body {
width: 100%;
padding: 0 4px;
}
.banner-title {
margin: 0;
font-weight: 800;
font-size: 1.25rem;
letter-spacing: -0.025em;
line-height: 1.2;
}
.banner-description {
margin: 0;
font-size: 0.95rem;
line-height: 1.5;
font-weight: 500;
}
/* Premium Color Themes */
.banner-info {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
color: #0369a1;
border-left-color: #0ea5e9;
}
.banner-info .banner-icon { color: #0ea5e9; }
.banner-success {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
color: #15803d;
border-left-color: #22c55e;
}
.banner-success .banner-icon { color: #22c55e; }
.banner-warning {
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
color: #a16207;
border-left-color: #f59e0b;
}
.banner-warning .banner-icon { color: #f59e0b; }
.banner-danger {
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
color: #b91c1c;
border-left-color: #ef4444;
}
.banner-danger .banner-icon { color: #ef4444; }
/* Dark Mode adaptation support */
:global(.dark-mode) .announcement-banner {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
}
:global(.dark-mode) .banner-info {
background: linear-gradient(135deg, #0c4a6e 0%, #075985 100%);
color: #e0f2fe;
}
:global(.dark-mode) .banner-success {
background: linear-gradient(135deg, #064e3b 0%, #065f46 100%);
color: #dcfce7;
}
:global(.dark-mode) .banner-warning {
background: linear-gradient(135deg, #713f12 0%, #78350f 100%);
color: #fef3c7;
}
:global(.dark-mode) .banner-danger {
background: linear-gradient(135deg, #7f1d1d 0%, #991b1b 100%);
color: #fee2e2;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="loading-overlay" v-if="show">
<div class="spinner"></div>
</div>
</template>
<script>
export default {
name: "LoadingSpinner",
props: {
show: { type: Boolean, default: false }
}
};
</script>
<style scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.2);
display: flex;
justify-content: center;
align-items: center;
z-index: 99999;
}
.spinner {
border: 6px solid #f3f3f3;
border-top: 6px solid #007bff;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,259 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
session: {
type: Object,
required: true
}
});
const isExpanded = ref(false);
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
const transactions = computed(() => {
return props.session.transactions || [];
});
const formattedDate = computed(() => {
if (!props.session.created_at) return 'N/A';
const date = new Date(props.session.created_at);
return date.toLocaleString('en-PH', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
}).replace(',', ' •');
});
const statusClass = computed(() => {
switch (props.session.status) {
case 'completed': return 'badge-soft-success';
case 'active': return 'badge-soft-primary';
case 'voided': return 'badge-soft-danger';
default: return 'badge-soft-secondary';
}
});
const paymentIcon = computed(() => {
switch (props.session.payment_method?.toLowerCase()) {
case 'cash': return 'fas fa-money-bill-wave';
case 'credit': return 'fas fa-credit-card';
case 'online': return 'fas fa-mobile-alt';
default: return 'fas fa-receipt';
}
});
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-PH', {
style: 'currency',
currency: 'PHP'
}).format(amount);
};
const getProductInfo = (transaction) => {
return {
name: transaction.product?.name || 'Unknown Product',
quantity: transaction.quantity || 0,
unitPrice: transaction.price_at_sale || 0,
totalPrice: transaction.total_price || 0
};
};
</script>
<template>
<div class="card mb-3 border-0 shadow-sm rounded-4 overflow-hidden pos-history-card">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span :class="['badge rounded-pill px-3 py-2 text-uppercase fw-bold', statusClass]">
{{ session.status }}
</span>
<h6 class="mb-0 mt-2 text-primary fw-bold">
{{ session.customer_name || 'Walk-in Customer' }}
</h6>
<small class="text-muted d-block mt-1">
<i class="far fa-clock me-1"></i> {{ formattedDate }}
</small>
</div>
<div class="text-end">
<h5 class="mb-0 fw-black text-dark">
{{ formatCurrency(session.total_amount) }}
</h5>
<small class="text-muted">
{{ session.items_count }} {{ session.items_count === 1 ? 'item' : 'items' }}
</small>
</div>
</div>
<div class="d-flex align-items-center justify-content-between mt-3 pt-3 border-top border-light">
<div class="d-flex align-items-center">
<div class="payment-icon-wrapper bg-light rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
<i :class="[paymentIcon, 'text-muted sm']"></i>
</div>
<span class="text-muted small text-capitalize">{{ session.payment_method || 'N/A' }}</span>
</div>
<div v-if="session.hashkey" class="text-muted small">
<span class="badge bg-light text-muted fw-normal rounded-pill">#{{ session.hashkey.substring(0, 8) }}</span>
</div>
</div>
<!-- Expandable Items Section -->
<div v-if="isExpanded && transactions.length > 0" class="items-section mt-3 pt-3 border-top border-light">
<div class="items-header d-flex align-items-center mb-2">
<i class="fas fa-box-open text-muted me-2"></i>
<span class="text-muted small fw-bold">Transaction Items</span>
</div>
<div class="items-list">
<div v-for="item in transactions" :key="item.id" class="item-row d-flex align-items-center justify-content-between py-2 border-bottom border-light">
<div class="item-info flex-grow-1">
<div class="item-name text-dark fw-semibold small">
{{ getProductInfo(item).name }}
</div>
<div class="item-qty text-muted small">
{{ getProductInfo(item).quantity }} × {{ formatCurrency(getProductInfo(item).unitPrice) }}
</div>
</div>
<div class="item-total text-end">
<span class="fw-bold text-dark small">
{{ formatCurrency(getProductInfo(item).totalPrice) }}
</span>
</div>
</div>
</div>
</div>
<!-- Footer with Toggle Button -->
<div
v-if="transactions.length > 0"
class="card-footer-toggle d-flex align-items-center justify-content-center mt-3 pt-3 border-top border-light cursor-pointer"
@click="toggleExpand"
>
<span class="text-muted small fw-bold me-2">
{{ isExpanded ? 'Hide Items' : 'View Items' }}
</span>
<i :class="['fas text-muted small', isExpanded ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
</div>
</div>
</div>
</template>
<style scoped>
.pos-history-card {
transition: transform 0.2s, box-shadow 0.2s;
background: var(--bg-card);
}
.pos-history-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0,0,0,0.05) !important;
}
:global(.dark-mode) .pos-history-card {
background: rgba(var(--bg-card-rgb), 0.7);
backdrop-filter: blur(15px);
}
.badge-soft-success {
background-color: rgba(40, 167, 69, 0.1);
color: #28a745;
}
.badge-soft-primary {
background-color: rgba(0, 123, 255, 0.1);
color: #007bff;
}
.badge-soft-danger {
background-color: rgba(220, 53, 69, 0.1);
color: #dc3545;
}
.badge-soft-secondary {
background-color: rgba(108, 117, 125, 0.1);
color: #6c757d;
}
:global(.dark-mode) .badge-soft-success { background-color: rgba(40, 167, 69, 0.2); }
:global(.dark-mode) .badge-soft-primary { background-color: rgba(0, 123, 255, 0.2); }
:global(.dark-mode) .badge-soft-danger { background-color: rgba(220, 53, 69, 0.2); }
:global(.dark-mode) .border-light { border-color: rgba(255,255,255,0.05) !important; }
/* Font Awesome standard sizes for visual hierarchy as per dictionary */
.sm { font-size: 0.875rem; }
/* Items section styling */
.items-section {
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 500px;
}
}
.item-row:last-child {
border-bottom: none !important;
}
.item-row {
transition: background-color 0.2s;
}
.item-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
:global(.dark-mode) .item-row:hover {
background-color: rgba(255, 255, 255, 0.02);
}
:global(.dark-mode) .item-name,
:global(.dark-mode) .item-total span {
color: var(--text-primary) !important;
}
/* Toggle button styling */
.card-footer-toggle {
transition: background-color 0.2s;
}
.cursor-pointer {
cursor: pointer;
}
.card-footer-toggle:hover {
background-color: rgba(0, 0, 0, 0.02);
}
:global(.dark-mode) .card-footer-toggle:hover {
background-color: rgba(255, 255, 255, 0.02);
}
/* Mobile responsiveness */
@media (max-width: 576px) {
.item-name {
font-size: 0.75rem;
}
.item-qty {
font-size: 0.7rem;
}
.item-total span {
font-size: 0.75rem;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup>
import { onMounted, ref } from 'vue';
import { usePosStore } from '../../stores/pos';
import PosHistoryCard from './PosHistoryCard.vue';
const props = defineProps({
storeHash: {
type: String,
required: true
}
});
const posStore = usePosStore();
const isLoadingMore = ref(false);
onMounted(async () => {
// Only fetch if empty or if needed (could always fetch for simplicity on mount)
await posStore.fetchPosSessions(props.storeHash, 1);
});
const loadMore = async () => {
if (posStore.posSessions.length < posStore.posSessionsCount && !posStore.loading) {
isLoadingMore.value = true;
await posStore.fetchPosSessions(props.storeHash, posStore.posSessionsPage + 1);
isLoadingMore.value = false;
}
};
</script>
<template>
<div class="pos-history-list position-relative mt-2">
<div v-if="posStore.loading && posStore.posSessions.length === 0" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted small">Loading POS history...</p>
</div>
<div v-else-if="posStore.posSessions.length === 0" class="text-center py-5 bg-light rounded-4 border border-dashed">
<div class="empty-state-icon mb-3 opacity-2">
<i class="fad fa-receipt fa-4x text-muted"></i>
</div>
<p class="text-muted small">No POS history found for this store.</p>
</div>
<div v-else>
<div class="history-items">
<div v-for="session in posStore.posSessions" :key="session.hashkey">
<PosHistoryCard :session="session" />
</div>
</div>
<div v-if="posStore.posSessions.length < posStore.posSessionsCount" class="text-center mt-4">
<button
@click="loadMore"
class="btn btn-outline-primary btn-sm rounded-pill px-4 py-2 d-inline-flex align-items-center fw-bold"
:disabled="isLoadingMore || posStore.loading"
>
<span v-if="isLoadingMore || posStore.loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
<i v-else class="fas fa-plus me-2"></i>
{{ isLoadingMore || posStore.loading ? 'LOADING...' : 'LOAD MORE' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.empty-state-icon {
transition: opacity 0.3s ease;
}
.opacity-2 {
opacity: 0.2;
}
.pos-history-list {
min-height: 100px;
}
.bg-light {
background-color: var(--bg-secondary) !important;
}
.border-dashed {
border-style: dashed !important;
border-width: 2px !important;
}
:global(.dark-mode) .bg-light {
background-color: rgba(0,0,0,0.2) !important;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div class="pos-today-stats mb-4">
<div class="d-flex align-items-center justify-content-between mb-3 mt-1">
<div class="d-flex align-items-center">
<div class="icon-avatar me-3 shadow-sm">
<i class="fas fa-chart-line text-primary"></i>
</div>
<div>
<h5 class="fw_7 mb-0">Today's Performance</h5>
<span class="text-muted small">Daily sales summary</span>
</div>
</div>
<div v-if="!loading" class="date-badge px-3 py-1 text-primary small fw_6 rounded-pill border">
Today
</div>
</div>
<div v-if="loading" class="text-center py-4 glass-card p-3 p-md-4 rounded-xl">
<div class="spinner-border text-primary spinner-border-sm" role="status">
<span class="sr-only">Loading...</span>
</div>
<p class="small text-muted mt-2 mb-0">Fetching stats...</p>
</div>
<div v-else class="glass-card p-3 p-md-4 rounded-xl">
<div class="row text-center mt-2">
<div class="col-6 border-right">
<p class="small text-muted mb-1 text-uppercase ls_1">Transactions</p>
<h3 class="mb-0 fw_7">{{ todayStats.count || 0 }}</h3>
</div>
<div class="col-6">
<p class="small text-muted mb-1 text-uppercase ls_1">Total Sales</p>
<h3 class="mb-0 fw_7">₱{{ formatAmount(todayStats.total) }}</h3>
</div>
</div>
<div v-if="!loading && todayStats.store_name" class="mt-3 text-center border-top-dashed pt-3">
<p class="small text-muted italic mb-0">
Terminal: <span class="fw_6 text-primary">{{ todayStats.store_name }}</span>
</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { usePosStore } from '../../stores/pos'
const props = defineProps({
loading: { type: Boolean, default: false }
})
const posStore = usePosStore()
const todayStats = computed(() => posStore.todayStats)
const formatAmount = (val) => {
if (!val) return '0.00'
return parseFloat(val).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
</script>
<style scoped>
.pos-today-stats {
transition: all 0.3s ease;
}
.ls_1 {
letter-spacing: 1px;
}
.border-right {
border-right: 1px solid var(--border-color);
}
.border-top-dashed {
border-top: 1px dashed var(--border-color);
}
.icon-avatar {
width: 44px;
height: 44px;
background: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.date-badge {
background: rgba(var(--primary-rgb), 0.05) !important;
}
.rounded-xl {
border-radius: 20px !important;
}
:global(.dark-mode) .icon-avatar {
background: #2d3138;
}
:global(.dark-mode) .icon-avatar i {
color: #10b981 !important;
}
:global(.dark-mode) .date-badge {
background: rgba(16, 185, 129, 0.1) !important;
color: #10b981 !important;
}
:global(.dark-mode) .text-primary {
color: #10b981 !important;
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<div class="product-card" @click="$emit('click')">
<div class="product-image-wrapper">
<FileImage :src="image" :alt="name" class="product-image" fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" />
<div v-if="price" class="product-price-badge">
{{ price }}
</div>
</div>
<div class="product-info">
<h5 class="product-name">{{ name }}</h5>
<p v-if="unit" class="product-unit">per {{ unit }}</p>
<p v-if="description" class="product-description text-truncate-2">
{{ description }}
</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import FileImage from '../Core/FileImage.vue'
const props = defineProps({
name: { type: String, required: true },
price: { type: [String, Number], default: '' },
unit: { type: String, default: '' },
description: { type: String, default: '' },
image: { type: String, default: '' }
})
defineEmits(['click'])
</script>
<style scoped>
.product-card {
background: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
height: 100%;
display: flex;
flex-direction: column;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.product-image-wrapper {
position: relative;
width: 100%;
padding-top: 100%;
/* 1:1 Aspect Ratio */
background: #f8f9fa;
}
.product-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.product-price-badge {
position: absolute;
bottom: 0;
right: 0;
background: rgba(66, 185, 131, 0.9);
color: white;
padding: 6px 12px;
border-top-left-radius: 12px;
font-weight: 700;
backdrop-filter: blur(4px);
font-size: 0.9rem;
}
.product-info {
padding: 12px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.product-name {
font-size: 1rem;
font-weight: 600;
margin-bottom: 4px;
color: #2c3e50;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.product-unit {
font-size: 0.75rem;
color: #7f8c8d;
margin-bottom: 8px;
font-style: italic;
}
.product-description {
font-size: 0.85rem;
color: #636e72;
margin-bottom: 0;
line-height: 1.4;
}
.text-truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Dark mode support (if applicable) */
:global(.dark-mode) .product-card {
background: #24272c;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
:global(.dark-mode) .product-name {
color: #e0e0e0;
}
:global(.dark-mode) .product-description {
color: #b0b0b0;
}
:global(.dark-mode) .product-image-wrapper {
background: #1a1c20;
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<div class="store-card" @click="$emit('click')">
<div class="store-image-wrapper">
<img :src="resolvedImage" :alt="name" class="store-image" @error="handleImageError" />
<div v-if="category" class="store-category-badge">
{{ category }}
</div>
</div>
<div class="store-info">
<h5 class="store-name">{{ name }}</h5>
<p v-if="subcategory" class="store-subcategory">{{ subcategory }}</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
name: { type: String, required: true },
category: { type: String, default: '' },
subcategory: { type: String, default: '' },
image: { type: String, default: '' }
})
defineEmits(['click'])
const resolvedImage = computed(() => {
if (!props.image) return 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin';
// Return blob URLs directly
if (props.image.startsWith('blob:')) {
return props.image;
}
// Check for http, https, or data URIs
if (props.image.startsWith('http') || props.image.startsWith('/') || props.image.startsWith('data:')) {
return props.image;
}
// If it's a hash (long string without slashes), resolve it
return `/RequestData/File/${props.image}`;
});
const handleImageError = (event) => {
event.target.src = 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin';
};
</script>
<style scoped>
.store-card {
background: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
height: 100%;
display: flex;
flex-direction: column;
}
.store-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.store-image-wrapper {
position: relative;
width: 100%;
padding-top: 60%;
/* 16:9 approx */
background: #f8f9fa;
}
.store-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.store-category-badge {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 123, 255, 0.85);
color: white;
padding: 4px 10px;
border-radius: 20px;
font-weight: 600;
backdrop-filter: blur(4px);
font-size: 0.75rem;
}
.store-info {
padding: 12px;
flex-grow: 1;
}
.store-name {
font-size: 1rem;
font-weight: 600;
margin-bottom: 2px;
color: #2c3e50;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.store-subcategory {
font-size: 0.8rem;
color: #7f8c8d;
margin-bottom: 0;
}
:global(.dark-mode) .store-card {
background: #24272c;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
:global(.dark-mode) .store-name {
color: #e0e0e0;
}
:global(.dark-mode) .store-image-wrapper {
background: #1a1c20;
}
</style>

View File

@@ -0,0 +1,378 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import axios from 'axios';
import LoadingSpinner from '../LoadingSpinner.vue';
import Dropzone from '../Core/Dropzone.vue';
import { useFileUpload } from '../../composables/useFileUpload.js';
const props = defineProps({
productHash: { type: String, required: true },
storeHash: { type: String, default: null },
onSaved: { type: Function, default: null },
onClose: { type: Function, default: null }
});
const { uploadFile, removeHash, setInitialHashes, isUploading: isFileUploading } = useFileUpload({
category: 'ProductMarket',
maxSizeMB: 10
});
// Form state
const productName = ref('');
const productDescription = ref('');
const productCategory = ref('');
const productSubcategory = ref('');
const productPrice = ref(0);
const productUnitName = ref('');
const productAvailable = ref(0);
const productBarcode = ref('');
// Data lists
const categoryList = ref([]);
const subcategoryList = ref([]);
// Loading state
const isLoading = ref(false);
const isSaving = ref(false);
const showSuccessState = ref(false);
const successMessage = ref('');
const error = ref(null);
// Dropzone handling
const dropzoneRef = ref(null);
const dropzoneFiles = ref([]);
onMounted(async () => {
await loadCategories();
await loadProductData();
});
const loadCategories = async () => {
try {
const response = await axios.post('/Products/New/Category/Datalist', {});
const data = response.data.categories || response.data;
if (data && Array.isArray(data)) {
categoryList.value = data.map(item => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : (item[1] || item[0])
}));
}
} catch (err) {
console.error('Error loading categories:', err);
}
};
const loadSubcategories = async () => {
if (!productCategory.value) {
subcategoryList.value = [];
return;
}
try {
const response = await axios.post('/Products/New/SubCategory/Datalist', {
category: productCategory.value
});
const data = response.data.subcategories || response.data;
if (data && Array.isArray(data)) {
subcategoryList.value = data.map(item => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : (item[1] || item[0])
}));
}
} catch (err) {
console.error('Error loading subcategories:', err);
}
};
const loadProductData = async () => {
try {
isLoading.value = true;
const response = await axios.post('/View/Product/Details/data', {
target: props.productHash,
data: {
product_id: props.productHash,
store_hash: props.storeHash
}
});
if (response.data && response.data.success && response.data.data) {
const product = response.data.data;
productName.value = product.name || '';
productDescription.value = props.storeHash ? (product.store_description || product.description) : (product.description || '');
productCategory.value = product.category || '';
productSubcategory.value = product.subcategory || '';
productPrice.value = props.storeHash ? (product.store_price || product.price) : (product.price || 0);
productUnitName.value = product.unitname || '';
productBarcode.value = product.barcode || '';
productAvailable.value = product.available || 0;
if (productCategory.value) {
await loadSubcategories();
productSubcategory.value = product.subcategory || '';
}
if (product.photourlDropzone && Array.isArray(product.photourlDropzone)) {
dropzoneFiles.value = product.photourlDropzone.map(f => ({
file: { name: f.name || 'Image' },
hashkey: f.hashkey,
progress: 100,
uploading: false,
preview: f.url
}));
setInitialHashes(product.photourlDropzone.map(f => f.hashkey));
}
}
} catch (err) {
console.error('Error loading product data:', err);
error.value = 'Failed to load product data';
} finally {
isLoading.value = false;
}
};
watch(() => dropzoneFiles.value, async (newFiles) => {
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
for (const fileObj of filesToUpload) {
const index = newFiles.indexOf(fileObj);
if (index === -1) continue;
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
const result = await uploadFile(fileObj.file);
if (result && result.hashkey) {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 100,
hashkey: result.hashkey
});
} else {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 0,
error: 'Upload failed'
});
}
}
}, { deep: true });
const handlePhotoRemoved = (hashkey) => {
removeHash(hashkey);
};
const handleCategoryChange = () => {
loadSubcategories();
};
const handleSubmit = async () => {
if (!props.storeHash && (!productName.value || !productCategory.value)) {
error.value = 'Name and Category are required';
return;
}
try {
isSaving.value = true;
error.value = null;
const response = await axios.post('/Products/Admin/Edit/', {
target: props.productHash,
data: {
store_hash: props.storeHash
},
EditProductName: productName.value,
EditProductDescription: productDescription.value,
EditProductCategory: productCategory.value,
EditProductSubCategory: productSubcategory.value,
EditProductPrice: parseFloat(productPrice.value),
EditProductUnitName: productUnitName.value,
EditProductAvailable: parseInt(productAvailable.value),
EditProductBarcode: productBarcode.value,
status: true,
photourl: dropzoneFiles.value
.filter(f => f.hashkey)
.map(f => f.hashkey)
});
if (response.data && response.data.success) {
showSuccessState.value = true;
successMessage.value = 'Product updated successfully!';
setTimeout(() => {
if (props.onSaved) props.onSaved();
}, 1500);
} else {
error.value = response.data?.message || 'Failed to update product';
}
} catch (err) {
error.value = err.response?.data?.message || 'Failed to update product';
} finally {
isSaving.value = false;
}
};
</script>
<template>
<div class="update-product-modal p-1">
<div v-if="isLoading" class="text-center py-5">
<LoadingSpinner />
<p class="mt-2 text-muted">Loading product details...</p>
</div>
<template v-else>
<div v-if="successMessage" class="alert alert-success rounded-xl mb-3 animate-fade-in">
<i class="fas fa-check-circle me-2"></i> {{ successMessage }}
</div>
<div v-if="error" class="alert alert-danger rounded-xl mb-3 animate-shake">
<i class="fas fa-exclamation-triangle me-2"></i> {{ error }}
</div>
<div class="form-scroll-area custom-scrollbar pe-2" style="max-height: 70vh; overflow-y: auto;">
<div class="row g-3">
<div v-if="!storeHash" class="col-12">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Product Name</label>
<input v-model="productName" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" placeholder="Enter product name">
</div>
</div>
<div class="col-12">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Description</label>
<textarea v-model="productDescription" class="form-control rounded-2xl border-0 shadow-sm px-4 py-3" rows="3" placeholder="Enter description"></textarea>
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Category</label>
<select v-model="productCategory" @change="handleCategoryChange" class="form-select rounded-pill border-0 shadow-sm px-4">
<option value="" disabled>Select Category</option>
<option v-for="cat in categoryList" :key="cat.value" :value="cat.value">{{ cat.label }}</option>
</select>
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Subcategory</label>
<select v-model="productSubcategory" :disabled="!subcategoryList.length" class="form-select rounded-pill border-0 shadow-sm px-4">
<option value="" disabled>Select Subcategory</option>
<option v-for="sub in subcategoryList" :key="sub.value" :value="sub.value">{{ sub.label }}</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">{{ storeHash ? 'Store Price' : 'Global Price' }} (PHP)</label>
<input v-model="productPrice" type="number" step="0.01" class="form-control rounded-pill border-0 shadow-sm px-4">
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Unit</label>
<input v-model="productUnitName" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" placeholder="e.g. 25kg">
</div>
</div>
<div class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Available Stock</label>
<input v-model="productAvailable" type="number" class="form-control rounded-pill border-0 shadow-sm px-4">
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Barcode</label>
<input v-model="productBarcode" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" maxlength="12">
</div>
</div>
<div v-if="!storeHash" class="col-12 mt-2">
<label class="form-label fw_7 small text-muted text-uppercase mb-2">Product Photos</label>
<Dropzone ref="dropzoneRef" v-model:files="dropzoneFiles" @removed="handlePhotoRemoved" />
</div>
</div>
</div>
<div class="modal-footer-actions d-flex gap-3 mt-4 pt-3 border-top">
<button type="button" class="btn btn-light rounded-pill px-4 flex-fill fw_6" @click="onClose" :disabled="isSaving">Cancel</button>
<AnimatedButton
type="button"
btnClass="btn btn-primary rounded-pill px-5 flex-fill fw_6 shadow-sm"
@click="handleSubmit"
:loading="isSaving"
:success="showSuccessState"
:disabled="isFileUploading"
>
Update Product
</AnimatedButton>
</div>
</template>
</div>
</template>
<style scoped>
.form-label {
margin-bottom: 0.4rem;
letter-spacing: 0.05em;
}
.form-control, .form-select {
background: #f8f9fa;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
background: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.05) !important;
}
.rounded-2xl { border-radius: 1.2rem; }
.rounded-xl { border-radius: 1rem; }
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #ccc;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
:global(.dark-mode) .form-control,
:global(.dark-mode) .form-select {
background: #24272c;
color: #eee;
}
:global(.dark-mode) .form-control:focus,
:global(.dark-mode) .form-select:focus {
background: #2c3036;
}
</style>

View File

@@ -0,0 +1,150 @@
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
const message = ref(null);
const visible = ref(true);
const fetchMessage = async () => {
try {
const response = await axios.get('/api/public/global-message');
if (response.data.success && response.data.data) {
message.value = response.data.data;
}
} catch (error) {
console.error('Error fetching global message:', error);
}
};
const dismiss = () => {
visible.value = false;
// Optional: Store in localStorage to keep it dismissed for the session
sessionStorage.setItem('dismissed_global_message', JSON.stringify(message.value));
};
onMounted(() => {
const dismissed = sessionStorage.getItem('dismissed_global_message');
fetchMessage().then(() => {
if (dismissed && message.value && JSON.stringify(message.value) === dismissed) {
visible.value = false;
}
});
// Refresh every 30 seconds
setInterval(fetchMessage, 30000);
});
const getIcon = (type) => {
switch (type) {
case 'success': return 'check-circle';
case 'warning': return 'exclamation-triangle';
case 'danger': return 'exclamation-circle';
default: return 'info-circle';
}
};
</script>
<template>
<transition name="fade">
<div v-if="message && visible" :class="['system-broadcast', `broadcast-${message.type || 'info'}`]">
<div class="broadcast-container">
<div class="broadcast-content">
<i :class="['fas', `fa-${getIcon(message.type)}`, 'broadcast-icon']"></i>
<span class="broadcast-text">{{ message.text }}</span>
</div>
<button class="broadcast-close" @click="dismiss">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</transition>
</template>
<style scoped>
.system-broadcast {
width: 100%;
padding: 12px 16px;
position: relative;
z-index: 10001;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
animation: slideDown 0.5s ease-out;
}
@keyframes slideDown {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
.broadcast-container {
max-width: 800px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
}
.broadcast-content {
display: flex;
align-items: center;
gap: 12px;
}
.broadcast-icon {
font-size: 1.2rem;
}
.broadcast-text {
font-weight: 600;
font-size: 0.95rem;
line-height: 1.4;
}
.broadcast-close {
background: transparent;
border: none;
color: inherit;
font-size: 1.1rem;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.broadcast-close:hover {
opacity: 1;
}
/* Types */
.broadcast-info {
background: linear-gradient(90deg, #0284c7, #0ea5e9);
color: white;
}
.broadcast-success {
background: linear-gradient(90deg, #059669, #10b981);
color: white;
}
.broadcast-warning {
background: linear-gradient(90deg, #d97706, #f59e0b);
color: white;
}
.broadcast-danger {
background: linear-gradient(90deg, #dc2626, #ef4444);
color: white;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.5s, transform 0.5s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
transform: translateY(-20px);
}
</style>

View File

@@ -0,0 +1,261 @@
<template>
<div class="api-tokens-panel">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="fw-black mb-1">API Tokens</h4>
<p class="text-muted small mb-0">
Issue bearer tokens for batch endpoints and external integrations. Scope each token to specific abilities and IPs.
</p>
</div>
<button class="btn btn-primary" @click="openCreate">
<i class="fas fa-key me-1"></i> New Token
</button>
</div>
<div v-if="freshToken" class="alert alert-warning d-flex align-items-start gap-3">
<i class="fas fa-exclamation-triangle mt-1"></i>
<div class="flex-fill">
<div class="fw-bold mb-1">Copy this token now it will not be shown again.</div>
<div class="d-flex align-items-center gap-2">
<code class="flex-fill px-2 py-1 bg-light rounded text-break">{{ freshToken }}</code>
<button class="btn btn-sm btn-outline-secondary" @click="copyFresh">
<i class="fas" :class="copied ? 'fa-check' : 'fa-copy'"></i>
</button>
</div>
</div>
<button class="btn-close" @click="freshToken = null" aria-label="Dismiss"></button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Name</th>
<th>Abilities</th>
<th>IPs</th>
<th>Expires</th>
<th>Last Used</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="text-center text-muted py-4">Loading...</td>
</tr>
<tr v-else-if="!tokens.length">
<td colspan="7" class="text-center text-muted py-4">No tokens yet.</td>
</tr>
<tr v-for="t in tokens" :key="t.id">
<td>
<div class="fw-bold">{{ t.name }}</div>
<div class="text-muted small">{{ t.description }}</div>
</td>
<td>
<span v-for="a in t.abilities" :key="a" class="badge bg-light text-dark border me-1">{{ a }}</span>
</td>
<td>
<div v-if="!t.allowed_ips?.length" class="text-muted small">any</div>
<div v-else>
<code v-for="ip in t.allowed_ips" :key="ip" class="d-block small">{{ ip }}</code>
</div>
</td>
<td>{{ fmt(t.expires_at) || '—' }}</td>
<td>
<div>{{ fmt(t.last_used_at) || 'never' }}</div>
<div class="text-muted small">{{ t.last_used_ip || '' }}</div>
</td>
<td>
<span v-if="t.revoked_at" class="badge bg-danger">revoked</span>
<span v-else-if="!t.is_active" class="badge bg-secondary">expired</span>
<span v-else class="badge bg-success">active</span>
</td>
<td class="text-end">
<button v-if="!t.revoked_at" class="btn btn-sm btn-outline-warning me-1" @click="revoke(t)">Revoke</button>
<button class="btn btn-sm btn-outline-danger" @click="destroy(t)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Modal -->
<div v-if="showCreate" class="modal-backdrop-custom" @click.self="showCreate = false">
<div class="modal-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold">Create API Token</h5>
<button class="btn-close" @click="showCreate = false"></button>
</div>
<div class="mb-2">
<label class="form-label small fw-bold">Name</label>
<input v-model="form.name" type="text" class="form-control" placeholder="e.g. cooperative-import-prod" />
</div>
<div class="mb-2">
<label class="form-label small fw-bold">Description</label>
<input v-model="form.description" type="text" class="form-control" placeholder="What is this token for?" />
</div>
<div class="mb-2">
<label class="form-label small fw-bold">Expires At (optional)</label>
<input v-model="form.expires_at" type="datetime-local" class="form-control" />
</div>
<div class="mb-2">
<label class="form-label small fw-bold">Allowed IPs (one per line, supports CIDR)</label>
<textarea v-model="form.allowed_ips_text" rows="3" class="form-control font-monospace small"
placeholder="203.0.113.5&#10;10.0.0.0/8"></textarea>
</div>
<div class="mb-2">
<label class="form-label small fw-bold d-flex justify-content-between">
<span>Abilities</span>
<span class="text-muted">{{ form.abilities.length }} selected</span>
</label>
<div class="border rounded p-2" style="max-height: 320px; overflow-y: auto;">
<div v-for="(items, group) in catalog" :key="group" class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<strong class="small">{{ group }}</strong>
<button type="button" class="btn btn-sm btn-link p-0" @click="toggleGroup(items)">toggle all</button>
</div>
<label v-for="item in items" :key="item.key" class="form-check d-inline-flex me-2 small">
<input type="checkbox" class="form-check-input me-1" :value="item.key" v-model="form.abilities" />
{{ item.label }}
</label>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<label class="form-check small text-danger">
<input type="checkbox" class="form-check-input me-1" v-model="form.wildcard" />
Grant wildcard (<code>*</code>) full access
</label>
<div>
<button class="btn btn-light me-2" @click="showCreate = false">Cancel</button>
<button class="btn btn-primary" :disabled="creating || !canSubmit" @click="submit">
<span v-if="creating"><i class="fas fa-spinner fa-spin me-1"></i> Creating...</span>
<span v-else>Create Token</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import axios from 'axios';
const tokens = ref([]);
const catalog = ref({});
const loading = ref(true);
const showCreate = ref(false);
const creating = ref(false);
const freshToken = ref(null);
const copied = ref(false);
const form = reactive({
name: '',
description: '',
expires_at: '',
allowed_ips_text: '',
abilities: [],
wildcard: false,
});
const canSubmit = computed(() => form.name.trim().length > 0 && (form.wildcard || form.abilities.length > 0));
const fmt = (v) => v ? new Date(v).toLocaleString() : '';
async function load() {
loading.value = true;
try {
const [list, cat] = await Promise.all([
axios.get('/admin/ultimate/api-tokens'),
axios.get('/admin/ultimate/api-tokens/catalog'),
]);
tokens.value = list.data.data || [];
catalog.value = cat.data.data.abilities || {};
} finally {
loading.value = false;
}
}
function openCreate() {
Object.assign(form, {
name: '', description: '', expires_at: '', allowed_ips_text: '',
abilities: [], wildcard: false,
});
showCreate.value = true;
}
function toggleGroup(items) {
const keys = items.map(i => i.key);
const allSelected = keys.every(k => form.abilities.includes(k));
if (allSelected) {
form.abilities = form.abilities.filter(a => !keys.includes(a));
} else {
form.abilities = Array.from(new Set([...form.abilities, ...keys]));
}
}
async function submit() {
creating.value = true;
try {
const abilities = form.wildcard ? ['*'] : form.abilities;
const allowed_ips = form.allowed_ips_text.split('\n').map(s => s.trim()).filter(Boolean);
const payload = {
name: form.name.trim(),
description: form.description.trim() || null,
abilities,
allowed_ips: allowed_ips.length ? allowed_ips : null,
expires_at: form.expires_at || null,
};
const { data } = await axios.post('/admin/ultimate/api-tokens', payload);
freshToken.value = data.data.plain_text_token;
showCreate.value = false;
await load();
} catch (e) {
alert(e.response?.data?.message || 'Failed to create token');
} finally {
creating.value = false;
}
}
async function revoke(t) {
if (!confirm(`Revoke token "${t.name}"? It will stop working immediately.`)) return;
await axios.post(`/admin/ultimate/api-tokens/${t.id}/revoke`);
await load();
}
async function destroy(t) {
if (!confirm(`Permanently delete token "${t.name}"? This removes audit history.`)) return;
await axios.delete(`/admin/ultimate/api-tokens/${t.id}`);
await load();
}
async function copyFresh() {
if (!freshToken.value) return;
await navigator.clipboard.writeText(freshToken.value);
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
}
onMounted(load);
</script>
<style scoped>
.modal-backdrop-custom {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 1050;
}
.modal-panel {
background: #fff; border-radius: 12px; padding: 20px;
width: min(720px, 95vw); max-height: 92vh; overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
</style>

View File

@@ -0,0 +1,194 @@
<template>
<BaseModal
:modelValue="show"
@update:modelValue="$emit('close')"
modalTitle="Ultimate Query Console"
>
<div class="query-lab-body">
<div class="mb-4">
<label class="form-label fw-bold text-muted small text-uppercase mb-2 d-flex justify-content-between align-items-center">
SQL Query
<span v-if="loading" class="spinner-border spinner-border-sm text-primary" role="status"></span>
</label>
<div class="query-input-container">
<textarea
v-model="query"
class="form-control font-monospace rounded-4 p-3 border-2 focus-ring shadow-sm"
rows="6"
placeholder="SELECT * FROM users WHERE active = 1..."
spellcheck="false"
></textarea>
<div class="d-flex justify-content-end mt-3">
<button
@click="execute"
:disabled="loading || !query"
class="btn btn-primary rounded-pill px-4 py-2 d-flex align-items-center gap-2 shadow-sm"
>
<i class="fas fa-play"></i>
Run Query
</button>
</div>
</div>
</div>
<!-- Results Section -->
<div v-if="results || affectedRows > 0" class="results-container animate-fade-in">
<h6 class="fw-bold mb-3 d-flex align-items-center gap-2">
<i class="fas fa-database text-success"></i>
Query Results
<span class="badge bg-secondary rounded-pill small ms-auto" v-if="results">
{{ results.length }} rows found
</span>
<span class="badge bg-info rounded-pill small ms-auto" v-else-if="affectedRows > 0">
{{ affectedRows }} rows affected
</span>
</h6>
<div class="table-responsive rounded-4 shadow-sm bg-white border overflow-auto" style="max-height: 400px;">
<table v-if="results && results.length > 0" class="table table-hover table-sm mb-0">
<thead class="bg-light sticky-top">
<tr>
<th v-for="key in Object.keys(results[0])" :key="key" class="text-uppercase small fw-bold px-3 py-2 border-bottom">
{{ key }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in results" :key="i">
<td v-for="(val, key) in row" :key="key" class="px-3 py-2 border-bottom font-monospace small">
{{ val }}
</td>
</tr>
</tbody>
</table>
<div v-else-if="results && results.length === 0" class="p-5 text-center text-muted">
<i class="fas fa-empty-set fa-3x mb-3 opacity-25"></i>
<p>No results found for this query.</p>
</div>
<div v-else-if="affectedRows > 0" class="p-4 text-center text-success">
<i class="fas fa-check-circle fa-2x mb-2"></i>
<p class="mb-0">Query executed successfully. {{ affectedRows }} rows affected.</p>
</div>
</div>
</div>
<!-- Error Alert -->
<div v-if="error" class="alert alert-danger rounded-4 border-0 shadow-sm d-flex align-items-start gap-3 mt-3 animate-shake">
<i class="fas fa-exclamation-triangle mt-1"></i>
<div>
<div class="fw-bold">Query Error</div>
<div class="small opacity-75">{{ error }}</div>
</div>
</div>
</div>
<template #footer>
<div class="d-flex w-100 gap-2">
<button type="button" class="btn btn-light rounded-pill px-4 flex-fill fw-bold" @click="clear">Clear Results</button>
<button type="button" class="btn btn-outline-secondary rounded-pill px-4 flex-fill fw-bold" @click="$emit('close')">Close</button>
</div>
</template>
</BaseModal>
</template>
<script setup>
import { ref } from 'vue';
import { useUltimate } from '@/composables/useUltimate';
import BaseModal from '@/Components/Core/BaseModal.vue';
const props = defineProps({
show: Boolean,
});
const emit = defineEmits(['close']);
const { runQuery, loading } = useUltimate();
const query = ref('');
const results = ref(null);
const affectedRows = ref(0);
const error = ref(null);
const execute = async () => {
error.value = null;
results.value = null;
affectedRows.value = 0;
try {
const response = await runQuery(query.value);
if (response.success) {
results.value = response.data || null;
affectedRows.value = response.affected || 0;
} else {
error.value = response.message;
}
} catch (err) {
error.value = err.response?.data?.message || 'Failed to execute query';
}
};
const clear = () => {
query.value = '';
results.value = null;
affectedRows.value = 0;
error.value = null;
};
</script>
<style scoped>
.focus-ring:focus {
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15);
}
.query-lab-body {
padding: 1rem;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out;
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
:global(.dark-mode) .table-responsive {
background-color: #1e293b;
border-color: #334155;
}
:global(.dark-mode) .table {
color: #f8fafc;
}
:global(.dark-mode) .table thead.bg-light {
background-color: #0f172a !important;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,511 @@
<template>
<div class="accounting-dashboard min-vh-100 bg-light pb-5">
<!-- Premium Header -->
<header class="header-premium text-white py-4 shadow-sm position-relative overflow-hidden mb-4 bg-primary-gradient">
<div class="container position-relative z-2">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-4">
<div class="d-flex align-items-center gap-4 animate-fade-in">
<div class="display-container position-relative bg-white rounded-circle p-3 shadow">
<i class="fas fa-file-invoice-dollar fa-2x text-primary"></i>
</div>
<div>
<h2 class="fw-bold text-white mb-0">Accounting Dashboard</h2>
<p class="text-white-50 small text-uppercase ls-wide mt-1">
{{ isBig3 ? 'Financial Records & Reports' : 'Store Financial Records' }}
</p>
</div>
</div>
<a href="/manage-accounts" class="btn btn-light btn-sm fw-semibold shadow-sm">
<i class="fas fa-sitemap me-1"></i> Manage accounts
</a>
</div>
</div>
</header>
<div class="container">
<div class="card border-0 shadow-lg rounded-4 bg-white overflow-hidden">
<!-- Tabs Header -->
<div class="card-header bg-white border-bottom p-0">
<ul class="nav nav-tabs nav-justified border-0" id="accountingTabs" role="tablist">
<li class="nav-item" role="presentation">
<button
class="nav-link py-3 fw-bold border-0"
:class="{ 'active text-primary border-bottom border-primary border-3': activeTab === 'daily' }"
@click="activeTab = 'daily'"
>
<i class="fas fa-calendar-day me-2"></i> Daily Entry
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link py-3 fw-bold border-0"
:class="{ 'active text-primary border-bottom border-primary border-3': activeTab === 'transactions' }"
@click="activeTab = 'transactions'"
>
<i class="fas fa-list me-2"></i> Transactions
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link py-3 fw-bold border-0"
:class="{ 'active text-primary border-bottom border-primary border-3': activeTab === 'monthly' }"
@click="activeTab = 'monthly'"
>
<i class="fas fa-table me-2"></i> Monthly Matrix
</button>
</li>
</ul>
</div>
<div class="card-body p-4">
<!-- Daily Entry Tab -->
<div v-if="activeTab === 'daily'" class="animate-fade-in">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0 text-dark"><i class="fas fa-calendar-alt text-primary me-2"></i> Date Selection</h4>
<div class="d-flex align-items-center gap-3">
<input type="date" v-model="selectedDate" class="form-control form-control-lg fw-bold text-center border-primary shadow-sm" style="max-width: 200px;" @change="fetchDailyData" />
<button @click="fetchDailyData" class="btn btn-primary" :disabled="loading.daily">
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading.daily }"></i>
</button>
</div>
</div>
<div v-if="loading.daily" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<div class="mt-2 text-muted fw-bold">Loading accounts...</div>
</div>
<div v-else>
<div class="alert alert-info border-0 rounded-3 small">
<i class="fas fa-info-circle me-2"></i> Enter amounts for the selected date. Leave empty or 0 if no transaction occurred.
</div>
<!-- Grouping Leaf Accounts by their immediate parent -->
<div class="row g-4 mt-2">
<div v-for="(accounts, parentName) in groupedLeafAccounts" :key="parentName" class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm rounded-3 bg-light">
<div class="card-header bg-dark text-white fw-bold border-0 py-2 rounded-top-3">
{{ parentName }}
</div>
<div class="card-body p-3">
<div v-for="acc in accounts" :key="acc.id" class="mb-3">
<label class="form-label small fw-bold text-muted mb-1">{{ acc.name }}</label>
<div class="input-group input-group-sm">
<span class="input-group-text bg-white text-muted"></span>
<input type="number" class="form-control fw-bold" v-model="dailyEntries[acc.id].amount" placeholder="0.00" step="0.01">
</div>
<input type="text" class="form-control form-control-sm mt-1" v-model="dailyEntries[acc.id].notes" placeholder="Notes (optional)...">
</div>
</div>
</div>
</div>
</div>
<div class="text-end mt-4 pt-3 border-top">
<button @click="saveDailyData" class="btn btn-success btn-lg px-5 rounded-pill shadow fw-bold" :disabled="savingDaily">
<i class="fas fa-save me-2" :class="{ 'fa-spin': savingDaily }"></i>
{{ savingDaily ? 'Saving...' : 'Save Daily Record' }}
</button>
</div>
</div>
</div>
<!-- Transactions List Tab -->
<div v-if="activeTab === 'transactions'" class="animate-fade-in">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3">
<h4 class="fw-bold mb-0 text-dark"><i class="fas fa-list text-primary me-2"></i> Transaction History</h4>
<div class="d-flex gap-2">
<input type="date" v-model="filters.date_from" class="form-control form-control-sm" title="From Date">
<input type="date" v-model="filters.date_to" class="form-control form-control-sm" title="To Date">
<button @click="fetchTransactions" class="btn btn-primary btn-sm px-3"><i class="fas fa-search"></i></button>
</div>
</div>
<div class="table-responsive bg-white rounded-3 shadow-sm border">
<table class="table table-hover align-middle mb-0 text-nowrap">
<thead class="bg-light text-muted small fw-bold text-uppercase">
<tr>
<th class="ps-3">Date</th>
<th>Account</th>
<th>Type</th>
<th>Amount</th>
<th>Notes</th>
<th class="text-end pe-3">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="loading.transactions" class="text-center">
<td colspan="6" class="py-4"><div class="spinner-border text-primary spinner-border-sm"></div> Loading...</td>
</tr>
<tr v-else-if="transactions.length === 0" class="text-center">
<td colspan="6" class="py-4 text-muted">No transactions found</td>
</tr>
<tr v-for="txn in transactions" :key="txn.id">
<td class="ps-3 fw-bold text-dark">{{ formatDate(txn.transaction_date) }}</td>
<td>
<div class="d-flex flex-column">
<span class="fw-bold">{{ txn.account?.name || 'Unknown' }}</span>
<span class="small text-muted">{{ txn.account?.parent?.name }}</span>
</div>
</td>
<td>
<span class="badge" :class="txn.flow === 'INCOME' ? 'bg-success' : 'bg-danger'">{{ txn.flow }}</span>
</td>
<td class="fw-bold" :class="txn.flow === 'INCOME' ? 'text-success' : 'text-danger'">
{{ txn.flow === 'INCOME' ? '+' : '-' }} {{ Number(txn.amount).toLocaleString(undefined, {minimumFractionDigits: 2}) }}
</td>
<td class="text-muted small text-truncate" style="max-width: 150px;" :title="txn.notes">{{ txn.notes || '-' }}</td>
<td class="text-end pe-3">
<button @click="deleteTransaction(txn.id)" class="btn btn-outline-danger btn-sm rounded-circle"><i class="fas fa-trash"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Monthly Matrix Tab -->
<div v-if="activeTab === 'monthly'" class="animate-fade-in">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3">
<h4 class="fw-bold mb-0 text-dark"><i class="fas fa-table text-primary me-2"></i> Monthly Overview</h4>
<div class="d-flex gap-2 align-items-center">
<select v-model="reportMonth" class="form-select form-select-sm fw-bold border-primary shadow-sm">
<option v-for="(m, i) in months" :value="i+1" :key="i">{{ m }}</option>
</select>
<input type="number" v-model="reportYear" class="form-control form-control-sm fw-bold border-primary shadow-sm" style="width: 80px;">
<button @click="fetchMonthlyReport" class="btn btn-primary btn-sm px-3 fw-bold"><i class="fas fa-sync-alt" :class="{ 'fa-spin': loading.monthly }"></i> Load</button>
</div>
</div>
<div v-if="loading.monthly" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<div class="mt-2 text-muted fw-bold">Generating Report...</div>
</div>
<div v-else>
<div v-for="topNode in accountTree" :key="topNode.id" class="mb-5 animate-slide-up">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="fw-black text-primary ls-tight mb-0">
<i class="fas fa-table me-2"></i> {{ topNode.name }}
</h5>
<div class="badge bg-soft-primary text-primary px-3 py-2 rounded-pill small fw-bold">
{{ reportMonthName }} {{ reportYear }}
</div>
</div>
<div class="table-responsive shadow-sm rounded border matrix-table-container mb-3">
<table class="table table-bordered table-sm mb-0 text-nowrap matrix-table" style="font-size: 0.82rem;">
<thead class="bg-dark text-white text-center align-middle sticky-top">
<tr>
<th rowspan="2" class="bg-primary text-white sticky-col" style="width: 60px; z-index: 5;">Date</th>
<template v-for="mid in topNode.children" :key="mid.id">
<th :colspan="countLeafs(mid)" class="bg-dark text-white border-white-10">{{ mid.name }}</th>
</template>
<th rowspan="2" class="bg-success text-white" style="width: 100px;">TOTAL</th>
</tr>
<tr>
<!-- Render Leaf Names -->
<template v-for="mid in topNode.children" :key="'sub_'+mid.id">
<template v-if="mid.children && mid.children.length > 0">
<th v-for="leaf in mid.children" :key="leaf.id" class="bg-secondary text-white fw-normal border-white-10" style="min-width: 80px;">
{{ leaf.name }}
</th>
</template>
<template v-else>
<th class="bg-secondary text-white fw-normal border-white-10" style="min-width: 80px;">{{ mid.name }}</th>
</template>
</template>
</tr>
</thead>
<tbody>
<tr v-for="day in matrixDays" :key="day" class="page-row">
<td class="text-center fw-bold bg-light sticky-col">{{ day }}</td>
<template v-for="mid in topNode.children" :key="'td_mid_'+mid.id">
<template v-if="mid.children && mid.children.length > 0">
<td v-for="leaf in mid.children" :key="'td_leaf_'+leaf.id" class="text-end px-2">
{{ formatAmount(matrixData[day][leaf.id]) }}
</td>
</template>
<template v-else>
<td class="text-end px-2">{{ formatAmount(matrixData[day][mid.id]) }}</td>
</template>
</template>
<td class="text-end fw-bold bg-soft-success text-success px-2">
{{ formatAmount(getRowTotalForCategory(day, topNode)) }}
</td>
</tr>
</tbody>
<tfoot class="bg-light fw-bold sticky-bottom">
<tr>
<td class="text-center text-primary sticky-col">TOTAL</td>
<template v-for="mid in topNode.children" :key="'tf_mid_'+mid.id">
<template v-if="mid.children && mid.children.length > 0">
<td v-for="leaf in mid.children" :key="'tf_leaf_'+leaf.id" class="text-end text-primary px-2">
{{ formatAmount(matrixTotals[leaf.id]) }}
</td>
</template>
<template v-else>
<td class="text-end text-primary px-2">{{ formatAmount(matrixTotals[mid.id]) }}</td>
</template>
</template>
<td class="text-end text-success px-2">
{{ formatAmount(getColumnTotalForCategory(topNode)) }}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import axios from 'axios';
import { useUIStore } from '@/stores/ui';
import { useAuth } from '@/composables/Core/useAuth';
const uiStore = useUIStore();
uiStore.setPageTitle('Accounting Dashboard');
const { isUltimate, isSuperOperator, isOperator } = useAuth();
const isBig3 = computed(() => isUltimate.value || isSuperOperator.value || isOperator.value);
const activeTab = ref('daily');
const selectedDate = ref(new Date().toISOString().split('T')[0]);
const leafAccounts = ref([]);
const groupedLeafAccounts = ref({});
const dailyEntries = ref({});
const savingDaily = ref(false);
const transactions = ref([]);
const filters = ref({ date_from: '', date_to: '' });
const accountTree = ref([]);
const reportMonth = ref(new Date().getMonth() + 1);
const reportYear = ref(new Date().getFullYear());
const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const matrixDays = ref(0);
const matrixData = ref({});
const matrixTotals = ref({});
const loading = ref({
daily: false,
transactions: false,
monthly: false,
tree: false
});
onMounted(() => {
fetchLeafAccounts();
fetchAccountTree();
});
const fetchLeafAccounts = async () => {
loading.value.daily = true;
try {
const res = await axios.post('/admin/accounting/leaf', {});
leafAccounts.value = res.data.data;
// Group them
const grouped = {};
const entries = {};
leafAccounts.value.forEach(acc => {
const parentName = acc.parent ? acc.parent.name : 'Uncategorized';
if (!grouped[parentName]) grouped[parentName] = [];
grouped[parentName].push(acc);
entries[acc.id] = { amount: '', notes: '' };
});
groupedLeafAccounts.value = grouped;
dailyEntries.value = entries;
// Once structure is ready, fetch today's data
await fetchDailyData();
} catch (error) {
console.error(error);
} finally {
loading.value.daily = false;
}
};
const fetchDailyData = async () => {
loading.value.daily = true;
try {
const res = await axios.post('/admin/accounting/daily', { date: selectedDate.value });
const existing = res.data.data;
// Reset entries first
Object.keys(dailyEntries.value).forEach(id => {
dailyEntries.value[id] = { amount: '', notes: '' };
});
// Fill with existing
Object.keys(existing).forEach(id => {
if(dailyEntries.value[id]) {
dailyEntries.value[id].amount = existing[id].amount;
dailyEntries.value[id].notes = existing[id].notes;
}
});
} catch (error) {
console.error(error);
} finally {
loading.value.daily = false;
}
};
const saveDailyData = async () => {
savingDaily.value = true;
try {
const res = await axios.post('/admin/accounting/daily/save', {
date: selectedDate.value,
entries: dailyEntries.value
});
if(res.data.success) {
if (window.toastr) window.toastr.success('Daily transactions saved successfully!');
}
} catch (error) {
console.error(error);
if (window.toastr) window.toastr.error('Failed to save transactions.');
} finally {
savingDaily.value = false;
}
};
const fetchTransactions = async () => {
loading.value.transactions = true;
try {
const res = await axios.post('/admin/accounting/transactions', filters.value);
transactions.value = res.data.data.data; // paginated
} catch (error) {
console.error(error);
} finally {
loading.value.transactions = false;
}
};
const deleteTransaction = async (id) => {
if(!confirm("Are you sure you want to delete this transaction?")) return;
try {
const res = await axios.post('/admin/accounting/transactions/delete', { id });
if(res.data.success) {
if (window.toastr) window.toastr.success('Transaction deleted');
fetchTransactions();
}
} catch (error) {
console.error(error);
}
};
const fetchAccountTree = async () => {
try {
const res = await axios.post('/admin/accounting/tree', {});
accountTree.value = res.data.data;
} catch (error) {
console.error(error);
}
};
const fetchMonthlyReport = async () => {
loading.value.monthly = true;
try {
const res = await axios.post('/admin/accounting/reports/monthly', {
month: reportMonth.value,
year: reportYear.value
});
matrixDays.value = res.data.days_in_month;
matrixData.value = res.data.matrix;
matrixTotals.value = res.data.column_totals;
} catch (error) {
console.error(error);
} finally {
loading.value.monthly = false;
}
};
const reportMonthName = computed(() => months[reportMonth.value - 1]);
// Utils for Matrix
const countLeafs = (node) => {
if (!node.children || node.children.length === 0) return 1;
let count = 0;
node.children.forEach(child => {
count += countLeafs(child);
});
return count;
};
const getLeafIds = (node) => {
if (!node.children || node.children.length === 0) return [node.id];
let ids = [];
node.children.forEach(child => {
ids = ids.concat(getLeafIds(child));
});
return ids;
};
const getRowTotalForCategory = (day, topNode) => {
const leafIds = getLeafIds(topNode);
let total = 0;
leafIds.forEach(id => {
total += parseFloat(matrixData.value[day][id] || 0);
});
return total;
};
const getColumnTotalForCategory = (topNode) => {
const leafIds = getLeafIds(topNode);
let total = 0;
leafIds.forEach(id => {
total += parseFloat(matrixTotals.value[id] || 0);
});
return total;
};
const formatAmount = (val) => {
if (!val || val == 0) return '-';
return Number(val).toLocaleString(undefined, {minimumFractionDigits: 2});
};
const formatDate = (dateString) => {
const d = new Date(dateString);
return d.toLocaleDateString();
};
</script>
<style scoped>
.bg-primary-gradient {
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
}
.matrix-table-container {
max-height: 65vh;
overflow: auto;
}
.matrix-table th, .matrix-table td {
vertical-align: middle;
}
.matrix-table .sticky-col {
position: sticky;
left: 0;
z-index: 2;
box-shadow: inset -1px 0 0 rgba(0,0,0,0.1);
}
.matrix-table .sticky-top {
position: sticky;
top: 0;
z-index: 3;
}
.matrix-table .sticky-bottom {
position: sticky;
bottom: 0;
z-index: 3;
box-shadow: inset 0 1px 0 rgba(0,0,0,0.1);
}
</style>

View File

@@ -0,0 +1,466 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import { usePageTitle } from '../composables/Core/usePageTitle';
import { useNavigate } from '../composables/Core/useNavigate';
import { useModal } from '../composables/Core/useModal';
import LoadingSpinner from '../Components/LoadingSpinner.vue';
import FileImage from '../Components/Core/FileImage.vue';
import BackButton from '../Components/Core/BackButton.vue';
import CardSimple from '../Components/Core/CardSimple.vue';
usePageTitle('Add Products to Store');
const props = defineProps({
target: { type: String, default: null },
});
const { navigate } = useNavigate();
const modal = useModal();
const STEP = { PICK: 1, EDIT: 2 };
const step = ref(STEP.PICK);
const storeHash = computed(() => props.target);
const store = ref(null);
const loadingProducts = ref(false);
const loadingStore = ref(false);
const submitting = ref(false);
const error = ref(null);
const allProducts = ref([]);
const search = ref('');
const selected = ref({});
const rows = ref([]);
const bulkPrice = ref('');
const bulkAvailable = ref('');
const firstPhoto = (v) => Array.isArray(v) ? (v[0] || '') : (v || '');
const filteredProducts = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return allProducts.value;
return allProducts.value.filter((p) =>
(p.name || '').toLowerCase().includes(q) ||
(p.category || '').toLowerCase().includes(q) ||
(p.subcategory || '').toLowerCase().includes(q)
);
});
const selectedCount = computed(() => Object.values(selected.value).filter(Boolean).length);
const fetchStore = async () => {
if (!storeHash.value) return;
loadingStore.value = true;
try {
const { data } = await axios.post('/View/Store/Details/data', { target: storeHash.value });
if (data?.success) store.value = data.data;
} catch (e) {
console.warn('Failed to fetch store details', e);
} finally {
loadingStore.value = false;
}
};
const fetchProducts = async () => {
loadingProducts.value = true;
error.value = null;
try {
const { data } = await axios.post('/Products/GlobalList', {});
if (data?.success && Array.isArray(data.products)) {
allProducts.value = data.products;
} else {
error.value = 'Failed to load products';
}
} catch (e) {
error.value = 'Failed to load products. Please try again.';
} finally {
loadingProducts.value = false;
}
};
const toggleProduct = (hash) => {
selected.value = { ...selected.value, [hash]: !selected.value[hash] };
};
const selectAllFiltered = () => {
const next = { ...selected.value };
for (const p of filteredProducts.value) next[p.hashkey] = true;
selected.value = next;
};
const clearSelection = () => {
selected.value = {};
};
const proceedToEdit = () => {
const picks = allProducts.value.filter((p) => selected.value[p.hashkey]);
if (picks.length === 0) {
error.value = 'Pick at least one product to continue.';
return;
}
error.value = null;
rows.value = picks.map((p) => ({
hashkey: p.hashkey,
name: p.name,
photourl: p.photourl,
unitname: p.unitname,
category: p.category,
price: parseFloat(p.price) || 0,
available: parseInt(p.available) || 0,
global_price: parseFloat(p.price) || 0,
global_available: parseInt(p.available) || 0,
description: p.description || '',
}));
step.value = STEP.EDIT;
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const backToPick = () => {
step.value = STEP.PICK;
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const applyBulkPrice = () => {
const v = parseFloat(bulkPrice.value);
if (isNaN(v) || v < 0) return;
rows.value = rows.value.map((r) => ({ ...r, price: v }));
};
const applyBulkAvailable = () => {
const v = parseInt(bulkAvailable.value);
if (isNaN(v) || v < 0) return;
rows.value = rows.value.map((r) => ({ ...r, available: v }));
};
const removeRow = (hash) => {
rows.value = rows.value.filter((r) => r.hashkey !== hash);
selected.value = { ...selected.value, [hash]: false };
if (rows.value.length === 0) backToPick();
};
const submit = async () => {
if (submitting.value) return;
if (!storeHash.value) {
error.value = 'No store specified.';
return;
}
for (const r of rows.value) {
if (!(r.price >= 0)) {
error.value = `Invalid price for "${r.name}".`;
return;
}
if (!(r.available >= 0)) {
error.value = `Invalid availability for "${r.name}".`;
return;
}
}
submitting.value = true;
error.value = null;
const failures = [];
for (const r of rows.value) {
try {
await axios.post('/Products/AssignToStore/', {
target: r.hashkey,
TargetStore: storeHash.value,
price: parseFloat(r.price),
available: parseInt(r.available),
description: r.description || '',
});
} catch (e) {
failures.push(r.name);
}
}
submitting.value = false;
if (failures.length === rows.value.length) {
error.value = 'Failed to add any product. Please try again.';
return;
}
modal.quickDismiss({
title: 'Products Added',
body: failures.length
? `Added ${rows.value.length - failures.length} of ${rows.value.length}. Failed: ${failures.join(', ')}.`
: `Added ${rows.value.length} product(s) to ${store.value?.name || 'your store'}.`,
onShown: () => {
setTimeout(() => navigate({ page: 'ViewStoreMarket', props: { target: storeHash.value } }), 1100);
},
});
};
onMounted(() => {
if (!storeHash.value) {
error.value = 'No store specified. Pick a store from Manage Stores.';
return;
}
fetchStore();
fetchProducts();
});
</script>
<template>
<div class="add-products-page pb-5">
<div class="tf-container mt-4">
<div class="d-flex align-items-center gap-2 mb-3">
<BackButton />
<h3 class="fw_6 mb-0">Add Products to Store</h3>
</div>
<CardSimple class="mb-3" :is-premium="false">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<div class="text-muted smallest">Target store</div>
<div class="fw_6">{{ store?.name || (loadingStore ? 'Loading…' : '—') }}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span v-if="step === STEP.PICK" class="badge bg-soft-primary text-primary px-3 py-2 rounded-pill">
Step 1 of 2 · Pick products
</span>
<span v-else class="badge bg-soft-primary text-primary px-3 py-2 rounded-pill">
Step 2 of 2 · Set price & stock
</span>
</div>
</div>
</CardSimple>
<div v-if="error" class="alert alert-danger mb-3">
<i class="fas fa-exclamation-triangle me-2"></i>{{ error }}
</div>
<!-- Step 1: Picker -->
<div v-if="step === STEP.PICK">
<CardSimple class="mb-3" :is-premium="false">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div class="flex-grow-1" style="min-width: 220px;">
<input v-model="search" type="text" class="form-control"
placeholder="Search products by name, category…" />
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary rounded-pill" @click="selectAllFiltered">
Select all shown
</button>
<button class="btn btn-sm btn-outline-secondary rounded-pill" @click="clearSelection"
:disabled="selectedCount === 0">
Clear
</button>
</div>
</div>
</CardSimple>
<div v-if="loadingProducts" class="text-center py-5">
<LoadingSpinner />
</div>
<div v-else-if="filteredProducts.length === 0" class="text-center py-5 text-muted">
<i class="fas fa-box-open fa-3x opacity-25 mb-3"></i>
<p class="mb-0">No products match your search.</p>
</div>
<div v-else class="row g-2">
<div v-for="p in filteredProducts" :key="p.hashkey" class="col-12 col-sm-6 col-lg-4">
<div class="product-pick-card" :class="{ picked: selected[p.hashkey] }"
@click="toggleProduct(p.hashkey)">
<div class="d-flex gap-3 align-items-center">
<div class="product-thumb">
<FileImage :src="firstPhoto(p.photourl)"
class="img-fluid rounded" alt="Product"
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" />
</div>
<div class="flex-grow-1 min-w-0">
<div class="d-flex align-items-center gap-2">
<div class="fw_6 text-truncate" :title="p.name">{{ p.name }}</div>
</div>
<div class="text-muted smallest text-truncate">
{{ p.category }}<span v-if="p.subcategory"> · {{ p.subcategory }}</span>
</div>
<div class="smallest">
{{ p.price }} <span class="text-muted">/ {{ p.unitname || 'unit' }}</span>
</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" :checked="!!selected[p.hashkey]"
@click.stop="toggleProduct(p.hashkey)" />
</div>
</div>
</div>
</div>
</div>
<div class="sticky-bottom-bar mt-4">
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted small">
<strong>{{ selectedCount }}</strong> selected
</div>
<button class="btn btn-primary rounded-pill px-4" :disabled="selectedCount === 0"
@click="proceedToEdit">
Continue <i class="fas fa-arrow-right ms-1"></i>
</button>
</div>
</div>
</div>
<!-- Step 2: Batch edit -->
<div v-else>
<CardSimple class="mb-3">
<div class="fw_6 mb-3">Bulk apply</div>
<div class="row g-3 align-items-end">
<div class="col-12 col-sm-5">
<label class="form-label smallest text-muted mb-1">Set all prices ()</label>
<div class="d-flex gap-2 align-items-center">
<input v-model="bulkPrice" type="number" min="0" step="0.01" class="form-control"
placeholder="e.g. 50" />
<button class="btn btn-primary rounded-pill flex-shrink-0" @click="applyBulkPrice">
Apply
</button>
</div>
</div>
<div class="col-12 col-sm-5">
<label class="form-label smallest text-muted mb-1">Set all availability</label>
<div class="d-flex gap-2 align-items-center">
<input v-model="bulkAvailable" type="number" min="0" step="1" class="form-control"
placeholder="e.g. 100" />
<button class="btn btn-primary rounded-pill flex-shrink-0" @click="applyBulkAvailable">
Apply
</button>
</div>
</div>
<div class="col-12 col-sm-2 d-flex align-items-end">
<button class="btn btn-outline-secondary rounded-pill w-100" @click="backToPick">
<i class="fas fa-arrow-left me-1"></i> Back
</button>
</div>
</div>
</CardSimple>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Product</th>
<th style="width: 160px;">Price ()</th>
<th style="width: 160px;">Available</th>
<th style="width: 60px;"></th>
</tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.hashkey">
<td>
<div class="d-flex align-items-center gap-2">
<div class="product-thumb-sm">
<FileImage :src="firstPhoto(r.photourl)"
class="img-fluid rounded" alt="Product"
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" />
</div>
<div class="min-w-0">
<div class="fw_6 text-truncate" :title="r.name">{{ r.name }}</div>
<div class="text-muted smallest">
global {{ r.global_price }} · {{ r.global_available }} {{ r.unitname || 'unit' }}
</div>
</div>
</div>
</td>
<td>
<input v-model.number="r.price" type="number" min="0" step="0.01"
class="form-control form-control-sm" />
</td>
<td>
<input v-model.number="r.available" type="number" min="0" step="1"
class="form-control form-control-sm" />
</td>
<td class="text-end">
<button class="btn btn-sm btn-icon btn-outline-danger" title="Remove"
@click="removeRow(r.hashkey)">
<i class="fas fa-times"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="sticky-bottom-bar mt-3">
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted small"><strong>{{ rows.length }}</strong> product(s) to add</div>
<button class="btn btn-primary rounded-pill px-4" :disabled="submitting || rows.length === 0"
@click="submit">
<span v-if="submitting"><LoadingSpinner small /></span>
<span v-else><i class="fas fa-check me-1"></i> Add to Store</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.product-pick-card {
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 12px;
padding: 12px;
cursor: pointer;
transition: all 0.15s ease;
background: var(--bg-card, #fff);
min-height: 84px;
}
.row.g-2 {
align-items: flex-start;
}
.product-pick-card:hover {
border-color: var(--primary, #4caf50);
transform: translateY(-1px);
}
.product-pick-card.picked {
border-color: var(--primary, #4caf50);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
}
.product-thumb {
width: 56px;
height: 56px;
flex-shrink: 0;
overflow: hidden;
border-radius: 8px;
background: rgba(0, 0, 0, 0.04);
}
.product-thumb :deep(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.product-thumb-sm {
width: 40px;
height: 40px;
flex-shrink: 0;
overflow: hidden;
border-radius: 6px;
background: rgba(0, 0, 0, 0.04);
}
.product-thumb-sm :deep(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.sticky-bottom-bar {
position: sticky;
bottom: 12px;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 14px;
padding: 12px 16px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
z-index: 5;
}
.min-w-0 {
min-width: 0;
}
</style>

View File

@@ -0,0 +1,401 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { usePageTitle } from '../composables/Core/usePageTitle'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import BackButton from '../Components/Core/BackButton.vue'
import axios from 'axios'
usePageTitle('Add Transaction')
const { navigate } = useNavigate()
const modal = useModal()
// State
const isLoading = ref(false)
const isSubmitting = ref(false)
const stores = ref([])
const products = ref([])
const transactionTypes = ref([])
const errors = ref({})
const showSuccessAnimation = ref(false)
const showSuccessState = ref(false)
const form = ref({
scope: 'global', // 'global' or 'store'
store_hash: '',
product_hash: '',
type: '',
amount: '',
description: '',
status: 'completed'
})
// Initialize
onMounted(async () => {
fetchInitialData()
})
const fetchInitialData = async () => {
isLoading.value = true
try {
// Fetch Stores
const storesResponse = await axios.post('/ListStores/MyStores/data')
if (storesResponse.data && Array.isArray(storesResponse.data)) {
stores.value = storesResponse.data
}
// Fetch Transaction Types
const typesResponse = await axios.post('/admin/transactions/types')
if (typesResponse.data && Array.isArray(typesResponse.data)) {
transactionTypes.value = typesResponse.data
}
} catch (error) {
console.error('Error fetching initial data:', error)
} finally {
isLoading.value = false
}
}
// Watchers
watch(() => form.value.store_hash, async (newStoreHash) => {
if (newStoreHash) {
fetchStoreProducts(newStoreHash)
} else {
products.value = []
form.value.product_hash = ''
}
})
watch(() => form.value.scope, (newScope) => {
if (newScope === 'global') {
form.value.store_hash = ''
form.value.product_hash = ''
}
})
const fetchStoreProducts = async (storeHash) => {
try {
const response = await axios.post('/View/Store/Details/data', { target: storeHash })
if (response.data && response.data.products) {
products.value = response.data.products
}
} catch (error) {
console.error('Error fetching products:', error)
}
}
// Form Submission
const submitForm = async () => {
isSubmitting.value = true
errors.value = {}
try {
const payload = {
amount: form.value.amount,
type: form.value.type,
description: form.value.description,
status: form.value.status
}
if (form.value.scope === 'store') {
payload.store_hash = form.value.store_hash
if (form.value.product_hash) {
payload.product_hash = form.value.product_hash
}
}
const response = await axios.post('/admin/transactions/create', payload)
if (response.data && response.data.success) {
// Success!
showSuccessState.value = true
showSuccessAnimation.value = true
setTimeout(() => {
showSuccessAnimation.value = false
navigate({ page: 'ManageGlobalTransactions' })
}, 2000)
}
} catch (error) {
if (error.response && error.response.data && error.response.data.errors) {
errors.value = error.response.data.errors
} else {
console.error('Error submitting transaction:', error)
modal.open({
title: 'Error',
body: 'Failed to save transaction. Please try again.'
})
}
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<div class="add-transaction-page pb-5">
<br><br>
<div class="tf-container">
<div class="d-flex justify-content-between align-items-center mb-4">
<BackButton />
<h4 class="fw_6 mb-0">Record New Transaction</h4>
</div>
<div class="card shadow-lg border-0 overflow-hidden glass-card">
<div class="card-header bg-gradient-primary text-white p-4">
<h5 class="mb-0"><i class="fas fa-plus-circle me-2"></i> Transaction Details</h5>
<p class="mb-0 text-white-50 small">Enter the transaction details below to record it in the system.</p>
</div>
<div class="card-body p-4">
<form @submit.prevent="submitForm">
<!-- Scope Selection -->
<div class="mb-4">
<label class="form-label fw_6 d-block mb-3">Transaction Scope</label>
<div class="scope-toggle-container shadow-sm rounded-pill overflow-hidden p-1">
<input
type="radio"
class="btn-check"
name="scope"
id="scope-global"
value="global"
v-model="form.scope"
>
<label class="btn btn-outline-primary border-0 rounded-pill py-2" for="scope-global">
<i class="fas fa-globe me-1"></i> Global
</label>
<input
type="radio"
class="btn-check"
name="scope"
id="scope-store"
value="store"
v-model="form.scope"
>
<label class="btn btn-outline-primary border-0 rounded-pill py-2" for="scope-store">
<i class="fas fa-store me-1"></i> Store Specific
</label>
</div>
</div>
<div class="row">
<!-- Store Selection (Conditional) -->
<div v-if="form.scope === 'store'" class="col-md-6 mb-3">
<label class="form-label fw_6">Select Store</label>
<select
v-model="form.store_hash"
class="form-select form-select-lg highlight-focus"
:class="{ 'is-invalid': errors.store_hash }"
required
>
<option value="">-- Choose a Store --</option>
<option v-for="store in stores" :key="store.hashkey" :value="store.hashkey">
{{ store.name }}
</option>
</select>
<div v-if="errors.store_hash" class="invalid-feedback">{{ errors.store_hash[0] }}</div>
</div>
<!-- Product Selection (Conditional & Optional) -->
<div v-if="form.scope === 'store'" class="col-md-6 mb-3">
<label class="form-label fw_6">Related Product (Optional)</label>
<select
v-model="form.product_hash"
class="form-select form-select-lg highlight-focus"
:disabled="!form.store_hash"
>
<option value="">-- No Specific Product --</option>
<option v-for="product in products" :key="product.hashkey" :value="product.hashkey">
{{ product.name }}
</option>
</select>
</div>
<!-- Transaction Type -->
<div class="col-md-6 mb-3">
<label class="form-label fw_6">Transaction Type</label>
<select
v-model="form.type"
class="form-select form-select-lg highlight-focus"
:class="{ 'is-invalid': errors.type }"
required
>
<option value="">-- Choose Type --</option>
<option v-for="type in transactionTypes" :key="type.value" :value="type.value">
{{ type.label }}
</option>
</select>
<div v-if="errors.type" class="invalid-feedback">{{ errors.type[0] }}</div>
</div>
<!-- Amount -->
<div class="col-md-6 mb-3">
<label class="form-label fw_6">Amount (PHP)</label>
<div class="input-group input-group-lg custom-input-group">
<span class="input-group-text"></span>
<input
type="number"
step="0.01"
v-model="form.amount"
class="form-control highlight-focus"
:class="{ 'is-invalid': errors.amount }"
placeholder="0.00"
required
>
</div>
<div v-if="errors.amount" class="text-danger small mt-1">{{ errors.amount[0] }}</div>
</div>
</div>
<!-- Description -->
<div class="mb-4">
<label class="form-label fw_6">Description / Notes</label>
<textarea
v-model="form.description"
class="form-control highlight-focus"
rows="3"
placeholder="Details about this transaction..."
></textarea>
</div>
<div class="mt-4">
<AnimatedButton
type="submit"
btnClass="btn btn-primary btn-lg w-100 glow-button"
:loading="isSubmitting"
:success="showSuccessState"
>
<i class="fas fa-save me-2"></i> Record Transaction
</AnimatedButton>
</div>
</form>
</div>
</div>
</div>
<div v-if="showSuccessAnimation" class="success-overlay">
<div class="text-center animate-bounce-in">
<LottiePlayer
path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/11999b7bb57c.json"
:loop="false"
width="200px"
height="200px"
/>
<h3 class="fw_8 mt-3 text-primary headline-gradient">Success!</h3>
<p class="text-muted">Transaction recorded in the ledger.</p>
</div>
</div>
</div>
</template>
<style scoped>
.glass-card {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 20px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
:global(.dark-mode) .glass-card {
background: rgba(31, 34, 40, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.bg-gradient-primary {
background: linear-gradient(135deg, #4e73df 0%, #224abe 100%);
}
.highlight-focus:focus {
border-color: var(--accent-color, #4e73df);
box-shadow: 0 0 0 0.25rem var(--accent-soft, rgba(78, 115, 223, 0.25));
}
.form-select-lg, .form-control-lg {
border-radius: 12px;
}
.glow-button {
border-radius: 15px;
padding: 15px;
transition: all 0.3s ease;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
.glow-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(78, 115, 223, 0.4);
}
.btn-check:checked + .btn-outline-primary {
background-color: var(--accent-color, #4e73df);
color: white;
}
.scope-toggle-container {
display: flex;
width: 100%;
background: var(--bg-secondary, #f8f9fa);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.05));
}
.scope-toggle-container .btn {
flex: 1;
}
.custom-input-group .input-group-text {
background-color: var(--bg-tertiary, #f0f2f5);
color: var(--text-primary);
border-color: var(--border-color);
}
:global(.dark-mode) .form-label {
color: var(--text-primary);
}
.tf-container {
max-width: 800px;
margin: 0 auto;
}
.success-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(10px);
}
:global(.dark-mode) .success-overlay {
background: rgba(18, 20, 24, 0.95);
}
.animate-bounce-in {
animation: bounce-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes bounce-in {
0% { transform: scale(0.3); opacity: 0; }
50% { transform: scale(1.05); opacity: 1; }
70% { transform: scale(0.9); }
100% { transform: scale(1); }
}
.headline-gradient {
background: linear-gradient(135deg, #4e73df 0%, #224abe 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
</style>

View File

@@ -0,0 +1,183 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useChapters } from '../composables/useChapters.js';
import { useNavigate } from '../composables/Core/useNavigate.js';
import { useModal } from '../composables/Core/useModal.js';
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Assign Officer');
const { fetchOfficerScope, assignOfficer, loading } = useChapters();
const { navigate } = useNavigate();
const modal = useModal();
const ROLES = ['PRESIDENT', 'VICE_PRESIDENT', 'SECRETARY', 'TREASURER', 'AUDITOR', 'BOARD_MEMBER'];
const roleLabel = (r) => ({
PRESIDENT: 'President', VICE_PRESIDENT: 'Vice President', SECRETARY: 'Secretary',
TREASURER: 'Treasurer', AUDITOR: 'Auditor', BOARD_MEMBER: 'Board Member',
}[r] || r);
const ownChapter = ref(null);
const eligibleMembers = ref([]);
const childChapters = ref([]);
const memberFilter = ref('');
const selectedMember = ref(null);
const selectedChapter = ref(null);
const selectedRole = ref('');
const submitting = ref(false);
const step = computed(() => {
if (!selectedMember.value) return 1;
if (!selectedChapter.value) return 2;
if (!selectedRole.value) return 3;
return 4;
});
const filteredMembers = computed(() => {
const q = memberFilter.value.trim().toLowerCase();
if (!q) return eligibleMembers.value;
return eligibleMembers.value.filter((m) => (m.name || '').toLowerCase().includes(q));
});
const selectMember = (m) => { selectedMember.value = m; };
const selectChapter = (c) => { selectedChapter.value = c; };
const back = () => {
if (selectedRole.value) { selectedRole.value = ''; return; }
if (selectedChapter.value) { selectedChapter.value = null; return; }
if (selectedMember.value) { selectedMember.value = null; return; }
};
const confirmAssign = async () => {
if (submitting.value) return;
submitting.value = true;
try {
const res = await assignOfficer({
memberUserHashkey: selectedMember.value.user_hashkey,
childChapterId: selectedChapter.value.id,
role: selectedRole.value,
});
if (res.success) {
modal.quickDismiss({
title: 'Officer Assigned',
body: res.message || 'Member assigned successfully.',
});
navigate({ page: 'Home' });
}
} catch (err) {
modal.quickDismiss({
title: 'Error',
body: err.response?.data?.message || err.response?.data?.error || 'Failed to assign officer.',
});
} finally {
submitting.value = false;
}
};
onMounted(async () => {
const scope = await fetchOfficerScope();
ownChapter.value = scope?.own_chapter ?? null;
eligibleMembers.value = scope?.eligible_members ?? [];
childChapters.value = scope?.child_chapters ?? [];
});
</script>
<template>
<div class="container py-4" style="max-width: 620px;">
<div class="d-flex align-items-center gap-2 mb-3">
<button v-if="step > 1" class="btn btn-sm btn-outline-secondary rounded-circle" @click="back">
<i class="fas fa-arrow-left"></i>
</button>
<h5 class="fw-bold mb-0"><i class="fas fa-user-tie me-2"></i>Assign Officer</h5>
</div>
<div v-if="loading && !ownChapter" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
<div v-else-if="!ownChapter" class="text-center py-5 text-muted">
<i class="fas fa-exclamation-triangle fa-2x text-warning mb-2"></i>
<p>You are not assigned to a chapter.</p>
</div>
<template v-else>
<!-- Step 1: pick member -->
<div v-if="step === 1" class="panel rounded-4 p-3">
<h6 class="fw-semibold mb-2">1. Select a member</h6>
<input v-model="memberFilter" type="text" class="form-control rounded-pill mb-3" placeholder="Search members..." />
<div v-if="!filteredMembers.length" class="text-muted small py-3 text-center">
No eligible members in {{ ownChapter.name }}.
</div>
<div v-for="m in filteredMembers" :key="m.user_hashkey" class="row-item rounded-3 p-3 mb-2" role="button" @click="selectMember(m)">
<i class="fas fa-user me-2 text-muted"></i><span class="fw-semibold">{{ m.name }}</span>
</div>
</div>
<!-- Step 2: pick child chapter -->
<div v-else-if="step === 2" class="panel rounded-4 p-3">
<h6 class="fw-semibold mb-2">2. Select a sub-chapter</h6>
<p class="small text-muted">Assigning <strong>{{ selectedMember.name }}</strong></p>
<div v-if="!childChapters.length" class="text-muted small py-3 text-center">
No sub-chapters available. Create one first.
</div>
<div v-for="c in childChapters" :key="c.id" class="row-item rounded-3 p-3 mb-2" role="button" @click="selectChapter(c)">
<span class="badge rounded-pill level-badge me-2">{{ (c.level || '').toUpperCase() }}</span>
<span class="fw-semibold">{{ c.name }}</span>
<span class="small text-muted ms-2">{{ c.active_members_count }} members</span>
</div>
</div>
<!-- Step 3: pick role -->
<div v-else-if="step === 3" class="panel rounded-4 p-3">
<h6 class="fw-semibold mb-3">3. Select a role</h6>
<div class="d-grid gap-2">
<button v-for="r in ROLES" :key="r" class="row-item rounded-3 p-3 text-start" @click="selectedRole = r">
<i class="fas fa-id-badge me-2 text-muted"></i>{{ roleLabel(r) }}
</button>
</div>
</div>
<!-- Step 4: confirm -->
<div v-else class="panel rounded-4 p-4">
<h6 class="fw-semibold mb-3">4. Confirm</h6>
<p>
Assign <strong>{{ selectedMember.name }}</strong> as
<strong>{{ roleLabel(selectedRole) }}</strong> to
<strong>{{ selectedChapter.name }}</strong>?
</p>
<div class="alert alert-warning rounded-3 small py-2">
This will MOVE them from {{ ownChapter.name }} to {{ selectedChapter.name }}.
</div>
<button class="btn btn-primary rounded-pill w-100 py-2 fw-semibold" :disabled="submitting" @click="confirmAssign">
<span v-if="submitting" class="spinner-border spinner-border-sm me-2"></span>
<i v-else class="fas fa-check me-2"></i>
{{ submitting ? 'Assigning...' : 'Confirm Assignment' }}
</button>
</div>
</template>
</div>
</template>
<style scoped>
.panel {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.row-item {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
cursor: pointer;
}
.level-badge {
background: var(--accent-color);
color: #fff;
font-size: 0.65rem;
}
:global(.dark-mode) .panel,
:global(.dark-mode) .row-item {
border-color: rgba(255, 255, 255, 0.08);
}
</style>

View File

@@ -0,0 +1,922 @@
<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Assign Product To Store');
import { ref, computed, onMounted, watch } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import LoadingSpinner from '../Components/LoadingSpinner.vue'
import CardSimple from '../Components/Core/CardSimple.vue'
const props = defineProps({
target: { type: String, default: null },
store_hash: { type: String, default: null },
payload: { type: Object, default: null },
user: { type: Object, default: null },
})
const { navigate } = useNavigate()
const modal = useModal()
// Form state
const productHash = ref(null)
const selectedStoreHash = ref('')
const productData = ref({})
const customPrice = ref(0)
const customStock = ref(0)
// Reset custom fields when product data loaded
watch(productData, (newData) => {
if (newData) {
customPrice.value = newData.price || 0
customStock.value = newData.available || 0
}
})
// Data
const storeList = ref([])
const isAdmin = ref(false)
// Loading state
const isLoading = ref(false)
const isSubmitting = ref(false)
const storesLoading = ref(false)
const successMessage = ref('')
const errorMessage = ref('')
// Computed
const currentUserType = computed(() => {
return props.user?.acct_type?.value || props.user?.acct_type || ''
})
const isUltimate = computed(() => {
return currentUserType.value === 'ult'
})
const selectedStore = computed(() => {
return storeList.value.find(s => s.hashkey === selectedStoreHash.value)
})
const isButtonDisabled = computed(() => {
return !!(isSubmitting.value || successMessage.value || !selectedStoreHash.value || !productHash.value)
})
// Initialize
onMounted(() => {
document.title = 'Assign Product to Store'
// Get product hash from props (passed via URL) or from query params
const urlParams = new URLSearchParams(window.location.search)
productHash.value = props.payload?.product_hashkey || props.payload?.product_hash || props.target || urlParams.get('target') || urlParams.get('product_id') || urlParams.get('id')
// Set store hash if provided
if (props.payload?.store_hashkey || props.payload?.store_hash || props.store_hash) {
selectedStoreHash.value = props.payload?.store_hashkey || props.payload?.store_hash || props.store_hash
}
if (!productHash.value) {
errorMessage.value = 'No product specified. Please select a product first.'
return
}
loadStores()
loadProductData()
})
// Load stores for current user (filtered by ownership/management)
const loadStores = async () => {
storesLoading.value = true
try {
const response = await axios.post('/ListStores/MyStores/data')
if (response.data && Array.isArray(response.data)) {
storeList.value = response.data
isAdmin.value = isUltimate.value || props.user?.acct_type === 'super operator' || props.user?.acct_type === 'operator'
}
} catch (error) {
console.error('Error loading stores:', error)
errorMessage.value = 'Failed to load your stores. Please try again.'
} finally {
storesLoading.value = false
}
}
// Load product data
const loadProductData = async () => {
isLoading.value = true
try {
const response = await axios.post('/View/Product/Details/data', {
target: productHash.value,
})
if (response.data && response.data.success && response.data.data) {
productData.value = response.data.data
} else {
errorMessage.value = 'Product not found or data unavailable.'
}
} catch (error) {
console.error('Error loading product data:', error)
errorMessage.value = 'Failed to load product details.'
} finally {
isLoading.value = false
}
}
// Get role badge
const getRoleBadge = (role) => {
switch (role) {
case 'owner': return { text: 'Owner', class: 'badge-owner' }
case 'manager': return { text: 'Manager', class: 'badge-manager' }
case 'admin': return { text: 'Admin', class: 'badge-admin' }
default: return { text: role, class: 'badge-default' }
}
}
// Show confirmation modal
const showConfirmation = () => {
if (!selectedStoreHash.value) {
modal.open({ title: 'Missing Selection', body: 'Please select a store to assign this product to.', footer: null })
return
}
const storeName = selectedStore.value?.name || 'Selected Store'
const productName = productData.value?.name || 'This product'
modal.yesNoModal({
title: 'Assign Product?',
body: `Are you sure you want to assign <strong>${productName}</strong> to <strong>${storeName}</strong>?`,
onYes: submitAssignment,
yesText: 'Assign',
noText: 'Cancel'
})
}
// Submit assignment
const submitAssignment = async () => {
isSubmitting.value = true
errorMessage.value = ''
successMessage.value = ''
try {
const response = await axios.post('/Products/AssignToStore/', {
TargetStore: selectedStoreHash.value,
target: productHash.value,
price: customPrice.value,
available: customStock.value,
})
if (response.data && response.data.success) {
successMessage.value = 'Product assigned to store successfully!'
// Navigate to the store view after a short delay
setTimeout(() => {
navigate({
page: 'ViewStoreMarket',
props: { target: selectedStoreHash.value }
})
}, 1800)
} else {
errorMessage.value = response.data?.message || 'Failed to assign product to store.'
}
} catch (error) {
console.error('Error assigning product:', error)
errorMessage.value = error.response?.data?.message || 'Failed to assign product. Please try again.'
} finally {
isSubmitting.value = false
}
}
// Cancel and go back
const goBack = () => {
navigate({ page: 'ListProductsMarket' })
}
</script>
<template>
<div class="assign-product-page pb-5">
<!-- Header -->
<div class="tf-container mt-5 mb-4 text-center">
<div class="page-icon-wrapper">
<i class="fas fa-store"></i>
<i class="fas fa-plus icon-overlay"></i>
</div>
<h1 class="fw_8 premium-title">Assign Product to Store</h1>
<p class="text-muted subtitle">Link a product to one of your stores for marketplace visibility</p>
</div>
<!-- Alerts -->
<div v-if="successMessage" class="tf-container mb-4">
<div class="glass-alert alert-success animate-fade-in">
<i class="fas fa-check-circle me-2"></i>
{{ successMessage }}
</div>
</div>
<div v-if="errorMessage" class="tf-container mb-4">
<div class="glass-alert alert-danger animate-shake">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ errorMessage }}
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="tf-container text-center py-5">
<LoadingSpinner size="large" />
<p class="text-muted mt-3">Loading product details...</p>
</div>
<!-- Main Form -->
<div v-else class="tf-container">
<div class="form-grid">
<!-- Left: Store Selection -->
<div class="form-section">
<CardSimple title="Select Store">
<div class="premium-input-group mb-4">
<label for="targetStore" class="form-label">
<i class="fas fa-store me-2"></i>Target Store <span class="required">*</span>
</label>
<div v-if="storesLoading" class="store-loading">
<LoadingSpinner size="small" />
<span class="ms-2 text-muted">Loading stores...</span>
</div>
<select
v-else
id="targetStore"
v-model="selectedStoreHash"
class="premium-select"
:disabled="storeList.length === 0"
>
<option value="" disabled>
{{ storeList.length === 0 ? 'No stores available' : 'Choose a store...' }}
</option>
<option v-for="store in storeList" :key="store.hashkey" :value="store.hashkey">
{{ store.name }} {{ store.category ? `(${store.category})` : '' }}
</option>
</select>
<p v-if="storeList.length === 0 && !storesLoading" class="input-hint text-warning mt-2">
<i class="fas fa-info-circle me-1"></i>
You don't own or manage any stores yet.
</p>
</div>
<!-- Store role indicator -->
<div v-if="selectedStore" class="selected-store-info animate-fade-in">
<div class="store-info-card">
<div class="store-info-header">
<i class="fas fa-store-alt"></i>
<span>{{ selectedStore.name }}</span>
</div>
<div class="store-info-details">
<span :class="['role-badge', getRoleBadge(selectedStore.role).class]">
<i class="fas fa-shield-alt me-1"></i>
{{ getRoleBadge(selectedStore.role).text }}
</span>
<span v-if="selectedStore.category" class="category-tag">
{{ selectedStore.category }}
</span>
</div>
</div>
</div>
<!-- Access Level Info -->
<div class="access-info mt-4">
<div class="access-info-header">
<i class="fas fa-lock me-2"></i>
<span class="fw_6">Your Access Level</span>
</div>
<div class="access-info-body">
<div class="access-item" :class="{ 'active': isUltimate || isAdmin }">
<i class="fas fa-crown"></i>
<span>Admin Access</span>
<i v-if="isUltimate || isAdmin" class="fas fa-check-circle text-success ms-auto"></i>
</div>
<div class="access-item" :class="{ 'active': !isAdmin }">
<i class="fas fa-user-shield"></i>
<span>Owner/Manager Only</span>
<i v-if="!isAdmin && !isUltimate" class="fas fa-check-circle text-success ms-auto"></i>
</div>
</div>
</div>
</CardSimple>
</div>
<!-- Right: Product Preview -->
<div class="form-section">
<CardSimple title="Product Details">
<!-- Product Photo -->
<div v-if="productData.photourl && productData.photourl.length > 0" class="product-photo-preview mb-4">
<img
:src="'/RequestData/File/' + productData.photourl[0]"
alt="Product Photo"
class="product-photo"
@error="$event.target.style.display = 'none'"
>
</div>
<div class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-tag me-2"></i>Product Name
</label>
<input
type="text"
class="premium-input"
:value="productData.name || ''"
disabled
>
</div>
<div class="row">
<div class="col-6">
<div class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-peso-sign me-2"></i>Price
</label>
<input
type="text"
class="premium-input"
:value="productData.price ? `₱${productData.price}` : ''"
disabled
>
</div>
</div>
<div class="col-6">
<div class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-weight-hanging me-2"></i>Unit
</label>
<input
type="text"
class="premium-input"
:value="productData.unitname || ''"
disabled
>
</div>
</div>
</div>
<div class="row">
<div class="col-6">
<div class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-layer-group me-2"></i>Category
</label>
<input
type="text"
class="premium-input"
:value="productData.category || ''"
disabled
>
</div>
</div>
<div class="col-6">
<div class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-barcode me-2"></i>Barcode
</label>
<input
type="text"
class="premium-input"
:value="productData.barcode || ''"
disabled
>
</div>
</div>
</div>
<div v-if="productData.description" class="premium-input-group mb-3">
<label class="form-label">
<i class="fas fa-align-left me-2"></i>Description
</label>
<div class="description-preview">
{{ productData.description }}
</div>
</div>
<hr class="my-4 opacity-25">
<div class="pivot-custom-fields animate-fade-in">
<h5 class="fw_7 mb-3 text-primary">
<i class="fas fa-edit me-2"></i>Store Specific Settings
</h5>
<div class="row">
<div class="col-6">
<div class="premium-input-group mb-3">
<label for="customPrice" class="form-label">
<i class="fas fa-coins me-2"></i>Custom Price
</label>
<div class="input-with-icon">
<span class="prefix">₱</span>
<input
id="customPrice"
v-model.number="customPrice"
type="number"
step="0.01"
class="premium-input ps-5"
placeholder="0.00"
>
</div>
</div>
</div>
<div class="col-6">
<div class="premium-input-group mb-3">
<label for="customStock" class="form-label">
<i class="fas fa-cubes me-2"></i>Initial Stock
</label>
<input
id="customStock"
v-model.number="customStock"
type="number"
class="premium-input"
placeholder="0"
>
</div>
</div>
</div>
<p class="text-muted small mt-1">
<i class="fas fa-info-circle me-1"></i> These values will only apply to this store.
</p>
</div>
</CardSimple>
</div>
</div>
<!-- Action Buttons -->
<div class="action-bar mt-5 text-center">
<button
id="assign-product-btn"
@click="showConfirmation"
:disabled="isButtonDisabled"
class="btn-premium-assign"
:class="{ 'btn-loading': isSubmitting }"
>
<span v-if="!isSubmitting">
<i class="fas fa-link me-2"></i>Assign Product to Store
</span>
<LoadingSpinner v-else size="small" color="white" />
</button>
<div class="mt-4">
<button
@click="goBack"
class="btn-text"
>
<i class="fas fa-chevron-left me-2"></i>Cancel and Return
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.premium-title {
font-family: 'Outfit', sans-serif;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
margin-bottom: 4px;
}
.subtitle {
font-size: 0.95rem;
max-width: 460px;
margin: 0 auto;
}
.page-icon-wrapper {
position: relative;
display: inline-block;
font-size: 2.5rem;
color: #3b82f6;
margin-bottom: 16px;
}
.icon-overlay {
position: absolute;
font-size: 0.9rem;
background: #22c55e;
color: white;
border-radius: 50%;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
bottom: 0;
right: -8px;
box-shadow: 0 2px 6px rgba(34, 197, 94, 0.4);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 992px) {
.form-grid {
grid-template-columns: 1fr;
}
}
.premium-input-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 600;
font-size: 0.85rem;
color: #475569;
margin-bottom: 8px;
display: flex;
align-items: center;
}
.required {
color: #ef4444;
margin-left: 4px;
}
.premium-input, .premium-select {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #fff;
font-size: 0.95rem;
transition: all 0.2s;
outline: none;
}
.premium-input:focus, .premium-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
.premium-input:disabled {
background: #f8fafc;
color: #64748b;
cursor: not-allowed;
}
.premium-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
cursor: pointer;
}
.premium-select:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.input-hint {
font-size: 0.8rem;
color: #94a3b8;
}
/* Store Loading */
.store-loading {
display: flex;
align-items: center;
padding: 12px 16px;
border: 1px dashed #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
/* Selected Store Info Card */
.selected-store-info {
margin-top: 16px;
}
.store-info-card {
padding: 16px;
border-radius: 12px;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border: 1px solid #bae6fd;
}
.store-info-header {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
color: #0369a1;
margin-bottom: 10px;
}
.store-info-details {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.role-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.badge-owner {
background: rgba(34, 197, 94, 0.15);
color: #15803d;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.badge-manager {
background: rgba(59, 130, 246, 0.15);
color: #1d4ed8;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.badge-admin {
background: rgba(168, 85, 247, 0.15);
color: #7e22ce;
border: 1px solid rgba(168, 85, 247, 0.3);
}
.badge-default {
background: rgba(100, 116, 139, 0.15);
color: #475569;
border: 1px solid rgba(100, 116, 139, 0.3);
}
.category-tag {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
background: rgba(100, 116, 139, 0.1);
color: #475569;
border: 1px solid rgba(100, 116, 139, 0.2);
}
/* Access Info */
.access-info {
border-radius: 12px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.access-info-header {
display: flex;
align-items: center;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 0.85rem;
color: #475569;
}
.access-info-body {
padding: 8px;
}
.access-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
font-size: 0.85rem;
color: #94a3b8;
transition: all 0.2s;
}
.access-item.active {
background: rgba(34, 197, 94, 0.08);
color: #1e293b;
}
.access-item i:first-child {
font-size: 0.9rem;
width: 20px;
text-align: center;
}
/* Product Photo Preview */
.product-photo-preview {
display: flex;
justify-content: center;
padding: 12px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.product-photo {
max-width: 100%;
max-height: 200px;
border-radius: 8px;
object-fit: contain;
}
.description-preview {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 0.9rem;
color: #64748b;
line-height: 1.6;
max-height: 120px;
overflow-y: auto;
}
/* Alerts */
.glass-alert {
padding: 16px 20px;
border-radius: 16px;
backdrop-filter: blur(8px);
font-weight: 500;
display: flex;
align-items: center;
}
.alert-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #15803d;
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #b91c1c;
}
/* Buttons */
.btn-premium-assign {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white;
border: none;
padding: 16px 48px;
border-radius: 14px;
font-weight: 700;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 15px -3px rgba(34, 197, 94, 0.3);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 280px;
}
.btn-premium-assign:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(34, 197, 94, 0.4);
filter: brightness(1.08);
}
.btn-premium-assign:disabled {
background: #cbd5e1;
cursor: not-allowed;
box-shadow: none;
}
.btn-loading {
padding: 12px 48px;
background: #16a34a;
}
.btn-text {
background: transparent;
border: none;
color: #64748b;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
}
.btn-text:hover {
color: #1e293b;
}
/* Animations */
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
/* Dark mode */
:global(.dark-mode) .premium-input, :global(.dark-mode) .premium-select {
background: #1e293b;
border-color: #334155;
color: #f8fafc;
}
:global(.dark-mode) .premium-input:disabled {
background: #0f172a;
color: #94a3b8;
}
:global(.dark-mode) .premium-title {
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
-webkit-background-clip: text;
}
:global(.dark-mode) .form-label {
color: #94a3b8;
}
:global(.dark-mode) .store-info-card {
background: linear-gradient(135deg, #0c4a6e 0%, #164e63 100%);
border-color: #0e7490;
}
:global(.dark-mode) .store-info-header {
color: #67e8f9;
}
:global(.dark-mode) .access-info {
border-color: #334155;
background: #1e293b;
}
.input-with-icon {
position: relative;
display: flex;
align-items: center;
}
.input-with-icon .prefix {
position: absolute;
left: 16px;
color: #64748b;
font-weight: 600;
}
.ps-5 {
padding-left: 38px !important;
}
.pivot-custom-fields {
background: rgba(59, 130, 246, 0.03);
padding: 16px;
border-radius: 12px;
border: 1px solid rgba(59, 130, 246, 0.1);
}
:global(.dark-mode) .pivot-custom-fields {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.2);
}
:global(.dark-mode) .access-info-header {
background: #1e293b;
border-color: #334155;
color: #94a3b8;
}
:global(.dark-mode) .access-item.active {
background: rgba(34, 197, 94, 0.15);
color: #f8fafc;
}
:global(.dark-mode) .description-preview {
background: #1e293b;
border-color: #334155;
color: #94a3b8;
}
:global(.dark-mode) .product-photo-preview {
background: #1e293b;
border-color: #334155;
}
:global(.dark-mode) .store-loading {
background: #1e293b;
border-color: #334155;
}
</style>

View File

@@ -0,0 +1,274 @@
<script setup>
import { usePageTitle } from '../../composables/Core/usePageTitle';
usePageTitle('Login');
import { ref, onMounted } from 'vue';
import axios from 'axios';
import { useNavigate } from '../../composables/Core/useNavigate.js';
import { resetRole } from '../../composables/Core/useAuth.js';
import { useUserStore } from '../../stores/user.js';
const { navigate } = useNavigate();
import { useUIStore } from '../../stores/ui';
const uiStore = useUIStore();
onMounted(() => {
// Clear any stale session artifacts on login landing
sessionStorage.clear();
localStorage.clear();
resetRole();
useUserStore().resetCurrentUser();
// Unregister service workers as per the legacy login.blade.php
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function (registrations) {
for (let registration of registrations) {
registration.unregister();
}
}).catch(function (error) {
console.error('Error while unregistering service workers:', error);
});
}
// Clear all Cache Storage to prevent stale service worker pages (stuck homepage issue)
if (window.caches) {
caches.keys().then(function(names) {
for (let name of names) {
caches.delete(name);
}
}).catch(function(error) {
console.error('Error while clearing caches:', error);
});
}
});
const usernumber = ref('');
const userpassword = ref('');
const keepAlive = ref(false);
const loading = ref(false);
const errorMessage = ref('');
const handleLogin = async () => {
if (!usernumber.value || !userpassword.value) {
errorMessage.value = 'Please fill in all fields.';
return;
}
loading.value = true;
errorMessage.value = '';
try {
const response = await axios.post('/post/loginnow', {
mobile_number: usernumber.value,
password: userpassword.value,
keepalive: keepAlive.value
}, {
withCredentials: true
});
if (response.data.success) {
// Redirect to home or reload to refresh auth state
window.location.href = '/';
} else {
errorMessage.value = response.data.message || 'Invalid credentials.';
if (window.toastr) window.toastr.error(errorMessage.value);
userpassword.value = ''; // Clear password on failure
}
} catch (error) {
errorMessage.value = error.response?.data?.message || 'An error occurred during login.';
if (window.toastr) window.toastr.error(errorMessage.value);
userpassword.value = '';
} finally {
loading.value = false;
}
};
const handleKeypress = (event) => {
if (event.key === 'Enter') {
handleLogin();
}
};
</script>
<template>
<div class="login-page-container">
<div class="login-card shadow-lg p-4">
<div class="login-logo text-center mb-4">
<img :src="uiStore.appLogo" :alt="uiStore.appName" class="login-logo-img mb-2">
<h2 class="fw_7">{{ uiStore.appName }}</h2>
</div>
<div v-if="errorMessage" class="alert alert-danger mb-4" role="alert">
{{ errorMessage }}
</div>
<div class="form-group mb-3">
<label for="usernumber" class="form-label fw_6">Mobile Number</label>
<div class="input-group custom-input-group">
<span class="input-group-text custom-input-addon">
<i class="fas fa-phone text-muted"></i>
</span>
<input type="text" id="usernumber" v-model="usernumber" class="form-control custom-input-field"
placeholder="e.g. 09123456789" @keypress="handleKeypress">
</div>
</div>
<div class="form-group mb-4">
<label for="userpassword" class="form-label fw_6">Password</label>
<div class="input-group custom-input-group">
<span class="input-group-text custom-input-addon">
<i class="fas fa-lock text-muted"></i>
</span>
<input type="password" id="userpassword" v-model="userpassword"
class="form-control custom-input-field" placeholder="Your password" @keypress="handleKeypress">
</div>
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" v-model="keepAlive" id="keepAlive">
<label class="form-check-label text-muted" for="keepAlive">
Keep me signed in
</label>
</div>
<button @click="handleLogin" class="btn btn-primary w-100 py-3 rounded-pill fw_7 mb-3" :disabled="loading">
<template v-if="loading">
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Signing in...
</template>
<template v-else>
Sign In
</template>
</button>
<div class="text-center">
<p class="text-muted small mb-0">Don't have an account?</p>
<a href="#" class="text-primary fw_6 undecorated">Contact your administrator</a>
</div>
</div>
</div>
</template>
<style scoped>
.login-page-container {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - 120px);
padding: 20px;
background-color: var(--bg-primary);
transition: background-color 0.3s ease;
}
.login-card {
max-width: 400px;
width: 100%;
background: var(--bg-card);
border-radius: 20px;
border: 1px solid var(--border-color);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05) !important;
transition: all 0.3s ease;
}
.login-logo h2 {
margin-bottom: 0;
color: var(--text-primary);
}
.login-logo-img {
max-width: 96px;
height: auto;
object-fit: contain;
}
.form-label {
font-size: 0.85rem;
color: var(--text-secondary);
margin-left: 5px;
}
.custom-input-group {
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.02);
}
.custom-input-addon {
background-color: var(--bg-secondary) !important;
border: 1px solid var(--border-color) !important;
border-right: none !important;
border-radius: 12px 0 0 12px !important;
color: var(--text-muted);
}
.custom-input-field {
background-color: var(--bg-secondary) !important;
border: 1px solid var(--border-color) !important;
border-left: none !important;
border-radius: 0 12px 12px 0 !important;
padding: 12px;
font-size: 1rem;
color: var(--text-primary) !important;
}
.custom-input-field:focus {
box-shadow: none;
border-color: var(--accent-color) !important;
}
.btn-primary {
background: #42b983;
border: none;
font-size: 1.1rem;
box-shadow: 0 4px 10px rgba(66, 185, 131, 0.3);
transition: all 0.2s ease;
color: white !important;
}
.btn-primary:hover {
background: #38a171;
transform: translateY(-1px);
box-shadow: 0 6px 15px rgba(66, 185, 131, 0.4);
}
.btn-primary:active {
transform: scale(0.98);
}
.btn-primary:disabled {
background: #a8dcc3;
opacity: 0.7;
}
.text-primary {
color: #42b983 !important;
}
.undecorated {
text-decoration: none;
}
.undecorated:hover {
text-decoration: underline;
}
/* Dark mode specific enhancements */
:global(.dark-mode) .login-card {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4) !important;
border-color: rgba(255, 255, 255, 0.05);
}
:global(.dark-mode) .custom-input-addon,
:global(.dark-mode) .custom-input-field {
background-color: rgba(255, 255, 255, 0.03) !important;
}
:global(.dark-mode) .form-check-input {
background-color: var(--bg-tertiary);
border-color: var(--border-color);
}
:global(.dark-mode) .form-check-input:checked {
background-color: #42b983;
border-color: #42b983;
}
</style>

View File

@@ -0,0 +1,206 @@
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import { usePageTitle } from '../composables/Core/usePageTitle'
import BackButton from '../Components/Core/BackButton.vue'
import LoadingSpinner from '../Components/LoadingSpinner.vue'
const props = defineProps({
target: String
})
const { navigate } = useNavigate()
const modal = useModal()
usePageTitle('Batch Add Cooperative Members')
const ROLES = ['MEMBER', 'OFFICER', 'ADMIN']
const MEMBERSHIP_TYPES = ['REGULAR', 'ASSOCIATE', 'LABORATORY']
const blankRow = () => ({
username: '',
name: '',
mobile_number: '',
password: 'Password123!',
role: 'MEMBER',
membership_type: '',
})
const cooperative = ref(null)
const loadingCoop = ref(true)
const members = ref([blankRow()])
const saving = ref(false)
const addRow = () => {
members.value.push(blankRow())
}
const removeRow = (index) => {
if (members.value.length > 1) {
members.value.splice(index, 1)
}
}
const fetchCooperative = async () => {
if (!props.target) {
loadingCoop.value = false
return
}
try {
const response = await axios.post('/Cooperatives/Get', { hashkey: props.target })
if (response.data?.success) {
cooperative.value = response.data.data
}
} catch (err) {
console.error('Failed to load cooperative', err)
} finally {
loadingCoop.value = false
}
}
const saveMembers = async () => {
if (!props.target) {
modal.open({ title: 'Error', body: 'Cooperative not specified.' })
return
}
const invalid = members.value.some(m => !m.username || !m.name || !m.mobile_number || !m.password)
if (invalid) {
modal.open({
title: 'Validation Error',
body: 'Please fill in Username, Name, Mobile, and Password for all rows.'
})
return
}
saving.value = true
try {
const response = await axios.post('/admin/batch/cooperative-members', {
cooperative_hash: props.target,
members: members.value
})
if (response.data?.success) {
modal.open({
title: 'Success',
body: `Successfully registered ${response.data.count} members with new user accounts.`,
onClose: () => navigate({ page: 'CooperativeDetail', props: { target: props.target } })
})
}
} catch (err) {
console.error('Error saving batch members:', err)
const errorMessage = err.response?.data?.errors
? err.response.data.errors.join('<br>')
: (err.response?.data?.message || 'Failed to save members.')
modal.open({ title: 'Error', body: errorMessage })
} finally {
saving.value = false
}
}
onMounted(fetchCooperative)
</script>
<template>
<div class="batch-add-page pb-5">
<div class="tf-container mt-4">
<div class="mb-3">
<BackButton :to="{ page: 'CooperativeDetail', props: { target: props.target } }" />
</div>
<div class="mb-4">
<h3 class="fw_6 mb-1">Batch Add Cooperative Members</h3>
<p class="text-muted small mb-0">
<span v-if="loadingCoop">Loading cooperative...</span>
<span v-else-if="cooperative">
Adding members to <strong>{{ cooperative.name }}</strong>. Each row creates a new user account and enrolls them as a member. Default password: <code>Password123!</code>
</span>
<span v-else class="text-danger">Cooperative not found.</span>
</p>
</div>
<div class="d-grid mb-3">
<button @click="addRow" class="btn btn-outline-primary rounded-pill">
<i class="fas fa-plus-circle me-2"></i> Add Member
</button>
</div>
<div class="row g-4">
<div v-for="(m, index) in members" :key="index" class="col-md-6 col-lg-4">
<div class="leaf-card p-3 bg-white rounded-3 border position-relative h-100">
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
<span class="badge bg-primary rounded-pill">#{{ index + 1 }}</span>
<button @click="removeRow(index)" class="btn btn-link text-danger p-0 border-0"
:disabled="members.length <= 1"><i class="fas fa-times-circle"></i></button>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Username *</label>
<input v-model="m.username" type="text" class="form-control form-control-sm" placeholder="Unique username">
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Full Name *</label>
<input v-model="m.name" type="text" class="form-control form-control-sm" placeholder="Member's full name">
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Mobile *</label>
<input v-model="m.mobile_number" type="text" class="form-control form-control-sm" placeholder="09xxxxxxxxx">
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Password *</label>
<input v-model="m.password" type="text" class="form-control form-control-sm" placeholder="Password">
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Role</label>
<select v-model="m.role" class="form-select form-select-sm">
<option v-for="r in ROLES" :key="r" :value="r">{{ r }}</option>
</select>
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Membership Type</label>
<select v-model="m.membership_type" class="form-select form-select-sm">
<option value=""></option>
<option v-for="t in MEMBERSHIP_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="d-grid mt-4">
<button @click="addRow" class="btn btn-outline-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-plus-circle me-2"></i> Add Another Member
</button>
</div>
<div class="d-grid mt-3 pt-3 border-top">
<button @click="saveMembers" :disabled="saving || !cooperative" class="btn btn-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-save me-2"></i>
{{ saving ? 'Saving...' : 'Save All Members' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.batch-add-page {
background: var(--bg-primary);
min-height: 100vh;
}
.leaf-card { transition: box-shadow 0.15s ease, transform 0.15s ease; }
.leaf-card:hover { box-shadow: 0 4px 12px rgba(13,110,253,0.08); transform: translateY(-2px); }
:global(.dark-mode) .leaf-card { background-color: var(--bg-secondary) !important; border-color: var(--border-color) !important; }
:global(.dark-mode) .form-control, :global(.dark-mode) .form-select {
background-color: var(--bg-secondary) !important;
color: var(--text-primary);
border-color: var(--border-color);
}
</style>

View File

@@ -0,0 +1,201 @@
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import { usePageTitle } from '../composables/Core/usePageTitle'
import BackButton from '../Components/Core/BackButton.vue'
import LoadingSpinner from '../Components/LoadingSpinner.vue'
const { navigate } = useNavigate()
const modal = useModal()
usePageTitle('Batch Add Cooperatives')
const COOPERATIVE_TYPES = ['AGRICULTURAL', 'CREDIT', 'CONSUMERS', 'MARKETING', 'SERVICE', 'MULTIPURPOSE']
const COOPERATIVE_CATEGORIES = ['MICRO', 'SMALL', 'MEDIUM', 'LARGE']
const blankRow = () => ({
name: '',
address: '',
registration_number: '',
cin: '',
tin: '',
cooperative_type: '',
cooperative_category: '',
registration_date: '',
contact_person: '',
contact_number: '',
contact_email: '',
})
const cooperatives = ref([blankRow()])
const saving = ref(false)
const addRow = () => {
cooperatives.value.push(blankRow())
}
const removeRow = (index) => {
if (cooperatives.value.length > 1) cooperatives.value.splice(index, 1)
}
const saveCooperatives = async () => {
const invalid = cooperatives.value.some(c => !c.name || !c.name.trim())
if (invalid) {
modal.open({
title: 'Validation Error',
body: 'Cooperative Name is required for all rows.'
})
return
}
saving.value = true
try {
const response = await axios.post('/admin/batch/cooperatives', { cooperatives: cooperatives.value })
if (response.data && response.data.success) {
modal.open({
title: 'Success',
body: `Successfully added ${response.data.count} cooperatives.`,
onClose: () => navigate({ page: 'CooperativeList' })
})
}
} catch (err) {
console.error('Error saving batch cooperatives:', err)
const errorMessage = err.response?.data?.errors
? err.response.data.errors.join('<br>')
: (err.response?.data?.message || 'Failed to save cooperatives.')
modal.open({
title: 'Error',
body: errorMessage
})
} finally {
saving.value = false
}
}
</script>
<template>
<div class="batch-add-page pb-5">
<div class="tf-container mt-4">
<div class="mb-3">
<BackButton to="CooperativeList" />
</div>
<div class="mb-4">
<h3 class="fw_6 mb-1">Batch Add Cooperatives</h3>
<p class="text-muted small mb-0">Register multiple cooperatives at once. Ideal for large-scale onboarding.</p>
</div>
<div class="d-grid mb-3">
<button @click="addRow" class="btn btn-outline-primary rounded-pill">
<i class="fas fa-plus-circle me-2"></i> Add Cooperative
</button>
</div>
<div class="row g-4">
<div v-for="(coop, index) in cooperatives" :key="index" class="col-md-6 col-lg-4">
<div class="leaf-card p-3 bg-white rounded-3 border position-relative h-100">
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
<span class="badge bg-primary rounded-pill">#{{ index + 1 }}</span>
<button @click="removeRow(index)" class="btn btn-link text-danger p-0 border-0"
:disabled="cooperatives.length <= 1"><i class="fas fa-times-circle"></i></button>
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">Name *</label>
<input v-model="coop.name" type="text" class="form-control form-control-sm" placeholder="Cooperative name">
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">Address</label>
<input v-model="coop.address" type="text" class="form-control form-control-sm" placeholder="Address">
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Registration #</label>
<input v-model="coop.registration_number" type="text" class="form-control form-control-sm" placeholder="REG-12345">
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">CIN</label>
<input v-model="coop.cin" type="text" class="form-control form-control-sm" placeholder="CIN">
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">TIN</label>
<input v-model="coop.tin" type="text" class="form-control form-control-sm" placeholder="TIN">
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Type</label>
<select v-model="coop.cooperative_type" class="form-select form-select-sm">
<option value="">Select Type</option>
<option v-for="t in COOPERATIVE_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Category</label>
<select v-model="coop.cooperative_category" class="form-select form-select-sm">
<option value="">Select Category</option>
<option v-for="c in COOPERATIVE_CATEGORIES" :key="c" :value="c">{{ c }}</option>
</select>
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">Registration Date</label>
<input v-model="coop.registration_date" type="date" class="form-control form-control-sm">
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Contact Person</label>
<input v-model="coop.contact_person" type="text" class="form-control form-control-sm" placeholder="Full name">
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Contact Number</label>
<input v-model="coop.contact_number" type="text" class="form-control form-control-sm" placeholder="09123456789">
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">Contact Email</label>
<input v-model="coop.contact_email" type="email" class="form-control form-control-sm" placeholder="email@example.com">
</div>
</div>
</div>
</div>
<div class="d-grid mt-4">
<button @click="addRow" class="btn btn-outline-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-plus-circle me-2"></i> Add Another Cooperative
</button>
</div>
<div class="d-grid mt-3 pt-3 border-top">
<button @click="saveCooperatives" :disabled="saving" class="btn btn-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-save me-2"></i>
{{ saving ? 'Saving...' : 'Save All Cooperatives' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.batch-add-page {
background: var(--bg-primary);
min-height: 100vh;
}
.leaf-card { transition: box-shadow 0.15s ease, transform 0.15s ease; }
.leaf-card:hover { box-shadow: 0 4px 12px rgba(13,110,253,0.08); transform: translateY(-2px); }
:global(.dark-mode) .leaf-card { background-color: var(--bg-secondary) !important; border-color: var(--border-color) !important; }
:global(.dark-mode) .form-control, :global(.dark-mode) .form-select {
background-color: var(--bg-secondary) !important;
color: var(--text-primary);
border-color: var(--border-color);
}
</style>

View File

@@ -0,0 +1,774 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import { usePageTitle } from '../composables/Core/usePageTitle'
import { useAuth } from '../composables/Core/useAuth'
import BackButton from '../Components/Core/BackButton.vue'
import LoadingSpinner from '../Components/LoadingSpinner.vue'
import { useFileUpload } from '../composables/useFileUpload.js'
const { navigate } = useNavigate()
const modal = useModal()
const { isStoreOwner } = useAuth()
const { uploadFile } = useFileUpload({ category: 'ProductMarket' })
usePageTitle('Batch Add Products')
const handleLeafPhoto = async (index, event) => {
const file = event.target.files?.[0]
if (!file) return
products.value[index].photoUploading = true
const result = await uploadFile(file)
products.value[index].photoUploading = false
if (result?.hashkey) products.value[index].photoHash = result.hashkey
}
const removeLeafPhoto = (index) => {
products.value[index].photoHash = ''
}
const downloadingTemplate = ref(false)
const downloadTemplate = async () => {
downloadingTemplate.value = true
try {
const response = await axios.get('/admin/batch/products/template', {
responseType: 'blob',
})
const url = URL.createObjectURL(new Blob([response.data]))
const a = document.createElement('a')
a.href = url
a.download = 'bukidbounty-batch-products-template.xlsx'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
modal.open({ title: 'Error', body: 'Failed to download template. Please try again.' })
} finally {
downloadingTemplate.value = false
}
}
const makeLeaf = () => ({
source: 'new',
product_hash: '',
linked: null,
name: '',
price: 0,
available: 0,
unitname: 'pcs',
description: '',
category: '',
subcategory: '',
barcode: '',
photoHash: '',
photoUploading: false,
})
const products = ref([makeLeaf()])
const saving = ref(false)
const categories = ref([])
const selectableStores = ref([])
const targetStoreHash = ref('')
const targetStore = computed(() =>
selectableStores.value.find(s => s.hashkey === targetStoreHash.value) || null
)
const addProduct = () => { products.value.push(makeLeaf()) }
const removeProduct = (index) => {
if (products.value.length > 1) products.value.splice(index, 1)
}
const fetchCategories = async () => {
try {
const response = await axios.post('/Products/New/Category/Datalist')
if (response.data && response.data.success) {
categories.value = response.data.categories
}
} catch (err) {
console.error('Error fetching categories:', err)
}
}
const fetchSelectableStores = async () => {
try {
const response = await axios.post('/Admin/Stores/Selectable')
if (response.data && response.data.success) {
selectableStores.value = response.data.data || []
}
} catch (err) {
console.error('Error loading stores:', err)
}
// Store owners must have an existing store before importing. Without
// one the backend rejects every row, so block the page and offer to
// create a store instead of letting them fill the form for nothing.
if (isStoreOwner.value && selectableStores.value.length === 0) {
modal.yesNoModal({
title: 'No store found',
body: 'You need to create a store before importing products.',
yesText: 'Create Store',
onYes: () => navigate({ page: 'CreateStore' }),
noText: 'Cancel',
onNo: () => navigate({ page: 'Home' }),
})
}
}
// --- Fuzzy picker modal -----------------------------------------------
const showPickerModal = ref(false)
const pickerLeafIndex = ref(-1)
const pickerQuery = ref('')
const pickerResults = ref([])
const pickerSearching = ref(false)
let pickerDebounce = null
const openPicker = (index) => {
pickerLeafIndex.value = index
pickerQuery.value = products.value[index].name || ''
pickerResults.value = []
showPickerModal.value = true
if (pickerQuery.value.trim().length >= 2) runPickerSearch({ warnIfEmpty: true })
}
const closePicker = () => {
showPickerModal.value = false
pickerLeafIndex.value = -1
pickerQuery.value = ''
pickerResults.value = []
}
const onPickerQueryInput = () => {
if (pickerDebounce) clearTimeout(pickerDebounce)
pickerDebounce = setTimeout(runPickerSearch, 250)
}
const runPickerSearch = async ({ warnIfEmpty = false } = {}) => {
const q = pickerQuery.value.trim()
if (q.length < 2) { pickerResults.value = []; return }
pickerSearching.value = true
try {
const { data } = await axios.post('/Products/Admin/FuzzySearch', {
name: q,
TargetStore: targetStoreHash.value || '',
})
pickerResults.value = (data && data.success) ? (data.data || []) : []
if (warnIfEmpty && pickerResults.value.length === 0) {
closePicker()
modal.open({
title: 'Warning',
body: `No existing global products found matching "${q}". Try a different name, or fill out this card to create a new product.`,
})
}
} catch (err) {
console.error('Fuzzy search failed:', err)
pickerResults.value = []
} finally {
pickerSearching.value = false
}
}
const selectGlobalProduct = (match) => {
if (match.already_in_store) return
const i = pickerLeafIndex.value
if (i < 0) return
products.value[i] = {
source: 'existing',
product_hash: match.hashkey,
linked: {
name: match.name,
price: match.price,
unitname: match.unitname,
category: match.category,
subcategory: match.subcategory,
description: match.description,
photourl: match.photourl,
},
name: match.name,
price: 0,
available: 0,
unitname: match.unitname,
description: '',
category: match.category || '',
subcategory: match.subcategory || '',
barcode: '',
}
closePicker()
}
const unlinkLeaf = (index) => {
products.value[index] = makeLeaf()
}
// --- Save -------------------------------------------------------------
const saveProducts = async () => {
const hasExisting = products.value.some(p => p.source === 'existing')
if ((hasExisting || isStoreOwner.value) && !targetStoreHash.value) {
modal.open({
title: 'Pick a Store',
body: isStoreOwner.value
? 'Select one of your stores at the top of the page before importing.'
: 'Select a target store at the top of the page to import existing products.'
})
return
}
for (let i = 0; i < products.value.length; i++) {
const p = products.value[i]
if (p.source === 'existing') {
if (!p.product_hash) {
modal.open({ title: 'Validation Error', body: `Row ${i + 1}: existing product link missing.` })
return
}
} else {
if (!p.name || p.price < 0 || p.available < 0 || !p.unitname) {
modal.open({
title: 'Validation Error',
body: `Row ${i + 1}: fill in Name, Price, Stock, and Unit.`
})
return
}
}
}
const payload = {
target_store_hash: targetStoreHash.value || null,
products: products.value.map(p => p.source === 'existing'
? {
source: 'existing',
product_hash: p.product_hash,
price: p.price,
available: p.available,
description: p.description,
}
: {
source: 'new',
name: p.name,
price: p.price,
available: p.available,
unitname: p.unitname,
description: p.description,
category: p.category,
subcategory: p.subcategory,
barcode: p.barcode,
photourl: p.photoHash ? [p.photoHash] : [],
}),
}
saving.value = true
try {
const response = await axios.post('/admin/batch/products', payload)
if (response.data && response.data.success) {
modal.open({
title: 'Success',
body: `Successfully added ${response.data.count} products.`,
onClose: () => navigate({ page: 'ManageProductsAdmin' })
})
}
} catch (err) {
console.error('Error saving batch products:', err)
const errorMessage = err.response?.data?.errors
? err.response.data.errors.join('<br>')
: (err.response?.data?.message || 'Failed to save products.')
modal.open({ title: 'Error', body: errorMessage })
} finally {
saving.value = false
}
}
onMounted(() => {
fetchCategories()
fetchSelectableStores()
})
</script>
<template>
<div class="batch-add-page min-vh-100 bg-light pb-5">
<header class="header-premium text-white py-4 shadow-sm position-relative overflow-hidden mb-4 bg-primary-gradient">
<div class="container position-relative z-2">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-4">
<div class="d-flex align-items-center gap-4 animate-fade-in">
<div class="display-container position-relative bg-white rounded-circle p-3 shadow">
<i class="fas fa-boxes-stacked fa-2x text-primary"></i>
</div>
<div>
<h2 class="fw-bold text-white mb-0">Batch Add Products</h2>
<p class="text-white-50 small text-uppercase ls-wide mt-1">Add multiple products each leaf is a complete product</p>
</div>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0 w-100 w-md-auto flex-wrap">
<BackButton to="ManageProductsAdmin" />
<button @click="navigate({ page: 'ManageProductsAdmin' })" class="btn btn-outline-light btn-sm fw-semibold rounded-pill shadow-sm">
<i class="fas fa-list me-2"></i> All Products
</button>
<button @click="downloadTemplate" :disabled="downloadingTemplate" class="btn btn-outline-light btn-sm fw-semibold rounded-pill shadow-sm">
<span v-if="downloadingTemplate"><LoadingSpinner size="small" class="me-1" /></span>
<span v-else><i class="fas fa-file-excel me-2"></i></span>
Template
</button>
<button @click="saveProducts" :disabled="saving" class="btn btn-light btn-sm fw-semibold shadow-sm">
<span v-if="saving"><LoadingSpinner size="small" class="me-2" /> Saving...</span>
<span v-else><i class="fas fa-save me-2"></i> Save All</span>
</button>
</div>
</div>
</div>
</header>
<div class="container">
<!-- Template download card -->
<div class="card border-0 shadow-sm rounded-4 mb-4 template-card overflow-hidden">
<div class="card-body p-0">
<div class="d-flex flex-wrap align-items-center gap-4 p-4">
<div class="d-flex align-items-center gap-4 flex-grow-1">
<div class="template-icon-wrap rounded-3 d-flex align-items-center justify-content-center flex-shrink-0">
<i class="fas fa-file-excel fa-2x text-success"></i>
</div>
<div>
<h6 class="fw-bold mb-1">Excel Template</h6>
<p class="text-muted small mb-0">
Download the pre-formatted Excel template. Fill in product data and add photos using
<strong>Insert Pictures This Device</strong> in each Photo cell.
Then enter your data here using the cards below.
</p>
</div>
</div>
<button
@click="downloadTemplate"
:disabled="downloadingTemplate"
class="btn btn-success rounded-pill fw-semibold px-4 shadow-sm w-100 w-md-auto"
>
<span v-if="downloadingTemplate">
<LoadingSpinner size="small" class="me-2" />Downloading
</span>
<span v-else>
<i class="fas fa-download me-2"></i>Download Template
</span>
</button>
</div>
<div class="template-steps d-flex gap-0 border-top">
<div class="step-item flex-fill text-center py-2 px-2 border-end">
<div class="fw-bold small text-primary"> Download</div>
<div class="smallest text-muted">Get the .xlsx template</div>
</div>
<div class="step-item flex-fill text-center py-2 px-2 border-end">
<div class="fw-bold small text-primary"> Fill in Excel</div>
<div class="smallest text-muted">Name, Price, Stock, Unit</div>
</div>
<div class="step-item flex-fill text-center py-2 px-2 border-end">
<div class="fw-bold small text-primary"> Add Photos</div>
<div class="smallest text-muted">Insert Pictures per row</div>
</div>
<div class="step-item flex-fill text-center py-2 px-2">
<div class="fw-bold small text-primary"> Enter here + 📷</div>
<div class="smallest text-muted">Use cards below + camera</div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-lg rounded-4 bg-white overflow-hidden mb-4">
<div class="card-body p-4">
<div class="mb-4 pb-3 border-bottom">
<label class="form-label small fw-bold text-muted mb-1">Target Store (optional for new products, required to import existing)</label>
<select v-model="targetStoreHash" class="form-select">
<option value="">No store create global products only</option>
<option v-for="store in selectableStores" :key="store.hashkey" :value="store.hashkey">
{{ store.name }}<span v-if="store.role"> ({{ store.role }})</span>
</option>
</select>
<div class="form-text smallest">
When a store is picked, every leaf (new or imported) is also listed in that store with its price, stock, and description.
</div>
</div>
<div class="d-grid mb-3">
<button @click="addProduct" class="btn btn-primary fw-bold rounded-pill">
<i class="fas fa-plus-circle me-2"></i> Add Product
</button>
</div>
<div class="alert alert-info border-0 rounded-3 small mb-4">
<i class="fas fa-info-circle me-2"></i>
Each card is a product. Use <strong>Pick existing</strong> to import a global product into the target store with your own price/stock/description.
</div>
<div class="row g-4">
<div
v-for="(product, index) in products"
:key="index"
class="col-md-6 col-lg-4"
>
<div class="leaf-card p-3 bg-white rounded-3 border position-relative h-100" :class="{ 'is-imported': product.source === 'existing' }">
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary rounded-pill">#{{ index + 1 }}</span>
<span v-if="product.source === 'existing'" class="badge bg-success-subtle text-success border border-success-subtle">
<i class="fas fa-link me-1"></i>Imported
</span>
</div>
<div class="d-flex align-items-center gap-2">
<button
v-if="product.source === 'new'"
@click="openPicker(index)"
class="btn btn-link btn-sm text-primary p-0 fw-semibold text-decoration-none text-nowrap"
title="Pick an existing global product"
>
<i class="fas fa-search me-1"></i> Pick existing
</button>
<button
v-else
@click="unlinkLeaf(index)"
class="btn btn-link btn-sm text-secondary p-0 fw-semibold text-decoration-none text-nowrap"
title="Unlink and start fresh"
>
<i class="fas fa-unlink me-1"></i> Unlink
</button>
<button
@click="removeProduct(index)"
class="btn btn-link text-danger p-0 border-0"
:disabled="products.length <= 1"
title="Remove product"
>
<i class="fas fa-times-circle"></i>
</button>
</div>
</div>
<template v-if="product.source === 'existing'">
<div class="mb-2">
<div class="fw-bold">{{ product.linked.name }}</div>
<div class="text-muted smallest">
<span v-if="product.linked.category">{{ product.linked.category }}<span v-if="product.linked.subcategory"> · {{ product.linked.subcategory }}</span> · </span>
<span>Global: {{ product.linked.price }} / {{ product.linked.unitname }}</span>
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-7">
<label class="form-label small fw-bold text-muted mb-1">Store Price</label>
<div class="input-group input-group-sm">
<span class="input-group-text bg-white text-muted"></span>
<input
v-model.number="product.price"
type="number"
step="0.01"
class="form-control fw-bold"
:placeholder="`Default ${product.linked.price}`"
>
</div>
<div class="form-text smallest">Leave 0 to use global {{ product.linked.price }}.</div>
</div>
<div class="col-5">
<label class="form-label small fw-bold text-muted mb-1">Stock *</label>
<input v-model.number="product.available" type="number" class="form-control form-control-sm fw-bold" placeholder="0">
</div>
</div>
<label class="form-label small fw-bold text-muted mb-1">Store Description</label>
<input
v-model="product.description"
type="text"
class="form-control form-control-sm"
:placeholder="product.linked.description ? `Default: ${product.linked.description}` : 'Leave blank to use global default'"
>
</template>
<template v-else>
<label class="form-label small fw-bold text-muted mb-1">Product Name *</label>
<input
v-model="product.name"
type="text"
class="form-control form-control-sm fw-bold mb-2"
placeholder="e.g. Banana"
>
<div class="row g-2 mb-2">
<div class="col-7">
<label class="form-label small fw-bold text-muted mb-1">Price *</label>
<div class="input-group input-group-sm">
<span class="input-group-text bg-white text-muted"></span>
<input v-model.number="product.price" type="number" step="0.01" class="form-control fw-bold" placeholder="0.00">
</div>
</div>
<div class="col-5">
<label class="form-label small fw-bold text-muted mb-1">Stock *</label>
<input v-model.number="product.available" type="number" class="form-control form-control-sm fw-bold" placeholder="0">
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Unit *</label>
<input v-model="product.unitname" type="text" class="form-control form-control-sm" placeholder="pcs, kg...">
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Category</label>
<select v-model="product.category" class="form-select form-select-sm">
<option value=""> Category </option>
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Subcategory</label>
<input v-model="product.subcategory" type="text" class="form-control form-control-sm" placeholder="Optional">
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Barcode</label>
<input v-model="product.barcode" type="text" class="form-control form-control-sm" placeholder="UPC / EAN">
</div>
</div>
<!-- Photo (optional) -->
<label class="form-label small fw-bold text-muted mb-1 mt-1">Photo</label>
<div class="d-flex align-items-center gap-2 mb-2">
<label
v-if="!product.photoHash"
class="btn btn-outline-secondary btn-sm rounded-pill flex-grow-1"
:class="{ disabled: product.photoUploading }"
:for="`photo-input-${index}`"
style="cursor:pointer;"
>
<span v-if="product.photoUploading">
<LoadingSpinner size="small" class="me-1" /> Uploading
</span>
<span v-else>
<i class="fas fa-camera me-1"></i> Add Photo
</span>
</label>
<div v-else class="d-flex align-items-center gap-2 flex-grow-1">
<img
:src="`/RequestData/File/${product.photoHash}`"
class="rounded-2 border"
style="width:48px;height:48px;object-fit:cover;"
alt="Product photo"
/>
<button
class="btn btn-link btn-sm text-danger p-0"
@click="removeLeafPhoto(index)"
title="Remove photo"
>
<i class="fas fa-times-circle"></i>
</button>
</div>
<input
:id="`photo-input-${index}`"
type="file"
accept="image/*"
class="d-none"
@change="(e) => handleLeafPhoto(index, e)"
/>
</div>
<label class="form-label small fw-bold text-muted mb-1">Description</label>
<input v-model="product.description" type="text" class="form-control form-control-sm" placeholder="Short description">
</template>
</div>
</div>
</div>
<div class="d-grid mt-4">
<button @click="addProduct" class="btn btn-outline-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-plus-circle me-2"></i> Add Another Product
</button>
</div>
<div class="d-grid mt-3 pt-3 border-top">
<button @click="saveProducts" :disabled="saving" class="btn btn-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-save me-2" :class="{ 'fa-spin': saving }"></i>
{{ saving ? 'Saving...' : 'Save All Products' }}
</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="showPickerModal" class="bb-modal-backdrop" @click.self="closePicker">
<div class="bb-modal">
<div class="bb-modal-header">
<div class="flex-grow-1 me-2">
<h4 class="fw_7 mb-1">Pick an existing global product</h4>
<p class="text-muted small mb-0">
<span v-if="targetStore">It will be imported into <strong>{{ targetStore.name }}</strong> with your store-specific price, stock, and description.</span>
<span v-else class="text-warning"><i class="fas fa-exclamation-triangle me-1"></i> Select a target store at the top of the page first.</span>
</p>
</div>
<button class="bb-modal-close" @click="closePicker" aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="bb-modal-body">
<input
v-model="pickerQuery"
@input="onPickerQueryInput"
type="text"
class="form-control mb-3"
placeholder="Search global products by name..."
autofocus
>
<div v-if="pickerSearching" class="text-center text-muted py-3">
<LoadingSpinner size="small" /> Searching...
</div>
<div v-else-if="pickerResults.length === 0 && pickerQuery.trim().length >= 2" class="text-muted text-center py-3">
No matching global products found.
</div>
<div v-else>
<div v-for="m in pickerResults" :key="m.hashkey" class="match-row d-flex align-items-center justify-content-between gap-2 p-2 border rounded mb-2">
<div class="flex-grow-1">
<div class="fw_6">{{ m.name }}</div>
<div class="text-muted small">
<span v-if="m.category">{{ m.category }}<span v-if="m.subcategory"> · {{ m.subcategory }}</span> · </span>
<span>{{ m.price }} / {{ m.unitname }}</span>
</div>
<div v-if="m.already_in_store" class="text-success smallest mt-1">
<i class="fas fa-check-circle me-1"></i> Already in this store
</div>
</div>
<button
class="btn btn-sm btn-primary rounded-pill flex-shrink-0"
:disabled="m.already_in_store || !targetStoreHash"
@click="selectGlobalProduct(m)"
>
<span v-if="m.already_in_store">In Store</span>
<span v-else>Use this</span>
</button>
</div>
</div>
</div>
<div class="bb-modal-footer">
<button class="btn btn-link text-muted" @click="closePicker">Cancel</button>
</div>
</div>
</div>
</template>
<style scoped>
.bg-primary-gradient {
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
}
.leaf-card {
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.leaf-card:hover {
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.08);
transform: translateY(-2px);
}
.leaf-card.is-imported {
border-color: #198754 !important;
background: linear-gradient(180deg, rgba(25, 135, 84, 0.04) 0%, rgba(255, 255, 255, 0) 60%);
}
:global(.dark-mode) .bg-light {
background-color: var(--bg-tertiary) !important;
}
:global(.dark-mode) .card {
background-color: var(--bg-card);
}
:global(.dark-mode) .leaf-card {
background-color: var(--bg-secondary) !important;
border-color: var(--border-color) !important;
}
:global(.dark-mode) .form-control,
:global(.dark-mode) .form-select,
:global(.dark-mode) .input-group-text {
background-color: var(--bg-secondary) !important;
color: var(--text-primary);
border-color: var(--border-color);
}
.bb-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 16px;
}
.bb-modal {
background: #fff;
border-radius: 16px;
width: 100%;
max-width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35);
}
.bb-modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
}
.bb-modal-close {
background: transparent;
border: none;
color: #64748b;
cursor: pointer;
font-size: 1rem;
padding: 4px 8px;
}
.bb-modal-body {
padding: 16px 24px;
overflow-y: auto;
}
.bb-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
}
:global(.dark-mode) .bb-modal {
background: var(--bg-card);
color: var(--text-primary);
}
:global(.dark-mode) .bb-modal-header,
:global(.dark-mode) .bb-modal-footer {
border-color: var(--border-color);
}
/* Template download card */
.template-card {
border: 1.5px solid #d1fae5 !important;
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
}
.template-icon-wrap {
width: 56px;
height: 56px;
background: #dcfce7;
}
.template-steps {
background: rgba(0, 0, 0, 0.02);
}
.step-item:last-child {
border-right: 0 !important;
}
.smallest {
font-size: 0.72rem;
}
:global(.dark-mode) .template-card {
background: linear-gradient(135deg, rgba(16,185,129,0.08) 0%, rgba(5,150,105,0.05) 100%);
border-color: rgba(16,185,129,0.3) !important;
}
:global(.dark-mode) .template-icon-wrap {
background: rgba(16,185,129,0.15);
}
:global(.dark-mode) .template-steps {
border-color: var(--border-color) !important;
}
:global(.dark-mode) .step-item {
border-color: var(--border-color) !important;
}
</style>

View File

@@ -0,0 +1,183 @@
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import { usePageTitle } from '../composables/Core/usePageTitle'
import BackButton from '../Components/Core/BackButton.vue'
import LoadingSpinner from '../Components/LoadingSpinner.vue'
const { navigate } = useNavigate()
const modal = useModal()
usePageTitle('Batch Add Stores')
const stores = ref([
{ name: '', description: '', address: '', category: '', subcategory: '', owner_hash: '' }
])
const loading = ref(false)
const saving = ref(false)
const categories = ref([])
const owners = ref([])
const addRow = () => {
stores.value.push({ name: '', description: '', address: '', category: '', subcategory: '', owner_hash: '' })
}
const removeRow = (index) => {
if (stores.value.length > 1) {
stores.value.splice(index, 1)
}
}
const fetchData = async () => {
try {
const [catRes, ownerRes] = await Promise.all([
axios.post('/Store/New/Category/Datalist'),
axios.post('/admin/user/list/numbers/hash')
])
if (catRes.data) categories.value = catRes.data
if (ownerRes.data) owners.value = ownerRes.data
} catch (err) {
console.error('Error fetching store metadata:', err)
}
}
const saveStores = async () => {
const invalid = stores.value.some(s => !s.name || !s.description || !s.address)
if (invalid) {
modal.open({
title: 'Validation Error',
body: 'Please fill in all required fields (Name, Description, Address) for all rows.'
})
return
}
saving.value = true
try {
const response = await axios.post('/admin/batch/stores', { stores: stores.value })
if (response.data && response.data.success) {
modal.open({
title: 'Success',
body: `Successfully added ${response.data.count} stores.`,
onClose: () => navigate({ page: 'ManageStoresAdmin' })
})
}
} catch (err) {
console.error('Error saving batch stores:', err)
const errorMessage = err.response?.data?.errors
? err.response.data.errors.join('<br>')
: (err.response?.data?.message || 'Failed to save stores.')
modal.open({
title: 'Error',
body: errorMessage
})
} finally {
saving.value = false
}
}
onMounted(() => {
fetchData()
})
</script>
<template>
<div class="batch-add-page pb-5">
<div class="tf-container mt-4">
<div class="mb-3">
<BackButton to="ManageStoresAdmin" />
</div>
<div class="mb-4">
<h3 class="fw_6 mb-1">Batch Add Stores</h3>
<p class="text-muted small mb-0">Create multiple stores at once. Ideal for large-scale onboarding.</p>
</div>
<div class="d-grid mb-3">
<button @click="addRow" class="btn btn-outline-primary rounded-pill">
<i class="fas fa-plus-circle me-2"></i> Add Store
</button>
</div>
<div class="row g-4">
<div v-for="(store, index) in stores" :key="index" class="col-md-6 col-lg-4">
<div class="leaf-card p-3 bg-white rounded-3 border position-relative h-100">
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
<span class="badge bg-primary rounded-pill">#{{ index + 1 }}</span>
<button @click="removeRow(index)" class="btn btn-link text-danger p-0 border-0"
:disabled="stores.length <= 1"><i class="fas fa-times-circle"></i></button>
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">Store Name *</label>
<input v-model="store.name" type="text" class="form-control form-control-sm" placeholder="Store name">
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">Address *</label>
<input v-model="store.address" type="text" class="form-control form-control-sm" placeholder="Complete address">
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">Description *</label>
<input v-model="store.description" type="text" class="form-control form-control-sm" placeholder="Short description">
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Category</label>
<select v-model="store.category" class="form-select form-select-sm">
<option value="">Select Category</option>
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Subcategory</label>
<input v-model="store.subcategory" type="text" class="form-control form-control-sm" placeholder="Subcategory">
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">Owner</label>
<select v-model="store.owner_hash" class="form-select form-select-sm">
<option value="">System Default</option>
<option v-for="owner in owners" :key="owner.hashkey" :value="owner.hashkey">{{ owner.name }} ({{ owner.username }})</option>
</select>
</div>
</div>
</div>
</div>
<div class="d-grid mt-4">
<button @click="addRow" class="btn btn-outline-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-plus-circle me-2"></i> Add Another Store
</button>
</div>
<div class="d-grid mt-3 pt-3 border-top">
<button @click="saveStores" :disabled="saving" class="btn btn-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-save me-2"></i>
{{ saving ? 'Saving...' : 'Save All Stores' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.batch-add-page {
background: var(--bg-primary);
min-height: 100vh;
}
.leaf-card { transition: box-shadow 0.15s ease, transform 0.15s ease; }
.leaf-card:hover { box-shadow: 0 4px 12px rgba(13,110,253,0.08); transform: translateY(-2px); }
:global(.dark-mode) .leaf-card { background-color: var(--bg-secondary) !important; border-color: var(--border-color) !important; }
:global(.dark-mode) .form-control, :global(.dark-mode) .form-select {
background-color: var(--bg-secondary) !important;
color: var(--text-primary);
border-color: var(--border-color);
}
</style>

View File

@@ -0,0 +1,183 @@
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import { usePageTitle } from '../composables/Core/usePageTitle'
import BackButton from '../Components/Core/BackButton.vue'
import LoadingSpinner from '../Components/LoadingSpinner.vue'
const { navigate } = useNavigate()
const modal = useModal()
usePageTitle('Batch Add Users')
const users = ref([
{ username: '', name: '', mobile_number: '', password: 'Password123!', type: 'user', parent_hash: '' }
])
const loading = ref(false)
const saving = ref(false)
const userTypes = ref([])
const parents = ref([])
const addRow = () => {
users.value.push({ username: '', name: '', mobile_number: '', password: 'Password123!', type: 'user', parent_hash: '' })
}
const removeRow = (index) => {
if (users.value.length > 1) {
users.value.splice(index, 1)
}
}
const fetchData = async () => {
try {
const [typeRes, parentRes] = await Promise.all([
axios.post('/admin/list/usertype/create'),
axios.post('/admin/user/list/numbers/hash')
])
if (typeRes.data) userTypes.value = typeRes.data
if (parentRes.data) parents.value = parentRes.data
} catch (err) {
console.error('Error fetching user metadata:', err)
}
}
const saveUsers = async () => {
const invalid = users.value.some(u => !u.username || !u.name || !u.mobile_number || !u.password || !u.type)
if (invalid) {
modal.open({
title: 'Validation Error',
body: 'Please fill in all required fields (Username, Name, Mobile, Password, Type) for all rows.'
})
return
}
saving.value = true
try {
const response = await axios.post('/admin/batch/users', { users: users.value })
if (response.data && response.data.success) {
modal.open({
title: 'Success',
body: `Successfully added ${response.data.count} users.`,
onClose: () => navigate({ page: 'UserList' })
})
}
} catch (err) {
console.error('Error saving batch users:', err)
const errorMessage = err.response?.data?.errors
? err.response.data.errors.join('<br>')
: (err.response?.data?.message || 'Failed to save users.')
modal.open({
title: 'Error',
body: errorMessage
})
} finally {
saving.value = false
}
}
onMounted(() => {
fetchData()
})
</script>
<template>
<div class="batch-add-page pb-5">
<div class="tf-container mt-4">
<div class="mb-3">
<BackButton to="UserList" />
</div>
<div class="mb-4">
<h3 class="fw_6 mb-1">Batch Add Users</h3>
<p class="text-muted small mb-0">Efficiently register multiple accounts. All passwords default to "Password123!" if not changed.</p>
</div>
<div class="d-grid mb-3">
<button @click="addRow" class="btn btn-outline-primary rounded-pill">
<i class="fas fa-plus-circle me-2"></i> Add User
</button>
</div>
<div class="row g-4">
<div v-for="(user, index) in users" :key="index" class="col-md-6 col-lg-4">
<div class="leaf-card p-3 bg-white rounded-3 border position-relative h-100">
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
<span class="badge bg-primary rounded-pill">#{{ index + 1 }}</span>
<button @click="removeRow(index)" class="btn btn-link text-danger p-0 border-0"
:disabled="users.length <= 1"><i class="fas fa-times-circle"></i></button>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Username *</label>
<input v-model="user.username" type="text" class="form-control form-control-sm" placeholder="Unique username">
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Full Name *</label>
<input v-model="user.name" type="text" class="form-control form-control-sm" placeholder="User's full name">
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Mobile *</label>
<input v-model="user.mobile_number" type="text" class="form-control form-control-sm" placeholder="09xxxxxxxxx">
</div>
<div class="col-6">
<label class="form-label small fw-bold text-muted mb-1">Password *</label>
<input v-model="user.password" type="text" class="form-control form-control-sm" placeholder="Password">
</div>
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">Account Type</label>
<select v-model="user.type" class="form-select form-select-sm">
<option v-for="type in userTypes" :key="type[0]" :value="type[0]">{{ type[1] }}</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small fw-bold text-muted mb-1">Parent Account</label>
<select v-model="user.parent_hash" class="form-select form-select-sm">
<option value="">System Default (Self)</option>
<option v-for="p in parents" :key="p.hashkey" :value="p.hashkey">{{ p.name }} ({{ p.username }})</option>
</select>
</div>
</div>
</div>
</div>
<div class="d-grid mt-4">
<button @click="addRow" class="btn btn-outline-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-plus-circle me-2"></i> Add Another User
</button>
</div>
<div class="d-grid mt-3 pt-3 border-top">
<button @click="saveUsers" :disabled="saving" class="btn btn-primary rounded-pill px-4 fw-semibold">
<i class="fas fa-save me-2"></i>
{{ saving ? 'Saving...' : 'Save All Users' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.batch-add-page {
background: var(--bg-primary);
min-height: 100vh;
}
.leaf-card { transition: box-shadow 0.15s ease, transform 0.15s ease; }
.leaf-card:hover { box-shadow: 0 4px 12px rgba(13,110,253,0.08); transform: translateY(-2px); }
:global(.dark-mode) .leaf-card { background-color: var(--bg-secondary) !important; border-color: var(--border-color) !important; }
:global(.dark-mode) .form-control, :global(.dark-mode) .form-select {
background-color: var(--bg-secondary) !important;
color: var(--text-primary);
border-color: var(--border-color);
}
</style>

View File

@@ -0,0 +1,323 @@
<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Buy View Product Market');
import { ref, onMounted, computed } from 'vue';
import axios from 'axios';
import { useNavigate } from '../composables/Core/useNavigate';
import LoadingSpinner from '../Components/LoadingSpinner.vue';
import FileImage from '../Components/Core/FileImage.vue';
import { useAuth } from '../composables/Core/useAuth';
import { useModal } from '../composables/Core/useModal';
const props = defineProps({
target: { type: String, required: false },
data: { type: Object, default: () => ({}) },
payload: { type: Object, default: null }
});
const { navigate } = useNavigate();
const { role } = useAuth();
const modal = useModal();
import { useProductStore } from '../stores/product';
const productStore = useProductStore();
const product = computed(() => productStore.currentProduct);
const loading = computed(() => productStore.loading);
const error = computed(() => productStore.error);
const fetchProductDetails = async () => {
const targetHash = props.payload?.product_hashkey || props.payload?.product_hash || props.target;
const storeHash = props.payload?.store_hashkey || props.payload?.store_hash || props.data?.store_hash;
await productStore.fetchProductById(targetHash, storeHash);
};
const goBack = () => {
const storeHash = props.payload?.store_hash || product.value?.store_hash;
if (storeHash) {
navigate({ page: 'ViewStoreMarket', props: { target: storeHash } });
} else {
navigate({ page: 'ListProductsMarket' });
}
};
const manageProduct = () => {
const storeHash = props.payload?.store_hash || product.value?.store_hash;
const productHash = props.payload?.product_hash || props.target;
if (product.value.is_from_store && storeHash) {
navigate({
page: 'ManageProductAdmin',
props: {
payload: {
product_hashkey: productHash,
store_hashkey: storeHash
}
}
});
} else {
navigate({ page: 'ManageProductAdmin', props: { target: productHash } });
}
};
const displayPrice = computed(() => {
if (!product.value) return '';
const price = product.value.store_price || product.value.price;
const prefix = product.value.is_from_store ? 'Store Price ' : '';
return `${prefix}${price} / ${product.value.unitname}`;
});
const addToCart = async () => {
try {
const productHash = props.payload?.product_hash || props.target;
const response = await axios.get(`/cart/add/one/${productHash}`);
if (response.data === true || response.data?.success) {
modal.open({
title: 'Success',
body: 'Added to cart!'
});
} else {
modal.open({
title: 'Error',
body: 'Failed to add to cart.'
});
}
} catch (e) {
console.error('Add to cart failed:', e);
modal.open({
title: 'Error',
body: 'Error adding to cart.'
});
}
};
const buyNow = () => {
// Navigate to a (yet to be created) checkout or confirmation page
modal.open({
title: 'Info',
body: 'Buy Now clicked! This would typically go to checkout.'
});
};
const printPosCode = () => {
const w = window.open('', '_blank', 'width=400,height=500');
w.document.write(`<html><body style="text-align:center;font-family:sans-serif">
<h3>${product.value.name}</h3>
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(product.value.pos_qrcode)}" />
<p style="font-size:12px">${product.value.pos_qrcode}</p>
<script>window.onload=()=>{window.print();window.close();}<\/script>
</body></html>`);
w.document.close();
};
onMounted(() => {
fetchProductDetails();
});
</script>
<template>
<div class="product-details-page pb-5">
<div v-if="loading" class="text-center py-5">
<LoadingSpinner />
<p class="mt-3 text-muted">Loading product details...</p>
</div>
<div v-else-if="error" class="tf-container mt-5 text-center">
<div class="alert alert-danger">{{ error }}</div>
<button @click="goBack" class="btn btn-outline-secondary mt-3 rounded-pill">
Go Back
</button>
</div>
<template v-else-if="product">
<!-- Hero Image Section -->
<div class="product-hero">
<button @click="goBack" class="back-btn shadow">
<i class="fas fa-chevron-left"></i>
</button>
<div class="hero-image-container">
<FileImage :src="product.photourl && product.photourl.length > 0 ? product.photourl[0] : ''"
class="hero-img" fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" />
</div>
</div>
<div class="tf-container product-content">
<div class="info-card shadow-sm">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="fw_7 mb-1">{{ product.name }}</h2>
<span class="badge bg-soft-success text-success rounded-pill px-3">
{{ product.category }}
</span>
</div>
<div class="text-end">
<h3 class="price-tag text-primary fw_7 mb-0">{{ displayPrice }}</h3>
<small class="text-muted" v-if="product.available !== null">
{{ product.available }} available
</small>
</div>
</div>
<div class="description-section mt-4">
<h5 class="fw_6 mb-2">Description</h5>
<p class="text-muted line-height-16">
{{ product.store_description || product.description }}
</p>
</div>
<!-- POS QR Code Section -->
<div v-if="product.pos_qrcode" class="pos-qr-section mt-4 p-3 rounded-xl text-center border">
<h6 class="fw_7 mb-2"><i class="fas fa-barcode me-2"></i> POS Scan Code</h6>
<div class="qr-container p-2 d-inline-block rounded shadow-sm mb-2 qr-container-bg">
<img :src="`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(product.pos_qrcode)}`"
:alt="product.pos_qrcode" style="width: 150px; height: 150px;">
</div>
<div class="small text-muted fw_6 mb-2">{{ product.is_from_store ? 'Store Exclusive Code' : 'Product Identification' }}</div>
<div>
<button @click="printPosCode" class="btn btn-outline-primary btn-sm rounded-pill px-3">
<i class="fas fa-print me-2"></i> Print
</button>
</div>
<div class="stats-row d-flex justify-content-around mt-3 pt-3 border-top">
<div class="stat-item">
<div class="small text-muted">Sold Today</div>
<div class="fw_7 stat-value">
{{ product.is_from_store ? (product.store_sold_today ?? product.sold_today ?? 0) : (product.sold_today ?? 0) }}
</div>
</div>
<div class="stat-item">
<div class="small text-muted">Total Sold</div>
<div class="fw_7 stat-value">
{{ product.is_from_store ? (product.store_sold ?? product.sold ?? 0) : (product.sold ?? 0) }}
</div>
</div>
</div>
</div>
<div class="actions-grid mt-4 pt-4 border-top">
<div class="row g-2">
<div class="col-6">
<button @click="addToCart"
class="btn btn-light w-100 py-3 rounded-xl fw_6 shadow-sm border">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/d36eb6a17e27.bin" class="me-2" style="width: 20px;">
Add Cart
</button>
</div>
<div class="col-6">
<button @click="buyNow" class="btn btn-primary w-100 py-3 rounded-xl fw_6 shadow-sm">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/6446fb001e8b.bin" class="me-2"
style="width: 20px; filter: brightness(0) invert(1);">
Buy Now
</button>
</div>
</div>
</div>
<div v-if="['ult', 'superoperator', 'operator'].includes(role)" class="admin-actions mt-3">
<button @click="manageProduct" class="btn btn-soft-dark w-100 py-3 rounded-xl fw_6">
<i class="fas fa-cog me-2"></i> Manage Product
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.pos-qr-section {
background-color: var(--bg-card);
color: var(--text-primary);
}
.qr-container-bg {
background-color: var(--bg-card);
}
.stat-value {
color: var(--text-primary);
}
.product-hero {
position: relative;
width: 100%;
}
.hero-image-container {
width: 100%;
height: 350px;
background: #f0f0f0;
}
.hero-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.back-btn {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: white;
display: flex;
align-items: center;
justify-content: center;
color: #333;
}
.product-content {
margin-top: -30px;
position: relative;
z-index: 20;
}
.info-card {
background: white;
border-radius: 30px;
padding: 25px;
}
.price-tag {
color: #42b983 !important;
}
.bg-soft-success {
background: rgba(66, 185, 131, 0.1);
}
.rounded-xl {
border-radius: 12px;
}
.btn-soft-dark {
background: #f1f2f6;
color: #2c3e50;
border: none;
}
.line-height-16 {
line-height: 1.6;
}
:global(.dark-mode) .info-card {
background: #24272c;
}
:global(.dark-mode) .back-btn {
background: #24272c;
color: #fff;
}
:global(.dark-mode) .btn-soft-dark {
background: #1a1c20;
color: #e0e0e0;
}
</style>

View File

@@ -0,0 +1,366 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import axios from 'axios'
import { usePageTitle } from '../composables/Core/usePageTitle'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import LoadingSpinner from '../Components/LoadingSpinner.vue'
import BackButton from '../Components/Core/BackButton.vue'
usePageTitle('My Cart')
const { navigate } = useNavigate()
const modal = useModal()
const cart = ref(null)
const items = ref([])
const isLoading = ref(true)
const isUpdating = ref(false)
const cartTotal = computed(() => {
return items.value.reduce((sum, item) => sum + (item.price * item.quantity), 0)
})
const fetchCart = async () => {
isLoading.value = true
try {
const response = await axios.post('/cart/get')
if (response.data && response.data.success) {
cart.value = response.data.cart
items.value = response.data.items || []
}
} catch (error) {
console.error('Error fetching cart:', error)
} finally {
isLoading.value = false
}
}
const updateQuantity = async (item, delta) => {
const newQuantity = item.quantity + delta
if (newQuantity < 1) return
isUpdating.value = true
try {
const response = await axios.post('/cart/update', {
item_hash: item.hashkey,
quantity: newQuantity
})
if (response.data.success) {
item.quantity = newQuantity
}
} catch (error) {
console.error('Error updating quantity:', error)
} finally {
isUpdating.value = false
}
}
const removeItem = async (itemHash) => {
modal.yesNoModal({
title: 'Remove Item',
body: 'Are you sure you want to remove this item from your cart?',
onYes: async () => {
try {
const response = await axios.post('/cart/remove', { item_hash: itemHash })
if (response.data.success) {
items.value = items.value.filter(i => i.hashkey !== itemHash)
}
} catch (error) {
console.error('Error removing item:', error)
}
}
})
}
const clearCart = async () => {
modal.yesNoModal({
title: 'Clear Cart',
body: 'Are you sure you want to clear all items from your cart?',
onYes: async () => {
try {
const response = await axios.post('/cart/clear')
if (response.data.success) {
items.value = []
}
} catch (error) {
console.error('Error clearing cart:', error)
}
}
})
}
const checkout = () => {
// Navigate to checkout or transaction creation page
navigate({ page: 'AddTransaction', props: { scope: 'cart' } })
}
onMounted(() => {
fetchCart()
})
</script>
<template>
<div class="cart-page pb-5">
<div class="tf-container mt-4 mb-3">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<BackButton />
<h2 class="fw_8 ms-3 mb-0 premium-title">Shopping Cart</h2>
</div>
<button v-if="items.length > 0" @click="clearCart" class="btn-clear text-danger">
<i class="far fa-trash-alt me-1"></i> Clear All
</button>
</div>
</div>
<div v-if="isLoading" class="tf-container text-center py-5 mt-5">
<LoadingSpinner size="large" />
<p class="text-muted mt-3">Loading your cart...</p>
</div>
<div v-else-if="items.length === 0" class="tf-container text-center py-5 mt-5">
<div class="empty-cart-illustration mb-4">
<i class="fas fa-shopping-basket text-light" style="font-size: 5rem;"></i>
</div>
<h3 class="fw_7">Your cart is empty</h3>
<p class="text-muted">Looks like you haven't added anything to your cart yet.</p>
<button @click="navigate({ page: 'MarketProduct' })" class="btn btn-primary rounded-pill px-4 py-2 mt-3 fw_6">
Continue Shopping
</button>
</div>
<div v-else class="tf-container">
<div class="cart-items-list mb-4">
<div v-for="item in items" :key="item.hashkey" class="cart-item-card animate-fade-in">
<div class="item-image-wrapper">
<img v-if="item.product?.photourl && item.product.photourl.length > 0"
:src="'/RequestData/File/' + item.product.photourl[0]"
class="item-img"
@error="$event.target.style.display = 'none'">
<div v-else class="item-img-placeholder">
<i class="fas fa-box"></i>
</div>
</div>
<div class="item-details">
<h5 class="fw_7 mb-1">{{ item.product?.name || 'Unknown Product' }}</h5>
<p class="text-muted small mb-2">{{ item.product?.category || 'General' }}</p>
<div class="item-price fw_7 text-primary">₱{{ item.price.toLocaleString() }}</div>
</div>
<div class="item-actions">
<div class="quantity-control shadow-sm rounded-pill">
<button @click="updateQuantity(item, -1)" class="btn-qty" :disabled="item.quantity <= 1 || isUpdating">
<i class="fas fa-minus"></i>
</button>
<span class="qty-num fw_7">{{ item.quantity }}</span>
<button @click="updateQuantity(item, 1)" class="btn-qty" :disabled="isUpdating">
<i class="fas fa-plus"></i>
</button>
</div>
<button @click="removeItem(item.hashkey)" class="btn-remove" :disabled="isUpdating">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- Cart Summary Panel -->
<div class="cart-summary-panel glass-card shadow-lg p-4 mb-5">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Subtotal ({{ items.length }} items)</span>
<span class="fw_6">₱{{ cartTotal.toLocaleString() }}</span>
</div>
<div class="d-flex justify-content-between mb-3 pb-3 border-bottom">
<span class="text-muted">Processing Fee</span>
<span class="text-success fw_6">FREE</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<span class="fw_8 fs-5">Order Total</span>
<span class="fw_8 fs-4 text-primary">₱{{ cartTotal.toLocaleString() }}</span>
</div>
<button @click="checkout" class="btn btn-primary w-100 py-3 rounded-xl fw_8 fs-5 glow-button">
Proceed to Checkout <i class="fas fa-arrow-right ms-2 opacity-50"></i>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.premium-title {
font-family: 'Outfit', sans-serif;
color: #1a202c;
letter-spacing: -0.025em;
}
:global(.dark-mode) .premium-title {
color: #f7fafc;
}
.btn-clear {
background: transparent;
border: none;
font-size: 0.85rem;
font-weight: 600;
padding: 5px 10px;
}
.cart-items-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.cart-item-card {
display: flex;
align-items: center;
background: white;
border-radius: 16px;
padding: 12px;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
border: 1px solid rgba(0,0,0,0.03);
transition: transform 0.2s;
}
:global(.dark-mode) .cart-item-card {
background: #2d3748;
border-color: rgba(255,255,255,0.05);
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.cart-item-card:hover {
transform: translateY(-2px);
}
.item-image-wrapper {
width: 80px;
height: 80px;
border-radius: 12px;
overflow: hidden;
background: #f7fafc;
flex-shrink: 0;
}
:global(.dark-mode) .item-image-wrapper {
background: #1a202c;
}
.item-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-img-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #cbd5e0;
font-size: 1.5rem;
}
.item-details {
flex-grow: 1;
padding-left: 16px;
}
.item-actions {
display: flex;
align-items: center;
gap: 12px;
}
.quantity-control {
display: flex;
align-items: center;
background: #f8fafc;
padding: 2px;
}
:global(.dark-mode) .quantity-control {
background: #1a202c;
}
.btn-qty {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: white;
color: #4a5568;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
transition: all 0.2s;
}
:global(.dark-mode) .btn-qty {
background: #2d3748;
color: #e2e8f0;
}
.btn-qty:hover:not(:disabled) {
background: #edf2f7;
color: #2b6cb0;
}
.qty-num {
width: 32px;
text-align: center;
}
.btn-remove {
background: #fff5f5;
color: #f56565;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
}
:global(.dark-mode) .btn-remove {
background: rgba(245, 101, 101, 0.1);
}
.cart-summary-panel {
border-radius: 24px;
background: white;
}
:global(.dark-mode) .cart-summary-panel {
background: #2d3748;
}
.rounded-xl {
border-radius: 16px;
}
.glow-button {
box-shadow: 0 4px 14px 0 rgba(70, 107, 255, 0.39);
transition: all 0.2s;
}
.glow-button:hover {
box-shadow: 0 6px 20px rgba(70, 107, 255, 0.45);
background-color: #3b82f6;
transform: scale(1.01);
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,145 @@
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useChapters } from '../composables/useChapters.js';
import { useAuth } from '../composables/Core/useAuth.js';
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Chapter Org Chart');
const { fetchOrgChart, loading } = useChapters();
const { isCoopMember } = useAuth();
const ownChapter = ref(null);
const children = ref([]);
const showOfficers = reactive({});
const loadingChild = reactive({});
const roleLabel = (role) => {
const map = {
PRESIDENT: 'President',
VICE_PRESIDENT: 'Vice President',
SECRETARY: 'Secretary',
TREASURER: 'Treasurer',
AUDITOR: 'Auditor',
BOARD_MEMBER: 'Board Member',
};
return map[role] || role || 'Member';
};
const levelLabel = (level) => (level || '').toUpperCase();
const toggleChild = async (child) => {
showOfficers[child.id] = !showOfficers[child.id];
if (showOfficers[child.id] && (!child.officers || child.officers.length === 0) && !child._loaded) {
loadingChild[child.id] = true;
try {
const res = await fetchOrgChart({ chapterId: child.id });
child.officers = res?.officers ?? [];
child._loaded = true;
} finally {
loadingChild[child.id] = false;
}
}
};
onMounted(async () => {
const res = await fetchOrgChart({});
ownChapter.value = res?.own_chapter ?? null;
children.value = (res?.children ?? []).map((c) => ({ ...c, officers: c.officers ?? [], _loaded: false }));
});
</script>
<template>
<div class="container py-4" style="max-width: 720px;">
<div v-if="loading && !ownChapter" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
<template v-else-if="ownChapter">
<!-- Header card -->
<div class="header-card rounded-4 p-4 mb-3">
<span class="badge rounded-pill mb-2 level-badge">{{ levelLabel(ownChapter.level) }}</span>
<h4 class="fw-bold mb-0">{{ ownChapter.name }}</h4>
</div>
<!-- Own chapter officers -->
<div class="panel rounded-4 p-3 mb-3">
<h6 class="fw-semibold mb-3"><i class="fas fa-user-tie me-2"></i>Chapter Officers</h6>
<div v-if="!ownChapter.officers?.length" class="text-muted small">No officers assigned yet.</div>
<div v-else class="d-flex flex-wrap gap-2">
<span v-for="(o, i) in ownChapter.officers" :key="i" class="officer-pill rounded-pill px-3 py-2 small">
<strong>{{ o.name }}</strong>
<span class="opacity-75"> · {{ roleLabel(o.role) }}</span>
</span>
</div>
</div>
<!-- Child chapters (officers only, no members) -->
<div v-if="!isCoopMember" class="panel rounded-4 p-3">
<h6 class="fw-semibold mb-3"><i class="fas fa-sitemap me-2"></i>Sub-Chapters</h6>
<div v-if="!children.length" class="text-muted small">No sub-chapters yet.</div>
<div v-for="child in children" :key="child.id" class="child-row rounded-3 mb-2 p-3">
<div class="d-flex align-items-center gap-2" role="button" @click="toggleChild(child)">
<span class="badge rounded-pill level-badge-sm">{{ levelLabel(child.level) }}</span>
<span class="fw-semibold flex-grow-1 text-truncate">{{ child.name }}</span>
<span class="small text-muted">{{ child.member_count }} member{{ child.member_count !== 1 ? 's' : '' }}</span>
<i class="fas" :class="showOfficers[child.id] ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
</div>
<div v-if="showOfficers[child.id]" class="mt-2 pt-2 border-top">
<div v-if="loadingChild[child.id]" class="small text-muted">Loading officers...</div>
<div v-else-if="!child.officers?.length" class="small text-muted">No officers assigned yet.</div>
<div v-else class="d-flex flex-wrap gap-2">
<span v-for="(o, i) in child.officers" :key="i" class="officer-pill rounded-pill px-3 py-1 small">
<strong>{{ o.name }}</strong>
<span class="opacity-75"> · {{ roleLabel(o.role) }}</span>
</span>
</div>
</div>
</div>
</div>
</template>
<div v-else class="text-center py-5 text-muted">
<i class="fas fa-sitemap fa-2x opacity-25 mb-3"></i>
<p>You are not assigned to a chapter yet.</p>
</div>
</div>
</template>
<style scoped>
.header-card {
background: var(--accent-color);
color: #fff;
}
.level-badge {
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
.level-badge-sm {
background: var(--accent-color);
color: #fff;
font-size: 0.65rem;
}
.panel {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.child-row {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.officer-pill {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark-mode) .panel,
:global(.dark-mode) .child-row,
:global(.dark-mode) .officer-pill {
border-color: rgba(255, 255, 255, 0.08);
}
</style>

View File

@@ -0,0 +1,112 @@
<script setup>
import { ref, watch } from 'vue';
import { useChapters } from '../composables/useChapters.js';
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Search Members');
const { searchMembers, loading } = useChapters();
const query = ref('');
const results = ref([]);
const searched = ref(false);
let debounceTimer = null;
const roleLabel = (role) => {
const map = {
PRESIDENT: 'President',
VICE_PRESIDENT: 'Vice President',
SECRETARY: 'Secretary',
TREASURER: 'Treasurer',
AUDITOR: 'Auditor',
BOARD_MEMBER: 'Board Member',
};
return map[role] || role;
};
const isOfficer = (role) => role && role !== 'MEMBER';
const runSearch = async () => {
const q = query.value.trim();
if (q.length < 2) {
results.value = [];
searched.value = false;
return;
}
searched.value = true;
results.value = await searchMembers(q);
};
watch(query, () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(runSearch, 400);
});
</script>
<template>
<div class="container py-4" style="max-width: 640px;">
<h5 class="fw-bold mb-3"><i class="fas fa-search me-2"></i>Search Members</h5>
<div class="search-bar rounded-pill p-1 mb-3 d-flex align-items-center">
<i class="fas fa-search mx-3 text-muted"></i>
<input
v-model="query"
type="text"
class="form-control border-0 bg-transparent"
placeholder="Type a member name..."
style="box-shadow: none;"
/>
<span v-if="loading" class="spinner-border spinner-border-sm me-3 text-muted"></span>
</div>
<div v-if="query.trim().length < 2" class="text-center py-5 text-muted">
<i class="fas fa-keyboard fa-2x opacity-25 mb-2"></i>
<p class="small mb-0">Type at least 2 characters to search.</p>
</div>
<template v-else>
<div v-if="!results.length && searched && !loading" class="text-center py-5 text-muted">
<i class="fas fa-user-slash fa-2x opacity-25 mb-2"></i>
<p class="small mb-0">No members found matching "{{ query }}".</p>
</div>
<div v-for="(m, i) in results" :key="i" class="result-card rounded-4 p-3 mb-2 d-flex align-items-center gap-3">
<div class="avatar rounded-circle d-flex align-items-center justify-content-center fw-bold">
{{ (m.name || '?').charAt(0).toUpperCase() }}
</div>
<div class="flex-grow-1 overflow-hidden">
<div class="fw-semibold text-truncate">{{ m.name }}</div>
<div class="small text-muted text-truncate">{{ m.chapter_name }}</div>
</div>
<span v-if="isOfficer(m.role)" class="badge rounded-pill role-badge">{{ roleLabel(m.role) }}</span>
</div>
</template>
</div>
</template>
<style scoped>
.search-bar {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.08);
}
.result-card {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.avatar {
width: 44px;
height: 44px;
background: var(--accent-color);
color: #fff;
flex-shrink: 0;
}
.role-badge {
background: var(--accent-color);
color: #fff;
}
:global(.dark-mode) .search-bar,
:global(.dark-mode) .result-card {
border-color: rgba(255, 255, 255, 0.08);
}
</style>

View File

@@ -0,0 +1,238 @@
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import { usePageTitle } from '../composables/Core/usePageTitle';
import { useNavigate } from '../composables/Core/useNavigate';
import { useModal } from '../composables/Core/useModal';
import { encodeHash } from '../composables/useUrlEncoder';
import { computed } from 'vue';
import SearchableTableWrapper from '../Components/Core/SearchableTableWrapper.vue';
import GovernanceResolutions from './Fragments/GovernanceResolutions.vue';
import DocumentRepository from './Fragments/DocumentRepository.vue';
const props = defineProps({
target: String
});
usePageTitle('Cooperative Details');
const { navigate } = useNavigate();
const modal = useModal();
const cooperative = ref(null);
const loading = ref(true);
const searchQuery = ref('');
const tableDensity = ref('comfortable');
const activeTab = ref('members');
const fetchDetails = async () => {
if (!props.target) return;
loading.value = true;
try {
const response = await axios.post('/Cooperatives/Get', { hashkey: props.target });
if (response.data.success) {
cooperative.value = response.data.data;
}
} catch (error) {
console.error('Failed to fetch cooperative details');
} finally {
loading.value = false;
}
};
const viewMember = (userHashkey) => {
navigate({ page: 'UserInfoEdit', props: { target: userHashkey } });
};
const filteredMembers = computed(() => {
if (!cooperative.value?.members) return [];
if (!searchQuery.value) return cooperative.value.members;
const query = searchQuery.value.toLowerCase();
return cooperative.value.members.filter(m =>
(m.user?.fullname?.toLowerCase().includes(query)) ||
(m.user?.name?.toLowerCase().includes(query)) ||
(m.role?.toLowerCase().includes(query))
);
});
const shareRegisterLink = async () => {
const encodedHash = encodeHash(props.target);
const url = `${window.location.origin}/register-coop--${encodedHash}`;
const title = cooperative.value?.name ?? 'Join our Cooperative';
const text = `Register as a member of ${title}`;
if (navigator.share) {
try {
await navigator.share({ title, text, url });
} catch {
// user cancelled or share failed — silently ignore
}
} else {
await navigator.clipboard.writeText(url);
modal.show({ title: 'Link Copied', message: 'Registration link copied to clipboard.', variant: 'info' });
}
};
onMounted(fetchDetails);
</script>
<template>
<div class="cooperative-detail pb-5">
<div class="tf-container mt-4">
<div v-if="loading" class="text-center py-5">
<i class="fas fa-spinner fa-spin fa-2x text-primary mb-2"></i>
<p class="text-muted">Loading details...</p>
</div>
<div v-else-if="!cooperative" class="text-center py-5">
<p class="text-danger">Cooperative not found.</p>
<button @click="navigate({ page: 'CooperativeList' })" class="btn btn-primary rounded-pill px-4 mt-3">Back to List</button>
</div>
<div v-else>
<!-- coop Header -->
<div class="card border-0 shadow-sm rounded-20 p-4 mb-4 bg-primary text-white overflow-hidden position-relative">
<div class="position-absolute top-0 end-0 opacity-10 p-4">
<i class="fas fa-landmark fa-6x rotate-15"></i>
</div>
<div class="d-flex align-items-center gap-3 position-relative">
<div class="bg-white text-primary rounded-circle p-3 shadow-lg">
<i class="fas fa-users fa-2x"></i>
</div>
<div>
<h2 class="fw_8 mb-1">{{ cooperative.name }}</h2>
<p class="mb-0 opacity-75"><i class="fas fa-map-marker-alt me-1"></i> {{ cooperative.address || 'No address provided' }}</p>
</div>
</div>
</div>
<!-- Tabs -->
<div class="card border-0 shadow-sm rounded-20 mb-4 overflow-hidden">
<div class="d-flex border-bottom bg-light">
<button
v-for="tab in ['members', 'governance', 'documents']"
:key="tab"
@click="activeTab = tab"
:class="['flex-fill py-3 border-0 transition-all fw_7 text-capitalize',
activeTab === tab ? 'bg-white text-primary border-bottom-primary' : 'bg-transparent text-muted']"
>
<i :class="['me-2',
tab === 'members' ? 'fas fa-user-friends' :
tab === 'governance' ? 'fas fa-gavel' : 'fas fa-folder-open']">
</i>
{{ tab }}
</button>
</div>
</div>
<!-- Action Buttons (Conditional) -->
<div v-if="activeTab === 'members'" class="mb-4 d-flex justify-content-end gap-2 flex-wrap animate-fade-in">
<button v-if="!cooperative.is_member"
@click="navigate({ page: 'CooperativeMemberRegister', props: { target: props.target } })"
class="btn btn-success rounded-pill px-4 py-2 shadow-sm">
<i class="fas fa-id-card me-2"></i> Register as Member
</button>
<button @click="shareRegisterLink" class="btn btn-outline-primary rounded-pill px-4 py-2 shadow-sm">
<i class="fas fa-share-alt me-2"></i> Share Register Link
</button>
<button @click="navigate({ page: 'EnrollFarmer', props: { target: props.target } })" class="btn btn-primary rounded-pill px-4 py-2 shadow-sm">
<i class="fas fa-user-plus me-2"></i> Enroll New Farmer
</button>
<button @click="navigate({ page: 'BatchAddCooperativeMembers', props: { target: props.target } })" class="btn btn-primary rounded-pill px-4 py-2 shadow-sm">
<i class="fas fa-users-cog me-2"></i> Batch Add Members
</button>
</div>
<!-- Tab Content -->
<transition name="fade" mode="out-in">
<div :key="activeTab">
<!-- Members Section -->
<div v-if="activeTab === 'members'" class="animate-fade-in">
<h4 class="fw_6 mb-3 text-dark d-flex align-items-center gap-2">
<i class="fas fa-users text-primary opacity-50"></i>
Members ({{ filteredMembers.length }}{{ searchQuery ? ' found' : '' }})
</h4>
<SearchableTableWrapper
v-model:search-value="searchQuery"
v-model:density-value="tableDensity"
:empty="filteredMembers.length === 0"
empty-title="No members found"
empty-message="Try a different search term or enroll new members."
empty-icon="fas fa-user-friends"
>
<template #table>
<thead>
<tr class="bg-light">
<th class="border-0">Member Name</th>
<th class="border-0">Role</th>
<th class="border-0 text-end">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="membership in filteredMembers" :key="membership.hashkey"
class="cursor-pointer"
@click="viewMember(membership.user.hashkey)">
<td class="border-0">
<div class="d-flex align-items-center gap-3">
<div class="bg-soft-primary text-primary rounded-circle p-2 member-avatar">
<i class="fas fa-user small"></i>
</div>
<div>
<h6 class="fw_6 mb-0 text-dark">{{ membership.user.fullname || membership.user.name }}</h6>
<small class="text-muted d-block opacity-75">ID: {{ membership.user.hashkey?.substring(0, 8) }}...</small>
</div>
</div>
</td>
<td class="border-0">
<span class="badge rounded-pill bg-soft-info text-info px-3 py-2 fw_5">
{{ membership.role }}
</span>
</td>
<td class="border-0 text-end">
<button class="btn btn-icon btn-soft-primary rounded-circle shadow-sm border-0">
<i class="fas fa-chevron-right small"></i>
</button>
</td>
</tr>
</tbody>
</template>
</SearchableTableWrapper>
</div>
<!-- Governance Section -->
<div v-else-if="activeTab === 'governance'" class="animate-fade-in">
<GovernanceResolutions :org-hash="props.target" />
</div>
<!-- Documents Section -->
<div v-else-if="activeTab === 'documents'" class="animate-fade-in">
<DocumentRepository :org-hash="props.target" />
</div>
</div>
</transition>
</div>
</div>
</div>
</template>
<style scoped>
.rounded-20 { border-radius: 20px; }
.cursor-pointer { cursor: pointer; }
.hover-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0,0,0,0.1) !important;
}
.bg-soft-primary { background-color: rgba(var(--primary-rgb), 0.1); }
.bg-soft-info { background-color: rgba(0, 184, 217, 0.1); }
.text-info { color: #00B8D9 !important; }
.member-avatar { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
.btn-soft-primary { background-color: rgba(var(--primary-rgb), 0.1); color: var(--primary); }
.border-bottom-primary { border-bottom: 3px solid var(--primary) !important; }
.transition-all { transition: all 0.2s ease; }
.rotate-15 { transform: rotate(-15deg); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,72 @@
<script setup>
import { ref, computed } from 'vue';
import { useAuth } from '../composables/Core/useAuth.js';
import CooperativeDetail from '@/Pages/CooperativeDetail.vue';
import DocumentRepository from '@/Pages/Fragments/DocumentRepository.vue';
import GovernanceResolutions from '@/Pages/Fragments/GovernanceResolutions.vue';
const { user } = useAuth();
const activeOrgHash = computed(() => {
const coops = user.value?.settings?.cooperatives;
if (Array.isArray(coops) && coops.length > 0) return coops[0];
return null;
});
const hubTab = ref('overview');
</script>
<template>
<div class="cooperative-hub-page pb-5">
<div class="tf-container mt-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="fw_7 mb-0 d-flex align-items-center gap-2">
<i class="fas fa-landmark text-primary opacity-50"></i>
Cooperative Hub
</h5>
<div class="d-flex gap-1 bg-soft-primary p-1 rounded-pill">
<button
@click="hubTab = 'overview'"
:class="['btn btn-xs rounded-pill px-3', hubTab === 'overview' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>Overview</button>
<button
@click="hubTab = 'docs'"
:class="['btn btn-xs rounded-pill px-3', hubTab === 'docs' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>Docs</button>
<button
@click="hubTab = 'votes'"
:class="['btn btn-xs rounded-pill px-3', hubTab === 'votes' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
>Resolutions</button>
</div>
</div>
<div v-if="!activeOrgHash" class="alert alert-warning small">
No active cooperative is configured for your account.
</div>
<div v-else class="card border-0 shadow-sm rounded-20 bg-white overflow-hidden p-0">
<div v-if="hubTab === 'overview'">
<CooperativeDetail :target="activeOrgHash" />
</div>
<div v-else-if="hubTab === 'docs'" class="p-3">
<DocumentRepository :org-hash="activeOrgHash" />
</div>
<div v-else-if="hubTab === 'votes'" class="p-3">
<GovernanceResolutions :org-hash="activeOrgHash" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.btn-xs {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
line-height: 1.5;
border-radius: 50rem;
}
.bg-soft-primary {
background-color: rgba(var(--primary-rgb), 0.1);
}
</style>

View File

@@ -0,0 +1,216 @@
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import { usePageTitle } from '../composables/Core/usePageTitle';
import { useNavigate } from '../composables/Core/useNavigate';
import { useModal } from '../composables/Core/useModal';
import { useAuth } from '../composables/Core/useAuth';
import TransactionListSkeleton from '../Components/Core/Skeleton/TransactionListSkeleton.vue';
usePageTitle('Cooperatives');
const { navigate } = useNavigate();
const modal = useModal();
const { isUltimate, isSuperOperator } = useAuth();
const cooperatives = ref([]);
const loading = ref(true);
const error = ref(null);
const showCreateModal = ref(false);
const isSaving = ref(false);
const createForm = ref({
name: '',
address: ''
});
const fetchCooperatives = async () => {
loading.value = true;
error.value = null;
try {
const response = await axios.post('/Cooperatives/List');
if (response.data.success) {
cooperatives.value = response.data.data;
} else {
error.value = response.data.message || 'Failed to load cooperatives.';
}
} catch (err) {
console.error('Failed to fetch cooperatives:', err);
error.value = err.response?.data?.message || 'A server error occurred. Please try again later.';
} finally {
loading.value = false;
}
};
const openCreateModal = () => {
createForm.value = { name: '', address: '' };
showCreateModal.value = true;
};
const closeCreateModal = () => {
showCreateModal.value = false;
createForm.value = { name: '', address: '' };
};
const handleCreate = async () => {
if (!createForm.value.name.trim()) {
modal.open({ title: 'Error', body: 'Cooperative name is required.' });
return;
}
isSaving.value = true;
try {
const response = await axios.post('/Cooperatives/Create', createForm.value);
if (response.data.success) {
showCreateModal.value = false;
createForm.value = { name: '', address: '' };
await fetchCooperatives();
modal.open({
title: 'Success',
body: 'Cooperative created successfully!',
});
} else {
modal.open({
title: 'Error',
body: response.data.message || 'Failed to create cooperative. Please try again.'
});
}
} catch (error) {
console.error('Failed to create cooperative:', error);
modal.open({
title: 'Error',
body: error.response?.data?.message || 'Failed to create cooperative. Please try again.'
});
} finally {
isSaving.value = false;
}
};
const viewDetails = (hashkey) => {
navigate({ page: 'CooperativeDetail', props: { target: hashkey } });
};
const goToCreate = () => {
navigate({ page: 'CreateCooperative' });
};
onMounted(fetchCooperatives);
</script>
<template>
<div class="cooperative-list pb-5">
<div class="tf-container mt-4">
<div class="mb-4">
<h3 class="fw_6 mb-3">Cooperatives</h3>
<div class="d-flex gap-2">
<button v-if="isUltimate || isSuperOperator" @click="navigate({ page: 'BatchAddCooperatives' })" class="btn btn-outline-primary rounded-pill px-4 py-2 d-flex align-items-center gap-2 flex-grow-1 justify-content-center">
<i class="fas fa-layer-group"></i> Batch Add
</button>
<button @click="goToCreate" class="btn btn-primary rounded-pill px-4 py-2 d-flex align-items-center gap-2 flex-grow-1 justify-content-center">
<i class="fas fa-plus"></i> New Cooperative
</button>
</div>
</div>
<div v-if="loading" class="mt-2 text-center">
<TransactionListSkeleton :count="6" />
</div>
<div v-else-if="error" class="text-center py-5 px-4 animate-fade-in">
<div class="bg-soft-danger text-danger rounded-circle mx-auto mb-4 p-3 d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
<i class="fas fa-exclamation-triangle fa-3x"></i>
</div>
<h4 class="fw-bold mb-2 text-dark">Data Not Loaded</h4>
<p class="text-muted mb-4 px-lg-5">{{ error }}</p>
<button @click="fetchCooperatives" class="btn btn-outline-primary rounded-pill px-4">
<i class="fas fa-sync-alt me-2"></i> Try Again
</button>
</div>
<div v-else-if="cooperatives.length === 0" class="text-center py-5">
<i class="fas fa-users-slash fa-3x text-muted opacity-2 mb-3"></i>
<p class="text-muted">No cooperatives found.</p>
<button @click="goToCreate" class="btn btn-primary mt-3 rounded-pill px-4">
<i class="fas fa-plus me-2"></i> Create First Cooperative
</button>
</div>
<div v-else class="row g-3">
<div v-for="coop in cooperatives" :key="coop.hashkey" class="col-12 col-md-6">
<div @click="viewDetails(coop.hashkey)" class="card border-0 shadow-sm rounded-20 p-3 cursor-pointer hover-card">
<div class="d-flex align-items-center gap-3">
<div class="bg-primary-subtle rounded-circle p-3 text-primary">
<i class="fas fa-users fa-lg"></i>
</div>
<div class="flex-grow-1">
<h5 class="fw_6 mb-1 text-truncate">{{ coop.name }}</h5>
<p class="text-muted small mb-0"><i class="fas fa-map-marker-alt me-1"></i> {{ coop.address || 'No address' }}</p>
</div>
<div class="text-end">
<span class="badge bg-light text-dark rounded-pill border">{{ coop.members_count || 0 }} Members</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Cooperative Modal -->
<div v-if="showCreateModal" class="modal-backdrop-custom" @click.self="closeCreateModal">
<div class="modal-card">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw_6 mb-0">Create Cooperative</h5>
<button @click="closeCreateModal" class="btn-close"></button>
</div>
<form @submit.prevent="handleCreate">
<div class="mb-3">
<label class="form-label small fw-bold">Cooperative Name *</label>
<input v-model="createForm.name" type="text" class="form-control rounded-pill" required placeholder="Enter cooperative name">
</div>
<div class="mb-4">
<label class="form-label small fw-bold">Address</label>
<textarea v-model="createForm.address" class="form-control rounded-15" rows="2" placeholder="Enter address (optional)"></textarea>
</div>
<div class="d-flex gap-2">
<button type="button" @click="closeCreateModal" class="btn btn-light rounded-pill flex-grow-1 py-2">Cancel</button>
<button :disabled="isSaving" type="submit" class="btn btn-primary rounded-pill flex-grow-1 py-2">
<span v-if="isSaving"><i class="fas fa-spinner fa-spin me-1"></i> Creating...</span>
<span v-else>Create</span>
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.rounded-20 { border-radius: 20px; }
.rounded-15 { border-radius: 15px; }
.cursor-pointer { cursor: pointer; }
.hover-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0,0,0,0.1) !important;
}
.bg-soft-danger { background-color: rgba(220, 53, 69, 0.1); }
.bg-primary-subtle { background-color: rgba(13, 110, 253, 0.1); }
.modal-backdrop-custom {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal-card {
background: white;
border-radius: 20px;
padding: 1.5rem;
width: 100%;
max-width: 450px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
</style>

View File

@@ -0,0 +1,493 @@
<template>
<div class="animate-fade-in pb-5">
<!-- Header -->
<div class="tf-container mb-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<button @click="navigate({ page: 'CooperativeDetail', props: { target: target } })"
class="btn btn-link text-decoration-none p-0 d-flex align-items-center text-muted">
<i class="fas fa-arrow-left me-2"></i>
<span>Back to Cooperative</span>
</button>
</div>
<div v-if="loadingCoop" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else-if="cooperative" class="coop-header-card p-4 rounded-20 shadow-sm mb-4 position-relative overflow-hidden">
<div class="header-overlay"></div>
<div class="position-relative z-1">
<div class="badge bg-white text-primary rounded-pill px-3 py-1 mb-2 mb-md-3 smallest fw-bold shadow-sm">
{{ cooperative.cooperative_type || 'COOPERATIVE' }}
</div>
<h1 class="h2 fw-black text-white mb-2">{{ cooperative.name }}</h1>
<div class="d-flex flex-wrap gap-3 text-white-50 small">
<div v-if="cooperative.address" class="d-flex align-items-center">
<i class="fas fa-map-marker-alt me-2"></i>
{{ cooperative.address }}
</div>
<div v-if="cooperative.registration_number" class="d-flex align-items-center">
<i class="fas fa-id-card me-2"></i>
Reg: {{ cooperative.registration_number }}
</div>
</div>
</div>
</div>
</div>
<!-- Registration Form -->
<div class="tf-container">
<div v-if="alreadyMember" class="alert alert-info rounded-15 border-0 shadow-sm mb-4 d-flex align-items-center p-3 animate-fade-in">
<div class="icon-circle bg-info text-white me-3 p-2 rounded-circle d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
<i class="fas fa-info-circle"></i>
</div>
<div>
<h6 class="mb-0 fw-bold">You are already a member</h6>
<p class="mb-0 small opacity-75">You have already registered as a member of this cooperative.</p>
</div>
</div>
<CardSimple title="Membership Information" icon="fas fa-user-tag" class="mb-4 rounded-20 border-0 shadow-sm">
<div class="row g-3">
<div class="col-md-6 mb-3">
<label class="form-label small fw-bold text-muted">Membership Type</label>
<select v-model="form.membership_type" class="form-select premium-select" :disabled="alreadyMember">
<option value="">Select Type</option>
<option value="REGULAR">REGULAR</option>
<option value="ASSOCIATE">ASSOCIATE</option>
<option value="HONORARY">HONORARY</option>
<option value="LABORATORY">LABORATORY</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label small fw-bold text-muted">Membership Level</label>
<select v-model="form.membership_level" class="form-select premium-select" :disabled="alreadyMember">
<option value="">Select Level</option>
<option value="PRIMARY">PRIMARY</option>
<option value="SECONDARY">SECONDARY</option>
<option value="TERTIARY">TERTIARY</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label small fw-bold text-muted">Year Membership Began</label>
<input type="number" v-model="form.year_beginning" class="form-control premium-input"
placeholder="e.g. 2024" :disabled="alreadyMember" />
</div>
</div>
</CardSimple>
<CardSimple title="Position Details (Optional)" icon="fas fa-briefcase" class="mb-4 rounded-20 border-0 shadow-sm">
<div class="row g-3">
<div class="col-md-6 mb-3">
<label class="form-label small fw-bold text-muted">Officer Position</label>
<input type="text" v-model="form.officer_position" class="form-control premium-input"
placeholder="e.g. Board Member" :disabled="alreadyMember" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label small fw-bold text-muted">Officer Level</label>
<select v-model="form.officer_level" class="form-select premium-select" :disabled="alreadyMember">
<option value="">Select Level</option>
<option value="PRIMARY">PRIMARY</option>
<option value="SECONDARY">SECONDARY</option>
<option value="TERTIARY">TERTIARY</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label small fw-bold text-muted">Concurrent Position</label>
<input type="text" v-model="form.concurrent_position" class="form-control premium-input"
placeholder="e.g. Treasurer" :disabled="alreadyMember" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label small fw-bold text-muted">Concurrent Level</label>
<select v-model="form.concurrent_level" class="form-select premium-select" :disabled="alreadyMember">
<option value="">Select Level</option>
<option value="PRIMARY">PRIMARY</option>
<option value="SECONDARY">SECONDARY</option>
<option value="TERTIARY">TERTIARY</option>
</select>
</div>
<div class="col-md-12 mb-3">
<label class="form-label small fw-bold text-muted">Cooperative Position</label>
<input type="text" v-model="form.cooperative_position" class="form-control premium-input"
placeholder="e.g. Chairperson" :disabled="alreadyMember" />
</div>
</div>
</CardSimple>
<CardSimple title="Classification" icon="fas fa-tags" class="mb-4 rounded-20 border-0 shadow-sm">
<div class="row g-3">
<div class="col-12 mb-3">
<label class="form-label small fw-bold text-muted">Priority Sector <span class="fw-normal">(select all that apply)</span></label>
<div class="row g-2 mt-1">
<div class="col-6 col-md-4" v-for="s in prioritySectors" :key="s">
<div class="form-check">
<input class="form-check-input" type="checkbox" :id="'ps-reg-' + s" :value="s" v-model="form.priority_sector" :disabled="alreadyMember">
<label class="form-check-label small" :for="'ps-reg-' + s">{{ s }}</label>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label small fw-bold text-muted">Common Bond</label>
<select v-model="form.common_bond" class="form-select premium-select" :disabled="alreadyMember">
<option value=""> Select </option>
<option v-for="b in commonBonds" :key="b" :value="b">{{ b }}</option>
</select>
</div>
<div class="col-12">
<label class="form-label small fw-bold text-muted d-block mb-2">Vulnerability Classification</label>
<div class="row g-2">
<div class="col-6 col-md-4" v-for="opt in vulnerabilityOptions" :key="opt">
<div class="form-check">
<input class="form-check-input" type="checkbox" :id="'vuln-'+opt"
:value="opt" v-model="form.vulnerability_classifications" :disabled="alreadyMember" />
<label class="form-check-label small" :for="'vuln-'+opt">{{ opt }}</label>
</div>
</div>
</div>
</div>
</div>
</CardSimple>
<CardSimple title="Government Program Participation" icon="fas fa-list-check" class="mb-4 rounded-20 border-0 shadow-sm">
<div class="row g-2 mb-3">
<div class="col-6 col-md-4" v-for="prog in programOptions" :key="prog">
<div class="form-check">
<input class="form-check-input" type="checkbox" :id="'prog-'+prog"
:value="prog" v-model="form.program_participation" :disabled="alreadyMember" />
<label class="form-check-label small" :for="'prog-'+prog">{{ prog }}</label>
</div>
</div>
</div>
<!-- SLP -->
<template v-if="form.program_participation.includes('SLP')">
<hr class="my-3" />
<p class="small fw-bold text-success mb-2"><i class="fas fa-seedling me-1"></i>SLP Details</p>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label small fw-bold text-muted">SLP Track</label>
<select v-model="form.slp_track" class="form-select premium-select" :disabled="alreadyMember">
<option value=""> Select </option>
<option value="MD">Microenterprise Development (MD)</option>
<option value="EF">Employment Facilitation (EF)</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-muted">SLPA / Association Name</label>
<input type="text" v-model="form.slp_association_name" class="form-control premium-input" :disabled="alreadyMember" />
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-muted">Listahanan (NHTO) ID</label>
<input type="text" v-model="form.listahanan_id" class="form-control premium-input" :disabled="alreadyMember" />
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-muted">4Ps Household ID</label>
<input type="text" v-model="form.fourtps_household_id" class="form-control premium-input" :disabled="alreadyMember" />
</div>
</div>
</template>
<!-- TUPAD -->
<template v-if="form.program_participation.includes('TUPAD')">
<hr class="my-3" />
<p class="small fw-bold text-warning mb-2"><i class="fas fa-hard-hat me-1"></i>TUPAD Details</p>
<div class="row g-3 mb-3">
<div class="col-12">
<label class="form-label small fw-bold text-muted">Beneficiary Category</label>
<select v-model="form.tupad_category" class="form-select premium-select" :disabled="alreadyMember">
<option value=""> Select </option>
<option v-for="c in tupadCategories" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-muted">Insurance Beneficiary Name</label>
<input type="text" v-model="form.tupad_insurance_beneficiary_name" class="form-control premium-input" :disabled="alreadyMember" />
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-muted">Relationship</label>
<input type="text" v-model="form.tupad_insurance_beneficiary_relation" class="form-control premium-input" placeholder="e.g. Spouse, Child" :disabled="alreadyMember" />
</div>
</div>
</template>
<!-- OSEC/NSRP -->
<template v-if="form.program_participation.includes('OSEC/NSRP')">
<hr class="my-3" />
<p class="small fw-bold text-primary mb-2"><i class="fas fa-briefcase me-1"></i>OSEC / NSRP Employment Profile</p>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label small fw-bold text-muted">Employment Status</label>
<select v-model="form.employment_status" class="form-select premium-select" :disabled="alreadyMember">
<option value=""> Select </option>
<option v-for="s in employmentStatuses" :key="s" :value="s">{{ s }}</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-muted">Preferred Occupation</label>
<input type="text" v-model="form.preferred_occupation" class="form-control premium-input" placeholder="e.g. Farmer, Welder" :disabled="alreadyMember" />
</div>
<div class="col-12">
<label class="form-label small fw-bold text-muted d-block mb-2">Technical Skills</label>
<div v-if="!alreadyMember" class="d-flex gap-2 mb-2">
<input v-model="newSkill" type="text" class="form-control premium-input"
placeholder="Add a skill and press +" @keyup.enter="addSkill" />
<button @click="addSkill" class="btn btn-outline-primary rounded-pill px-3">+</button>
</div>
<div class="d-flex flex-wrap gap-1">
<span v-for="(sk, i) in form.nsrp_skills" :key="i"
class="badge bg-primary-subtle text-primary rounded-pill px-3 py-2">
{{ sk }}
<i v-if="!alreadyMember" class="fas fa-times ms-1 cursor-pointer" @click="form.nsrp_skills.splice(i, 1)"></i>
</span>
</div>
</div>
</div>
</template>
</CardSimple>
<CardSimple title="Government ID Numbers" icon="fas fa-id-badge" class="mb-4 rounded-20 border-0 shadow-sm">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">PhilSys ID</label>
<input type="text" v-model="form.philsys_id" class="form-control premium-input" placeholder="National ID" :disabled="alreadyMember" />
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">SSS Number</label>
<input type="text" v-model="form.sss_number" class="form-control premium-input" placeholder="00-0000000-0" :disabled="alreadyMember" />
</div>
<div class="col-md-4">
<label class="form-label small fw-bold text-muted">Pag-IBIG Number</label>
<input type="text" v-model="form.pagibig_number" class="form-control premium-input" placeholder="0000-0000-0000" :disabled="alreadyMember" />
</div>
</div>
</CardSimple>
<CardSimple title="Other Information" icon="fas fa-ellipsis-h" class="mb-4 rounded-20 border-0 shadow-sm">
<div class="mb-3">
<label class="form-label small fw-bold text-muted">Alternative Cooperative Name</label>
<input type="text" v-model="form.cooperative_name_alt" class="form-control premium-input"
placeholder="If the cooperative is known by another name" :disabled="alreadyMember" />
</div>
</CardSimple>
<!-- Submit Button -->
<div class="px-3 px-md-0 mt-5">
<button
@click="handleRegister"
class="btn btn-premium-launch w-100 py-3 rounded-pill shadow-primary-sm d-flex align-items-center justify-content-center gap-2"
:disabled="isSaving || alreadyMember"
>
<span v-if="isSaving" class="spinner-border spinner-border-sm" role="status"></span>
<i v-else class="fas fa-check-circle"></i>
<span class="fw-black">{{ alreadyMember ? 'Already Registered' : 'Confirm Registration' }}</span>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import { usePageTitle } from '../composables/Core/usePageTitle';
import { useNavigate } from '../composables/Core/useNavigate';
import { useModal } from '../composables/Core/useModal';
import CardSimple from '../Components/Core/CardSimple.vue';
const props = defineProps({
target: String
});
usePageTitle('Register as Member');
const { navigate } = useNavigate();
const modal = useModal();
const cooperative = ref(null);
const loadingCoop = ref(true);
const isSaving = ref(false);
const alreadyMember = ref(false);
const prioritySectors = ref([]);
const commonBonds = ['Residential', 'Institutional', 'Occupational', 'Associational'];
const employmentStatuses = ['Employed', 'Underemployed', 'Unemployed', 'Self-employed'];
const tupadCategories = ['Underemployed', 'Displaced Worker', 'Senior Citizen (fit to work)', 'PWD', 'Solo Parent', 'Indigenous Person', 'Former Rebel'];
const vulnerabilityOptions = ['Indigenous People (IP)', 'Person with Disability (PWD)', 'Senior Citizen', 'Solo Parent', 'Out-of-School Youth (OSY)', 'Internally Displaced Person (IDP)', 'Distressed OFW', 'Former Rebel'];
const programOptions = ['SLP', 'TUPAD', 'OSEC/NSRP', '4Ps/Pantawid Pamilya', 'Listahanan'];
const newSkill = ref('');
const addSkill = () => {
const s = newSkill.value.trim();
if (s && !form.value.nsrp_skills.includes(s)) form.value.nsrp_skills.push(s);
newSkill.value = '';
};
const form = ref({
membership_type: '',
membership_level: '',
officer_position: '',
officer_level: '',
concurrent_position: '',
concurrent_level: '',
cooperative_name_alt: '',
cooperative_position: '',
year_beginning: '',
// Classification
priority_sector: [],
common_bond: '',
vulnerability_classifications: [],
// Gov IDs
philsys_id: '',
sss_number: '',
pagibig_number: '',
// SLP
slp_track: '',
slp_association_name: '',
listahanan_id: '',
fourtps_household_id: '',
// TUPAD
tupad_category: '',
tupad_insurance_beneficiary_name: '',
tupad_insurance_beneficiary_relation: '',
// OSEC/NSRP
preferred_occupation: '',
nsrp_skills: [],
employment_status: '',
// Programs
program_participation: [],
});
const fetchCooperative = async () => {
loadingCoop.value = true;
try {
const [coopRes, settingsRes] = await Promise.all([
axios.post('/Cooperatives/Get', { hashkey: props.target }),
axios.get('/api/public/system-settings'),
]);
if (coopRes.data.success) {
cooperative.value = coopRes.data.data;
alreadyMember.value = coopRes.data.is_member;
if (alreadyMember.value && coopRes.data.membership) {
const m = coopRes.data.membership;
Object.keys(form.value).forEach(k => {
if (m[k] !== undefined && m[k] !== null) form.value[k] = m[k];
});
}
}
if (settingsRes.data?.priority_sectors) {
prioritySectors.value = settingsRes.data.priority_sectors;
}
} catch (error) {
console.error('[CoopRegister] Failed to fetch cooperative:', error);
} finally {
loadingCoop.value = false;
}
};
const handleRegister = async () => {
if (!form.value.membership_type) {
modal.open({ title: 'Missing Info', body: 'Please select a membership type.' });
return;
}
isSaving.value = true;
try {
const response = await axios.post('/Cooperatives/Member/Register', {
cooperative_hash: props.target,
...form.value,
});
if (response.data.success) {
modal.open({
title: 'Registration Successful',
body: 'You have been registered as a member of ' + cooperative.value.name,
onClose: () => navigate({ page: 'CooperativeDetail', props: { target: props.target } })
});
} else {
modal.open({
title: 'Registration Failed',
body: response.data.message || 'Something went wrong.'
});
}
} catch (error) {
modal.open({
title: 'Error',
body: error.response?.data?.message || 'Failed to complete registration. Please try again later.'
});
} finally {
isSaving.value = false;
}
};
onMounted(() => {
fetchCooperative();
});
</script>
<style scoped>
.coop-header-card {
background: linear-gradient(135deg, var(--accent-color), #2d3436);
color: white;
}
.header-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url('https://www.transparenttextures.com/patterns/carbon-fibre.png');
opacity: 0.1;
}
.premium-input, .premium-select {
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
transition: all 0.3s ease;
}
.premium-input:focus, .premium-select:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 4px var(--accent-soft);
background-color: var(--bg-primary);
}
.btn-premium-launch {
background: linear-gradient(135deg, var(--accent-color), #4834d4);
color: white;
border: none;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.btn-premium-launch:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(83, 61, 234, 0.3);
}
.btn-premium-launch:active:not(:disabled) {
transform: translateY(1px);
}
.rounded-20 {
border-radius: 20px;
}
.rounded-15 {
border-radius: 15px;
}
:global(.dark-mode) .premium-input,
:global(.dark-mode) .premium-select {
background-color: var(--bg-card);
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark-mode) .premium-input:focus,
:global(.dark-mode) .premium-select:focus {
background-color: var(--bg-tertiary);
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<div class="bottom-navigation-bar">
<div class="tf-container">
<ul class="tf-navigation-bar" :style="{ background: uiStore.darkMode ? 'transparent' : '' }">
<li>
<a class="nav-item-link" href="#" @click.prevent="navigate('Home')" @mouseenter="prefetch('Home')" @touchstart.passive="prefetch('Home')">
<i class="fas fa-home"></i>
<span>Home</span>
</a>
</li>
<li v-if="uiStore.isModuleEnabled('cart')">
<a class="nav-item-link" href="#" @click.prevent="navigate('CartProductMarket')" @mouseenter="prefetch('CartProductMarket')" @touchstart.passive="prefetch('CartProductMarket')">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/d36eb6a17e27.bin" alt="Cart" class="nav-icon">
<span>Cart</span>
</a>
</li>
<li>
<a class="nav-item-link" href="#" @click.prevent="navigate('MyWallet')" @mouseenter="prefetch('MyWallet')" @touchstart.passive="prefetch('MyWallet')">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/9908be28dd8a.bin" alt="Wallet" class="nav-icon spinning-on-hover">
<span>Wallet</span>
</a>
</li>
<li v-if="['tandem','ngo'].includes(uiStore.app_mode)">
<a class="nav-item-link" href="#" @click.prevent="navigate('CooperativeHub')" @mouseenter="prefetch('CooperativeHub')" @touchstart.passive="prefetch('CooperativeHub')">
<i class="fas fa-landmark"></i>
<span>Hub</span>
</a>
</li>
<li v-if="uiStore.isModuleEnabled('properties')">
<a class="nav-item-link" href="#" @click.prevent="navigate('ListProperties')" @mouseenter="prefetch('ListProperties')" @touchstart.passive="prefetch('ListProperties')">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/53c45417d1d1.bin" alt="Properties" class="nav-icon">
<span>Properties</span>
</a>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { useUIStore } from '../../../stores/ui';
const uiStore = useUIStore();
const navigate = (page) => {
if (window.$navigateHelper) {
window.$navigateHelper({ page });
} else {
console.warn('Global $navigate function not found.');
}
};
const prefetch = (page) => {
if (window.$prefetchPage) window.$prefetchPage(page);
};
</script>
<style scoped>
.bottom-navigation-bar {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: var(--layout-max-width, 1440px);
z-index: 9999;
background-color: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-top: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.04);
padding: 10px 0 env(safe-area-inset-bottom, 10px);
transition: all 0.3s ease;
}
:global(body.is-full-width) .bottom-navigation-bar {
max-width: none !important;
}
:global(.dark-mode) .bottom-navigation-bar {
background-color: var(--header-bg);
border-top-color: var(--border-color);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
}
:global(.dark-mode) .tf-navigation-bar {
background-color: transparent !important;
}
.tf-navigation-bar {
display: flex;
justify-content: space-around;
align-items: center;
list-style: none;
padding: 0;
margin: 0;
}
.nav-item-link {
text-decoration: none;
color: #717171;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
transition: all 0.2s ease;
padding: 4px 12px;
border-radius: 12px;
}
.nav-item-link:hover {
color: #533dea;
background-color: rgba(83, 61, 234, 0.05);
}
.nav-item-link i {
font-size: 22px;
}
.nav-icon {
width: 24px;
height: 24px;
object-fit: contain;
}
:global(.dark-mode) .nav-item-link {
color: #e0e0e0;
}
:global(.dark-mode) .nav-item-link:hover {
color: #816ef0;
background-color: rgba(129, 110, 240, 0.1);
}
</style>

View File

@@ -0,0 +1,227 @@
<script setup>
import { onMounted, onUnmounted, h, ref, watch, nextTick } from 'vue'
import { useUIStore } from '../../../stores/ui'
import { useUserStore } from '../../../stores/user'
import { useUserNotes } from '../../../composables/useUserNotes'
import { useModal } from '../../../composables/Core/useModal'
import SystemBroadcast from '../../../Components/SystemBroadcast.vue'
const uiStore = useUIStore()
const userStore = useUserStore()
const { notes, fetchNotes, dismissNotes, hasNotes } = useUserNotes()
const modal = useModal()
const headerRef = ref(null)
let resizeObserver = null
const updateHeaderHeight = () => {
if (headerRef.value) {
const height = headerRef.value.offsetHeight
document.documentElement.style.setProperty('--header-height', `${height}px`)
}
}
const goBack = () => {
window.history.back()
}
const reloadPage = () => {
window.location.reload()
}
const openAccountSettings = () => {
if (window.$navigateHelper) {
window.$navigateHelper({ page: 'AccountSettings' })
} else {
console.warn('Global $navigate function not found.')
}
}
const openNotesModal = () => {
if (!hasNotes()) return
modal.continueCancelModal({
title: 'Notes',
body: h('div', {
style: 'white-space: pre-wrap; font-size: 16px; line-height: 1.5; color: #333;'
}, notes.value),
continueText: 'Dismiss Note',
cancelText: 'Close',
continueClass: 'btn btn-danger w-50 py-2 rounded-3 shadow-sm fw-bold',
cancelClass: 'btn btn-light w-50 py-2 rounded-3 border fw-bold text-muted',
onContinue: async () => {
const success = await dismissNotes()
if (success) {
await fetchNotes()
}
}
})
}
onMounted(() => {
if (userStore.isLoggedIn) {
fetchNotes()
}
updateHeaderHeight()
// Create a ResizeObserver to handle height changes (e.g., SystemBroadcast showing/hiding)
if (window.ResizeObserver && headerRef.value) {
resizeObserver = new ResizeObserver(() => {
updateHeaderHeight()
})
resizeObserver.observe(headerRef.value)
}
// Fallback: update on window resize
window.addEventListener('resize', updateHeaderHeight)
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
window.removeEventListener('resize', updateHeaderHeight)
})
// Update height when notes status changes
watch(() => hasNotes(), () => {
nextTick(() => updateHeaderHeight())
})
// Update height when page title changes (might wrap)
watch(() => uiStore.pageTitle, () => {
nextTick(() => updateHeaderHeight())
})
</script>
<template>
<div class="header is-fixed" id="maintopbarheader" ref="headerRef">
<SystemBroadcast />
<div class="tf-container">
<div class="tf-statusbar d-flex justify-content-center align-items-center">
<a href="javascript:void(0);" class="back-btn" @click="goBack" id="backbutton-top">
<i class="fas fa-chevron-left"></i>
</a>
<h3 id="topbar-title" @click="reloadPage" class="header-title">{{ uiStore.pageTitle }}</h3>
<div class="action-right-group">
<a
v-if="hasNotes()"
href="javascript:void(0);"
class="action-right-btn notes-btn"
@click="openNotesModal"
id="btn-notes-top"
title="You have notes"
>
<i class="fas fa-copy"></i>
<span class="notes-badge"></span>
</a>
<a href="javascript:void(0);" class="action-right-btn" @click="openAccountSettings" id="btn-popup-up">
<i class="fas fa-sliders-h"></i>
</a>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.header {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: var(--layout-max-width, 1440px);
z-index: 9999;
background-color: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
}
:global(body.is-full-width) .header {
max-width: none !important;
}
:global(.dark-mode) .header {
background-color: var(--header-bg);
border-bottom-color: var(--border-color);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.header-title {
font-size: 18px;
font-weight: 700;
color: #1e1e1e;
margin: 0;
cursor: pointer;
}
:global(.dark-mode) .header-title {
color: #e0e0e0;
}
.tf-statusbar {
height: 56px;
position: relative;
}
.back-btn {
position: absolute;
left: 16px;
color: #1e1e1e;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.action-right-group {
position: absolute;
right: 16px;
display: flex;
align-items: center;
gap: 14px;
}
.action-right-btn {
color: #1e1e1e;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.notes-btn {
color: #f2c71c;
}
.notes-badge {
position: absolute;
top: -2px;
right: -4px;
width: 8px;
height: 8px;
background-color: #ea3434;
border-radius: 50%;
border: 1.5px solid #fff;
}
:global(.dark-mode) .back-btn,
:global(.dark-mode) .action-right-btn {
color: #e0e0e0;
}
:global(.dark-mode) .notes-btn {
color: #f2c71c;
}
:global(.dark-mode) .notes-badge {
border-color: rgba(28, 30, 34, 0.85);
}
</style>

View File

@@ -0,0 +1,120 @@
<script setup>
import { usePageTitle } from '../../composables/Core/usePageTitle'
import { useNavigate } from '../../composables/Core/useNavigate'
usePageTitle('Page Not Found')
const { navigate } = useNavigate()
</script>
<template>
<div class="notfound-page min-vh-100 d-flex align-items-center justify-content-center p-4">
<div class="notfound-container glass-card text-center p-5 shadow-lg animate-fade-in">
<div class="illustration-container mb-4">
<div class="notfound-404 shadow-text">404</div>
<div class="notfound-icon">
<i class="fas fa-search-location"></i>
</div>
</div>
<h2 class="fw_8 mb-3 premium-title">Lost in the Fields?</h2>
<p class="text-muted mb-5 px-3">
The page you're looking for doesn't exist or has been relocated to a new plot.
</p>
<button
@click="navigate({ page: 'Home' })"
class="btn btn-primary btn-lg rounded-pill px-5 py-3 fw_7 shadow-primary-lg glow-button"
>
<i class="fas fa-home me-2"></i>
Back to Dashboard
</button>
</div>
</div>
</template>
<style scoped>
.notfound-page {
background: radial-gradient(circle at top left, rgba(59, 130, 246, 0.05) 0%, transparent 25%),
radial-gradient(circle at bottom right, rgba(16, 185, 129, 0.05) 0%, transparent 25%);
}
.notfound-container {
max-width: 500px;
width: 100%;
border-radius: 32px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
:global(.dark-mode) .notfound-container {
background: rgba(31, 41, 55, 0.8);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.illustration-container {
position: relative;
height: 180px;
}
.notfound-404 {
font-size: 10rem;
font-weight: 900;
line-height: 1;
background: linear-gradient(135deg, #3b82f6 0%, #10b981 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
opacity: 0.1;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.notfound-icon {
font-size: 5rem;
color: #3b82f6;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
filter: drop-shadow(0 10px 15px rgba(59, 130, 246, 0.3));
}
.premium-title {
font-family: 'Outfit', sans-serif;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
:global(.dark-mode) .premium-title {
background: linear-gradient(135deg, #f1f5f9 0%, #cbd5e1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.shadow-primary-lg {
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.4);
}
.glow-button {
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.glow-button:hover {
transform: translateY(-5px);
box-shadow: 0 15px 30px rgba(59, 130, 246, 0.5);
filter: brightness(1.1);
}
.animate-fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,166 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useChapters } from '../composables/useChapters.js';
import { useNavigate } from '../composables/Core/useNavigate.js';
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Create Chapter');
const { fetchOfficerScope, createChapter, loading } = useChapters();
const { navigate } = useNavigate();
const CHILD_LEVELS = {
national: ['region'],
region: ['province'],
province: ['city', 'municipal'],
city: ['barangay'],
municipal: ['barangay'],
barangay: [],
};
const ownChapter = ref(null);
const cooperative = ref(null);
const form = ref({ name: '', location_key: '', lat: '', lng: '' });
const userEditedKey = ref(false);
const errorMessage = ref('');
const submitting = ref(false);
const createdChapter = ref(null);
const childLevel = computed(() => {
if (!ownChapter.value) return null;
const levels = CHILD_LEVELS[ownChapter.value.level] || [];
return levels.length ? levels[0] : null;
});
const isBarangay = computed(() => ownChapter.value?.level === 'barangay');
watch(() => form.value.name, (val) => {
if (!userEditedKey.value) {
form.value.location_key = (val || '').toLowerCase().trim();
}
});
const canSubmit = computed(() => form.value.name && form.value.location_key && childLevel.value);
const submit = async () => {
if (submitting.value || !canSubmit.value) return;
errorMessage.value = '';
submitting.value = true;
try {
const res = await createChapter({
name: form.value.name,
locationKey: form.value.location_key,
lat: form.value.lat === '' ? null : Number(form.value.lat),
lng: form.value.lng === '' ? null : Number(form.value.lng),
});
if (res.success) {
createdChapter.value = res.chapter;
} else {
errorMessage.value = res.message || 'Failed to create chapter.';
}
} catch (err) {
errorMessage.value = err.response?.data?.message || err.response?.data?.error || 'An error occurred.';
} finally {
submitting.value = false;
}
};
onMounted(async () => {
const scope = await fetchOfficerScope();
ownChapter.value = scope?.own_chapter ?? null;
cooperative.value = scope?.cooperative ?? null;
});
</script>
<template>
<div class="container py-4" style="max-width: 560px;">
<h5 class="fw-bold mb-3"><i class="fas fa-map-marker-alt me-2"></i>Create Sub-Chapter</h5>
<div v-if="loading && !ownChapter" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
<div v-else-if="!ownChapter" class="text-center py-5 text-muted">
<i class="fas fa-exclamation-triangle fa-2x text-warning mb-2"></i>
<p>You are not assigned to a chapter.</p>
</div>
<div v-else-if="isBarangay" class="text-center py-5 text-muted">
<i class="fas fa-ban fa-2x text-danger mb-2"></i>
<p>Cannot create sub-chapters at barangay level.</p>
</div>
<div v-else-if="createdChapter" class="text-center py-5">
<i class="fas fa-check-circle fa-4x text-success mb-3"></i>
<h5 class="fw-bold">Chapter Created!</h5>
<p class="text-muted">
<strong>{{ createdChapter.name }}</strong>
<span class="badge rounded-pill level-badge ms-2">{{ (createdChapter.level || '').toUpperCase() }}</span>
</p>
<button class="btn btn-primary rounded-pill px-4" @click="navigate({ page: 'Home' })">Done</button>
</div>
<div v-else class="info-card rounded-4 p-4">
<div class="meta rounded-3 p-2 mb-3 small">
<div><i class="fas fa-layer-group me-1"></i> Level: <strong>{{ (childLevel || '').toUpperCase() }}</strong></div>
<div><i class="fas fa-sitemap me-1"></i> Parent: <strong>{{ ownChapter.name }}</strong></div>
<div v-if="cooperative"><i class="fas fa-handshake me-1"></i> Cooperative: <strong>{{ cooperative.name }}</strong></div>
</div>
<div v-if="errorMessage" class="alert alert-danger rounded-3 small py-2">{{ errorMessage }}</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Chapter Name</label>
<input v-model="form.name" type="text" class="form-control rounded-pill" placeholder="e.g. Malaybalay City" />
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Location Key</label>
<input v-model="form.location_key" type="text" class="form-control rounded-pill"
placeholder="auto-generated" @input="userEditedKey = true" />
<div class="form-text small text-muted">Lowercase identifier (auto-filled from name).</div>
</div>
<div class="row g-2 mb-4">
<div class="col-6">
<label class="form-label small fw-semibold">Latitude <span class="text-muted">(optional)</span></label>
<input v-model="form.lat" type="number" step="any" class="form-control rounded-pill" />
</div>
<div class="col-6">
<label class="form-label small fw-semibold">Longitude <span class="text-muted">(optional)</span></label>
<input v-model="form.lng" type="number" step="any" class="form-control rounded-pill" />
</div>
</div>
<button class="btn btn-primary rounded-pill w-100 py-2 fw-semibold" :disabled="submitting || !canSubmit" @click="submit">
<span v-if="submitting" class="spinner-border spinner-border-sm me-2"></span>
<i v-else class="fas fa-plus me-2"></i>
{{ submitting ? 'Creating...' : 'Create Chapter' }}
</button>
</div>
</div>
</template>
<style scoped>
.info-card {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.meta {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.08);
line-height: 1.6;
}
.level-badge {
background: var(--accent-color);
color: #fff;
}
:global(.dark-mode) .info-card,
:global(.dark-mode) .meta {
border-color: rgba(255, 255, 255, 0.08);
}
</style>

View File

@@ -0,0 +1,198 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import { useChapters } from '../composables/useChapters.js';
import { useNavigate } from '../composables/Core/useNavigate.js';
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Create Member');
const { fetchOfficerScope, loading } = useChapters();
const { navigate } = useNavigate();
const ownChapter = ref(null);
const cooperative = ref(null);
const form = ref({
name: '',
username: '',
mobile_number: '',
password: '',
});
const fieldErrors = ref({});
const errorMessage = ref('');
const submitting = ref(false);
const done = ref(false);
const mobileError = ref('');
const mobileTaken = ref(false);
const usernameTaken = ref(false);
const validateMobile = (val) => {
if (!val) { mobileError.value = 'Mobile number is required.'; return false; }
if (!/^(09|\+639)\d{9}$/.test(val)) {
mobileError.value = 'Must be a valid Philippine mobile number (e.g. 09XXXXXXXXX).';
return false;
}
mobileError.value = '';
return true;
};
const checkMobile = async () => {
if (!validateMobile(form.value.mobile_number)) return;
try {
const res = await axios.post('/admin/user/number/exists', { mobile_number: form.value.mobile_number });
mobileTaken.value = !!res.data?.exists;
} catch (e) { /* ignore */ }
};
const checkUsername = async () => {
if (!form.value.username) { usernameTaken.value = false; return; }
try {
const res = await axios.post('/admin/user/username/exists', { username: form.value.username });
usernameTaken.value = !!res.data?.exists;
} catch (e) { /* ignore */ }
};
const canSubmit = computed(() =>
form.value.name &&
form.value.username &&
form.value.mobile_number &&
form.value.password &&
!mobileError.value &&
!mobileTaken.value &&
!usernameTaken.value &&
ownChapter.value?.hashkey
);
const submit = async () => {
if (submitting.value || !canSubmit.value) return;
fieldErrors.value = {};
errorMessage.value = '';
submitting.value = true;
try {
const res = await axios.post('/api/public/chapter/register', {
chapter_hash: ownChapter.value.hashkey,
name: form.value.name,
username: form.value.username,
mobile_number: form.value.mobile_number,
password: form.value.password,
});
if (res.data.success) {
done.value = true;
} else {
errorMessage.value = res.data.message || 'Failed to create member.';
}
} catch (err) {
if (err.response?.data?.errors) fieldErrors.value = err.response.data.errors;
else errorMessage.value = err.response?.data?.message || 'An error occurred.';
} finally {
submitting.value = false;
}
};
const reset = () => {
form.value = { name: '', username: '', mobile_number: '', password: '' };
fieldErrors.value = {};
errorMessage.value = '';
mobileTaken.value = false;
usernameTaken.value = false;
done.value = false;
};
onMounted(async () => {
const scope = await fetchOfficerScope();
ownChapter.value = scope?.own_chapter ?? null;
cooperative.value = scope?.cooperative ?? null;
});
</script>
<template>
<div class="container py-4" style="max-width: 560px;">
<h5 class="fw-bold mb-3"><i class="fas fa-user-plus me-2"></i>Create Member</h5>
<div v-if="loading && !ownChapter" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
<div v-else-if="!ownChapter" class="text-center py-5 text-muted">
<i class="fas fa-exclamation-triangle fa-2x text-warning mb-2"></i>
<p>You are not assigned to a chapter, so you cannot create members.</p>
</div>
<div v-else-if="done" class="text-center py-5">
<i class="fas fa-check-circle fa-4x text-success mb-3"></i>
<h5 class="fw-bold">Member Created!</h5>
<p class="text-muted">The new member was added to <strong>{{ ownChapter.name }}</strong>.</p>
<button class="btn btn-outline-primary rounded-pill px-4 me-2" @click="reset">Add Another</button>
<button class="btn btn-primary rounded-pill px-4" @click="navigate({ page: 'Home' })">Done</button>
</div>
<div v-else class="info-card rounded-4 p-4">
<div class="assign-note rounded-3 p-2 mb-3 small">
<i class="fas fa-map-marker-alt me-1"></i>
Will be added to: <strong>{{ ownChapter.name }}</strong>
<span v-if="cooperative"> · {{ cooperative.name }}</span>
</div>
<div v-if="errorMessage" class="alert alert-danger rounded-3 small py-2">{{ errorMessage }}</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Full Name</label>
<input v-model="form.name" type="text" class="form-control rounded-pill"
:class="{ 'is-invalid': fieldErrors.name }" placeholder="Juan Dela Cruz" />
<div v-if="fieldErrors.name" class="invalid-feedback">{{ fieldErrors.name[0] }}</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Username</label>
<input v-model="form.username" type="text" class="form-control rounded-pill"
:class="{ 'is-invalid': usernameTaken || fieldErrors.username }"
placeholder="juandelacruz" autocomplete="off" @blur="checkUsername" />
<div v-if="usernameTaken" class="invalid-feedback d-block">Username already taken.</div>
<div v-else-if="fieldErrors.username" class="invalid-feedback d-block">{{ fieldErrors.username[0] }}</div>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Mobile Number</label>
<input v-model="form.mobile_number" type="tel" class="form-control rounded-pill"
:class="{ 'is-invalid': mobileError || mobileTaken || fieldErrors.mobile_number }"
placeholder="09XXXXXXXXX" @blur="checkMobile" />
<div v-if="mobileError" class="invalid-feedback d-block">{{ mobileError }}</div>
<div v-else-if="mobileTaken" class="invalid-feedback d-block">Mobile number already taken.</div>
<div v-else-if="fieldErrors.mobile_number" class="invalid-feedback d-block">{{ fieldErrors.mobile_number[0] }}</div>
</div>
<div class="mb-4">
<label class="form-label small fw-semibold">Password</label>
<input v-model="form.password" type="password" class="form-control rounded-pill"
:class="{ 'is-invalid': fieldErrors.password }" placeholder="Min. 6 characters" autocomplete="new-password" />
<div v-if="fieldErrors.password" class="invalid-feedback">{{ fieldErrors.password[0] }}</div>
</div>
<button class="btn btn-primary rounded-pill w-100 py-2 fw-semibold" :disabled="submitting || !canSubmit" @click="submit">
<span v-if="submitting" class="spinner-border spinner-border-sm me-2"></span>
<i v-else class="fas fa-user-plus me-2"></i>
{{ submitting ? 'Creating...' : 'Create Member' }}
</button>
</div>
</div>
</template>
<style scoped>
.info-card {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.assign-note {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark-mode) .info-card,
:global(.dark-mode) .assign-note {
border-color: rgba(255, 255, 255, 0.08);
}
</style>

View File

@@ -0,0 +1,364 @@
<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Create Cooperative');
import { ref, computed } from 'vue';
import axios from 'axios';
import { useNavigate } from '../composables/Core/useNavigate.js';
import CardSimple from '../Components/Core/CardSimple.vue';
const { navigate } = useNavigate();
const name = ref('');
const address = ref('');
const registrationNumber = ref('');
const cin = ref('');
const tin = ref('');
const cooperativeType = ref('');
const cooperativeCategory = ref('');
const registrationDate = ref('');
const contactPerson = ref('');
const contactNumber = ref('');
const contactEmail = ref('');
const loading = ref(false);
const error = ref(null);
const successMessage = ref('');
const isButtonDisabled = computed(() => {
return !!(loading.value || successMessage.value || !name.value.trim());
});
const handleSubmit = async () => {
error.value = null;
successMessage.value = '';
if (!name.value.trim()) {
error.value = 'Cooperative name is required';
return;
}
loading.value = true;
try {
const response = await axios.post('/Cooperatives/Create', {
name: name.value.trim(),
address: address.value.trim(),
registration_number: registrationNumber.value.trim(),
cin: cin.value.trim(),
tin: tin.value.trim(),
cooperative_type: cooperativeType.value,
cooperative_category: cooperativeCategory.value,
registration_date: registrationDate.value,
contact_person: contactPerson.value.trim(),
contact_number: contactNumber.value.trim(),
contact_email: contactEmail.value.trim(),
});
if (response.data && response.data.success) {
successMessage.value = 'Cooperative created successfully!';
setTimeout(() => {
navigate({ page: 'CooperativeList' });
}, 1200);
} else {
error.value = response.data?.message || 'Failed to create cooperative';
}
} catch (err) {
console.error('Failed to create cooperative:', err);
error.value = err.response?.data?.message || 'Failed to create cooperative. Please try again.';
} finally {
loading.value = false;
}
};
</script>
<template>
<div class="create-cooperative-page pb-5">
<div class="tf-container mt-5 mb-4 text-center">
<h1 class="fw_8 premium-title">Register Cooperative</h1>
<p class="text-muted">Create a new cooperative organization for farmers and members</p>
</div>
<div v-if="successMessage" class="tf-container mb-4">
<div class="glass-alert alert-success animate-fade-in">
<i class="fas fa-check-circle me-2"></i>
{{ successMessage }}
</div>
</div>
<div v-if="error" class="tf-container mb-4">
<div class="glass-alert alert-danger animate-shake">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ error }}
</div>
</div>
<div class="tf-container">
<CardSimple title="Cooperative Details">
<div class="premium-input-group mb-4">
<label for="coopName" class="form-label">Cooperative Name <span class="required">*</span></label>
<input
type="text"
id="coopName"
v-model="name"
class="premium-input"
placeholder="e.g., Bukidnon Farmers Cooperative"
autocomplete="off"
>
</div>
<div class="premium-input-group mb-4">
<label for="coopAddress" class="form-label">Address</label>
<textarea
id="coopAddress"
v-model="address"
class="premium-input"
rows="2"
placeholder="Complete physical address of the cooperative"
></textarea>
</div>
</CardSimple>
<CardSimple title="Registration Information" class="mt-4">
<div class="row">
<div class="col-md-4 mb-4">
<div class="premium-input-group">
<label for="regNum" class="form-label">Registration Number</label>
<input type="text" id="regNum" v-model="registrationNumber" class="premium-input" placeholder="e.g. REG-12345">
</div>
</div>
<div class="col-md-4 mb-4">
<div class="premium-input-group">
<label for="cin" class="form-label">CIN (Coop ID Number)</label>
<input type="text" id="cin" v-model="cin" class="premium-input" placeholder="e.g. CIN-67890">
</div>
</div>
<div class="col-md-4 mb-4">
<div class="premium-input-group">
<label for="tin" class="form-label">TIN</label>
<input type="text" id="tin" v-model="tin" class="premium-input" placeholder="000-000-000-000">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="premium-input-group">
<label for="coopType" class="form-label">Cooperative Type</label>
<select id="coopType" v-model="cooperativeType" class="premium-input">
<option value="">Select Type</option>
<option value="AGRICULTURAL">Agricultural</option>
<option value="CREDIT">Credit</option>
<option value="CONSUMERS">Consumers</option>
<option value="MARKETING">Marketing</option>
<option value="SERVICE">Service</option>
<option value="MULTIPURPOSE">Multipurpose</option>
</select>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="premium-input-group">
<label for="coopCat" class="form-label">Cooperative Category</label>
<select id="coopCat" v-model="cooperativeCategory" class="premium-input">
<option value="">Select Category</option>
<option value="MICRO">Micro</option>
<option value="SMALL">Small</option>
<option value="MEDIUM">Medium</option>
<option value="LARGE">Large</option>
</select>
</div>
</div>
</div>
</CardSimple>
<CardSimple title="Contact Information" class="mt-4">
<div class="row">
<div class="col-md-6 mb-4">
<div class="premium-input-group">
<label for="regDate" class="form-label">Registration Date</label>
<input type="date" id="regDate" v-model="registrationDate" class="premium-input">
</div>
</div>
<div class="col-md-6 mb-4">
<div class="premium-input-group">
<label for="contactPerson" class="form-label">Contact Person</label>
<input type="text" id="contactPerson" v-model="contactPerson" class="premium-input" placeholder="Full name">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="premium-input-group">
<label for="contactNum" class="form-label">Contact Number</label>
<input type="text" id="contactNum" v-model="contactNumber" class="premium-input" placeholder="e.g. 09123456789">
</div>
</div>
<div class="col-md-6 mb-4">
<div class="premium-input-group">
<label for="contactEmail" class="form-label">Contact Email</label>
<input type="email" id="contactEmail" v-model="contactEmail" class="premium-input" placeholder="email@example.com">
</div>
</div>
</div>
</CardSimple>
<div class="action-bar mt-5 text-center">
<button
@click="handleSubmit"
:disabled="isButtonDisabled"
class="btn-premium-launch"
>
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
<i v-else class="fas fa-plus-circle me-2"></i>
{{ loading ? 'Creating...' : 'Create Cooperative' }}
</button>
<div class="mt-4">
<button
@click="navigate({ page: 'CooperativeList' })"
class="btn-text"
>
<i class="fas fa-chevron-left me-2"></i> Cancel and Return
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.premium-title {
font-family: 'Outfit', sans-serif;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
}
.premium-input-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 600;
font-size: 0.9rem;
color: #475569;
margin-bottom: 8px;
display: flex;
align-items: center;
}
.required {
color: #ef4444;
margin-left: 4px;
}
.premium-input {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #fff;
font-size: 0.95rem;
transition: all 0.2s;
outline: none;
}
.premium-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
.glass-alert {
padding: 16px 20px;
border-radius: 16px;
backdrop-filter: blur(8px);
font-weight: 500;
display: flex;
align-items: center;
}
.alert-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #15803d;
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #b91c1c;
}
.btn-premium-launch {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
border: none;
padding: 16px 48px;
border-radius: 14px;
font-weight: 700;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.3);
}
.btn-premium-launch:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(37, 99, 235, 0.4);
filter: brightness(1.1);
}
.btn-premium-launch:disabled {
background: #cbd5e1;
cursor: not-allowed;
box-shadow: none;
}
.btn-text {
background: transparent;
border: none;
color: #64748b;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
}
.btn-text:hover {
color: #1e293b;
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
:global(.dark-mode) .premium-input {
background: #1e293b;
border-color: #334155;
color: #f8fafc;
}
:global(.dark-mode) .premium-title {
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
-webkit-background-clip: text;
background-clip: text;
}
:global(.dark-mode) .form-label {
color: #94a3b8;
}
</style>

View File

@@ -0,0 +1,289 @@
<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Add Organization');
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import { useNavigate } from '../composables/Core/useNavigate.js';
import CardSimple from '../Components/Core/CardSimple.vue';
import { useUIStore } from '../stores/ui';
const { navigate } = useNavigate();
const uiStore = useUIStore();
const name = ref('');
const type = ref(uiStore.defaultOrgType || 'COOPERATIVE');
const address = ref('');
const orgTypeOptions = computed(() => {
const types = uiStore.groupTypes.length ? uiStore.groupTypes : ['COOPERATIVE', 'NGO', 'CORPORATION'];
return types.map(t => ({
value: t,
label: t.charAt(0) + t.slice(1).toLowerCase().replace(/_/g, ' '),
}));
});
onMounted(() => {
type.value = uiStore.defaultOrgType || 'COOPERATIVE';
});
const loading = ref(false);
const error = ref(null);
const successMessage = ref('');
const isButtonDisabled = computed(() => {
return !!(loading.value || successMessage.value || !name.value.trim() || !type.value);
});
const handleSubmit = async () => {
error.value = null;
successMessage.value = '';
if (!name.value.trim()) {
error.value = 'Organization name is required';
return;
}
loading.value = true;
try {
const response = await axios.post('/Organizations/Create', {
name: name.value.trim(),
type: type.value,
address: address.value.trim(),
});
if (response.data && response.data.success) {
successMessage.value = 'Organization created successfully!';
setTimeout(() => {
navigate({ page: 'Home' });
}, 1200);
} else {
error.value = response.data?.message || 'Failed to create organization';
}
} catch (err) {
console.error('Failed to create organization:', err);
error.value = err.response?.data?.message || 'Failed to create organization. Please try again.';
} finally {
loading.value = false;
}
};
</script>
<template>
<div class="create-organization-page pb-5">
<div class="tf-container mt-5 mb-4 text-center">
<h1 class="fw_8 premium-title">Add Organization</h1>
<p class="text-muted">Register a new organization (cooperative, association, or company)</p>
</div>
<div v-if="successMessage" class="tf-container mb-4">
<div class="glass-alert alert-success animate-fade-in">
<i class="fas fa-check-circle me-2"></i>
{{ successMessage }}
</div>
</div>
<div v-if="error" class="tf-container mb-4">
<div class="glass-alert alert-danger animate-shake">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ error }}
</div>
</div>
<div class="tf-container">
<CardSimple title="Organization Details">
<div class="premium-input-group mb-4">
<label for="orgName" class="form-label">Organization Name <span class="required">*</span></label>
<input
type="text"
id="orgName"
v-model="name"
class="premium-input"
placeholder="e.g., Bukidnon Farmers Association"
autocomplete="off"
>
</div>
<div class="premium-input-group mb-4">
<label for="orgType" class="form-label">Type <span class="required">*</span></label>
<select id="orgType" v-model="type" class="premium-input">
<option v-for="opt in orgTypeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div class="premium-input-group mb-4">
<label for="orgAddress" class="form-label">Address</label>
<textarea
id="orgAddress"
v-model="address"
class="premium-input"
rows="2"
placeholder="Complete physical address of the organization"
></textarea>
</div>
</CardSimple>
<div class="action-bar mt-5 text-center">
<button
@click="handleSubmit"
:disabled="isButtonDisabled"
class="btn-premium-launch"
>
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
<i v-else class="fas fa-plus-circle me-2"></i>
{{ loading ? 'Creating...' : 'Create Organization' }}
</button>
<div class="mt-4">
<button
@click="navigate({ page: 'Home' })"
class="btn-text"
>
<i class="fas fa-chevron-left me-2"></i> Cancel and Return
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.premium-title {
font-family: 'Outfit', sans-serif;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
}
.premium-input-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 600;
font-size: 0.9rem;
color: #475569;
margin-bottom: 8px;
display: flex;
align-items: center;
}
.required {
color: #ef4444;
margin-left: 4px;
}
.premium-input {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #fff;
font-size: 0.95rem;
transition: all 0.2s;
outline: none;
}
.premium-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
.glass-alert {
padding: 16px 20px;
border-radius: 16px;
backdrop-filter: blur(8px);
font-weight: 500;
display: flex;
align-items: center;
}
.alert-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #15803d;
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #b91c1c;
}
.btn-premium-launch {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
border: none;
padding: 16px 48px;
border-radius: 14px;
font-weight: 700;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.3);
}
.btn-premium-launch:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(37, 99, 235, 0.4);
filter: brightness(1.1);
}
.btn-premium-launch:disabled {
background: #cbd5e1;
cursor: not-allowed;
box-shadow: none;
}
.btn-text {
background: transparent;
border: none;
color: #64748b;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
}
.btn-text:hover {
color: #1e293b;
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
:global(.dark-mode) .premium-input {
background: #1e293b;
border-color: #334155;
color: #f8fafc;
}
:global(.dark-mode) .premium-title {
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
-webkit-background-clip: text;
background-clip: text;
}
:global(.dark-mode) .form-label {
color: #94a3b8;
}
</style>

View File

@@ -0,0 +1,880 @@
<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Create Product');
import { ref, computed, onMounted, watch } from 'vue';
import axios from 'axios';
import { useNavigate } from '../composables/Core/useNavigate';
import { useModal } from '../composables/Core/useModal';
import { useFileUpload } from '../composables/useFileUpload.js';
import { useProductStore } from '../stores/product';
import CardSimple from '../Components/Core/CardSimple.vue';
import Dropzone from '../Components/Core/Dropzone.vue';
import FileImage from '../Components/Core/FileImage.vue';
import StockPhotoPicker from '../Components/Core/StockPhotoPicker.vue';
import LoadingSpinner from '../Components/LoadingSpinner.vue';
const { navigate } = useNavigate();
const modal = useModal();
const productStore = useProductStore();
const { uploadFile, removeHash, photoHashes, isUploading: isFileUploading, uploadError } = useFileUpload({
category: 'ProductMarket',
maxSizeMB: 10,
});
const STEP = {
PICK: 1,
NEW_GLOBAL: 2,
DESCRIPTION: 3,
ASSIGN_STORES: 4,
PER_STORE: 5,
};
const step = ref(STEP.PICK);
const isLoading = ref(false);
const isSubmitting = ref(false);
const error = ref(null);
// Stores
const selectableStores = ref([]);
const noStoresChecked = ref(false);
// Flow mode
const mode = ref('existing'); // 'existing' | 'new'
// Selected existing global product
const pickedProduct = ref(null);
// Search
const searchTerm = ref('');
const searchResults = ref([]);
const isSearching = ref(false);
let searchDebounce = null;
// New global product form
const newProduct = ref({
name: '',
description: '',
category: '',
subcategory: '',
price: 1,
unitname: '',
available: 1,
barcode: '',
});
const categoryList = ref([]);
const subcategoryList = ref([]);
const dropzoneRef = ref(null);
const dropzoneFiles = ref([]);
// Stock photo picker
const showPhotoPicker = ref(false);
const onStockPhotoSelected = ({ hashkey, url }) => {
// Mirror Dropzone's entry shape: preview drives the thumbnail, hashkey is
// what the submit handler filters on for the photourl payload.
dropzoneFiles.value.push({ file: null, name: 'stock-photo.jpg', preview: url, hashkey, uploading: false, progress: 100, error: null });
};
// Description override (existing path)
const overrideDescription = ref('');
// Store selection + per-store overrides
const assignedStoreHashes = ref([]); // array of store hashkeys
const perStoreOverrides = ref({}); // { [storeHash]: { price, available } }
// ---------------- Bootstrap ----------------
onMounted(async () => {
isLoading.value = true;
await fetchSelectableStores();
isLoading.value = false;
noStoresChecked.value = true;
if (selectableStores.value.length === 0) {
modal.yesNoModal({
title: 'No store found',
body: 'You need to create a store before you can add a product. Create one now?',
yesText: 'Create Store',
onYes: () => navigate({ page: 'CreateStore' }),
noText: 'Cancel',
onNo: () => navigate({ page: 'Home' }),
});
} else {
loadCategories();
}
});
const fetchSelectableStores = async () => {
try {
const { data } = await axios.post('/Admin/Stores/Selectable');
if (data && data.success) selectableStores.value = data.data || [];
} catch (e) {
console.error('Failed to load stores', e);
}
};
const loadCategories = async () => {
try {
const { data } = await axios.post('/Products/New/Category/Datalist', {});
if (Array.isArray(data)) {
categoryList.value = data.map((item) => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : item[1] || item[0],
}));
}
} catch (e) {
console.error('Failed to load categories', e);
}
};
const loadSubcategories = async () => {
if (!newProduct.value.category) {
subcategoryList.value = [];
return;
}
try {
const { data } = await axios.post('/Products/New/SubCategory/Datalist', {
category: newProduct.value.category,
});
if (Array.isArray(data)) {
subcategoryList.value = data.map((item) => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : item[1] || item[0],
}));
}
} catch (e) {
console.error('Failed to load subcategories', e);
}
};
watch(() => newProduct.value.category, loadSubcategories);
// ---------------- Search ----------------
watch(searchTerm, (val) => {
clearTimeout(searchDebounce);
if (!val || val.trim().length < 2) {
searchResults.value = [];
return;
}
searchDebounce = setTimeout(runSearch, 300);
});
const runSearch = async () => {
if (!searchTerm.value || searchTerm.value.trim().length < 2) return;
isSearching.value = true;
try {
const { data } = await axios.post('/Products/Admin/FuzzySearch', {
name: searchTerm.value.trim(),
});
searchResults.value = data && data.success && Array.isArray(data.data) ? data.data : [];
} catch (e) {
console.error('Search failed', e);
searchResults.value = [];
} finally {
isSearching.value = false;
}
};
// ---------------- Dropzone ----------------
watch(
() => dropzoneFiles.value,
async (newFiles) => {
const filesToUpload = newFiles.filter((f) => !f.uploading && !f.hashkey && !f.error);
for (const fileObj of filesToUpload) {
const idx = newFiles.indexOf(fileObj);
if (idx === -1) continue;
dropzoneRef.value.setFileStatus(idx, { uploading: true, progress: 30 });
const result = await uploadFile(fileObj.file);
if (result && result.hashkey) {
dropzoneRef.value.setFileStatus(idx, { uploading: false, progress: 100, hashkey: result.hashkey });
if (error.value && error.value.startsWith('Photo upload failed:')) {
error.value = null;
}
} else {
const msg = uploadError.value || 'Upload failed';
dropzoneRef.value.setFileStatus(idx, { uploading: false, progress: 0, error: msg });
error.value = `Photo upload failed: ${msg}`;
}
}
},
{ deep: true }
);
const handlePhotoRemoved = (hashkey) => {
if (hashkey) removeHash(hashkey);
};
// ---------------- Step transitions ----------------
const selectExistingProduct = (product) => {
pickedProduct.value = product;
mode.value = 'existing';
overrideDescription.value = product.description || '';
step.value = STEP.DESCRIPTION;
error.value = null;
};
const startNewProduct = () => {
mode.value = 'new';
pickedProduct.value = null;
step.value = STEP.NEW_GLOBAL;
error.value = null;
};
const validateNewProduct = () => {
const p = newProduct.value;
if (!p.name) return 'Product name is required';
if (!p.description) return 'Description is required';
if (!p.category) return 'Category is required';
if (!p.subcategory) return 'Subcategory is required';
if (!p.price || parseFloat(p.price) <= 0) return 'Valid price is required';
if (!p.unitname) return 'Unit name is required';
const hasPhoto = dropzoneFiles.value.some((f) => !!f.hashkey);
if (!hasPhoto) return 'At least one photo is required';
if (p.barcode && !/^\d{12}$/.test(p.barcode)) return 'Barcode must be exactly 12 digits';
return null;
};
const advanceFromNewGlobal = () => {
const err = validateNewProduct();
if (err) {
error.value = err;
return;
}
error.value = null;
step.value = STEP.ASSIGN_STORES;
};
const advanceFromDescription = () => {
if (!overrideDescription.value || !overrideDescription.value.trim()) {
error.value = 'Description is required';
return;
}
error.value = null;
step.value = STEP.ASSIGN_STORES;
};
const toggleStore = (hash) => {
const i = assignedStoreHashes.value.indexOf(hash);
if (i >= 0) {
assignedStoreHashes.value.splice(i, 1);
delete perStoreOverrides.value[hash];
} else {
assignedStoreHashes.value.push(hash);
}
};
const advanceFromStores = () => {
if (assignedStoreHashes.value.length === 0) {
error.value = 'Select at least one store.';
return;
}
// Seed per-store defaults from the global product.
const defaultPrice = mode.value === 'new'
? parseFloat(newProduct.value.price) || 0
: parseFloat(pickedProduct.value?.price) || 0;
const defaultAvailable = mode.value === 'new'
? parseInt(newProduct.value.available) || 1
: 1;
for (const hash of assignedStoreHashes.value) {
if (!perStoreOverrides.value[hash]) {
perStoreOverrides.value[hash] = {
price: defaultPrice,
available: defaultAvailable,
};
}
}
error.value = null;
step.value = STEP.PER_STORE;
};
const goBack = () => {
error.value = null;
if (step.value === STEP.PER_STORE) {
step.value = STEP.ASSIGN_STORES;
} else if (step.value === STEP.ASSIGN_STORES) {
step.value = mode.value === 'new' ? STEP.NEW_GLOBAL : STEP.DESCRIPTION;
} else if (step.value === STEP.DESCRIPTION || step.value === STEP.NEW_GLOBAL) {
step.value = STEP.PICK;
}
};
// ---------------- Submit ----------------
const submit = async () => {
if (isSubmitting.value) return;
for (const hash of assignedStoreHashes.value) {
const ov = perStoreOverrides.value[hash];
if (!ov || !ov.price || parseFloat(ov.price) <= 0) {
error.value = 'Each assigned store needs a valid price.';
return;
}
if (ov.available === '' || ov.available === null || parseInt(ov.available) < 0) {
error.value = 'Each assigned store needs a valid availability.';
return;
}
}
isSubmitting.value = true;
error.value = null;
try {
let productHash = pickedProduct.value?.hashkey;
let storesToAssign = [...assignedStoreHashes.value];
if (mode.value === 'new') {
const firstStore = storesToAssign[0];
const photoHashList = dropzoneFiles.value.filter((f) => f.hashkey).map((f) => f.hashkey);
const { data } = await axios.post('/Products/Admin/New/', {
NewProductName: newProduct.value.name,
NewProductDescription: newProduct.value.description,
NewProductCategory: newProduct.value.category,
NewProductSubCategory: newProduct.value.subcategory,
NewProductPrice: parseFloat(newProduct.value.price),
NewProductUnitName: newProduct.value.unitname,
NewProductAvailable: parseInt(newProduct.value.available),
NewProductBarcode: newProduct.value.barcode,
TargetStore: firstStore,
photourl: photoHashList,
});
if (!data || !data.success) {
error.value = data?.message || 'Failed to create global product.';
isSubmitting.value = false;
return;
}
productHash = data.data?.hashkey || data.hashkey;
}
const descriptionOverride =
mode.value === 'new' ? newProduct.value.description : overrideDescription.value;
const failures = [];
for (const hash of storesToAssign) {
const ov = perStoreOverrides.value[hash];
try {
await axios.post('/Products/AssignToStore/', {
target: productHash,
TargetStore: hash,
price: parseFloat(ov.price),
available: parseInt(ov.available),
description: descriptionOverride,
});
} catch (e) {
const storeName = selectableStores.value.find((s) => s.hashkey === hash)?.name || hash;
failures.push(storeName);
}
}
if (failures.length === storesToAssign.length) {
error.value = 'Failed to assign product to any selected store.';
isSubmitting.value = false;
return;
}
productStore.fetchProducts();
modal.quickDismiss({
title: 'Product Listed',
body:
failures.length > 0
? `Listed in ${storesToAssign.length - failures.length} store(s). Failed for: ${failures.join(', ')}.`
: `Your product is now listed in ${storesToAssign.length} store(s).`,
onShown: () => {
setTimeout(() => navigate({ page: 'ManageProductsAdmin' }), 1200);
},
});
} catch (e) {
console.error('Submit failed', e);
error.value = e.response?.data?.message || 'Failed to create product.';
} finally {
isSubmitting.value = false;
}
};
// ---------------- Helpers ----------------
const stepTitle = computed(() => {
switch (step.value) {
case STEP.PICK:
return 'Find your product';
case STEP.NEW_GLOBAL:
return 'Create new product';
case STEP.DESCRIPTION:
return 'Describe your product';
case STEP.ASSIGN_STORES:
return 'Assign to stores';
case STEP.PER_STORE:
return 'Price & availability per store';
default:
return '';
}
});
const stepNumber = computed(() => {
if (step.value === STEP.PICK) return 1;
if (step.value === STEP.NEW_GLOBAL || step.value === STEP.DESCRIPTION) return 2;
if (step.value === STEP.ASSIGN_STORES) return 3;
if (step.value === STEP.PER_STORE) return 4;
return 1;
});
const storeName = (hash) =>
selectableStores.value.find((s) => s.hashkey === hash)?.name || hash;
const globalDefaultPrice = computed(() =>
mode.value === 'new'
? parseFloat(newProduct.value.price) || 0
: parseFloat(pickedProduct.value?.price) || 0
);
</script>
<template>
<div class="csop-page">
<div class="tf-container mt-4 mb-3 text-center">
<h1 class="fw_8 page-title">Add a Product to Your Store</h1>
<p class="text-muted small mb-0">Step {{ stepNumber }} of 4 {{ stepTitle }}</p>
</div>
<div v-if="error" class="tf-container mb-3">
<div class="glass-alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ error }}
</div>
</div>
<div v-if="isLoading" class="tf-container text-center py-5">
<LoadingSpinner />
</div>
<div v-else-if="noStoresChecked && selectableStores.length === 0" class="tf-container text-center py-5">
<p class="text-muted">You need a store before adding products.</p>
<button class="btn btn-primary rounded-pill px-4 mt-2" @click="navigate({ page: 'CreateStore' })">
Create a Store
</button>
</div>
<div v-else class="tf-container">
<!-- STEP 1: Pick existing or new -->
<div v-if="step === STEP.PICK">
<CardSimple title="Search for your product" cardStyle="height: auto">
<p class="text-muted small">
Many products are already in our system. Type a product name to see if yours exists.
</p>
<div class="premium-input-group mb-3">
<input
type="text"
v-model="searchTerm"
class="premium-input"
placeholder="e.g., Premium Rice"
autofocus
/>
</div>
<div v-if="isSearching" class="text-center py-3">
<LoadingSpinner size="small" />
</div>
<div v-else-if="searchResults.length > 0" class="results-list">
<div
v-for="m in searchResults"
:key="m.hashkey"
class="result-row"
@click="selectExistingProduct(m)"
>
<FileImage
:src="m.photourl && m.photourl[0] ? m.photourl[0] : ''"
:alt="m.name"
class="result-thumb"
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin"
/>
<div class="result-info">
<div class="fw_6">{{ m.name }}</div>
<div class="text-muted smallest">
<span v-if="m.category">{{ m.category }}<span v-if="m.subcategory"> · {{ m.subcategory }}</span> · </span>
<span>{{ m.price }} / {{ m.unitname }}</span>
</div>
</div>
<i class="fas fa-chevron-right text-muted"></i>
</div>
</div>
<div
v-else-if="searchTerm && searchTerm.length >= 2"
class="text-muted smallest text-center py-3"
>
No matches yet. You can create a new product below.
</div>
</CardSimple>
<div class="text-center mt-4">
<button class="btn btn-outline-primary rounded-pill px-4" @click="startNewProduct">
<i class="fas fa-plus me-2"></i> My product is not listed Create new
</button>
</div>
<div class="text-center mt-3">
<button class="btn-text" @click="navigate({ page: 'Home' })">
<i class="fas fa-chevron-left me-2"></i> Cancel
</button>
</div>
</div>
<!-- STEP 2 (NEW): Full new global product form -->
<div v-else-if="step === STEP.NEW_GLOBAL">
<CardSimple title="New product details">
<div class="premium-input-group mb-3">
<label class="form-label">Product Name <span class="required">*</span></label>
<input type="text" v-model="newProduct.name" class="premium-input" placeholder="e.g., Premium Rice" />
</div>
<div class="premium-input-group mb-3">
<label class="form-label">Description <span class="required">*</span></label>
<textarea v-model="newProduct.description" class="premium-input" rows="3" placeholder="Describe your product..."></textarea>
</div>
<div class="row">
<div class="col-md-6">
<div class="premium-input-group mb-3">
<label class="form-label">Category <span class="required">*</span></label>
<select v-model="newProduct.category" class="premium-select">
<option value="" disabled>Select Category</option>
<option v-for="cat in categoryList" :key="cat.value" :value="cat.value">{{ cat.label }}</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="premium-input-group mb-3">
<label class="form-label">Subcategory <span class="required">*</span></label>
<select v-model="newProduct.subcategory" class="premium-select" :disabled="subcategoryList.length === 0">
<option value="" disabled>Select Subcategory</option>
<option v-for="sub in subcategoryList" :key="sub.value" :value="sub.value">{{ sub.label }}</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="premium-input-group mb-3">
<label class="form-label">Base Price (PHP) <span class="required">*</span></label>
<input type="number" v-model="newProduct.price" class="premium-input" min="1" step="0.01" />
</div>
</div>
<div class="col-md-4">
<div class="premium-input-group mb-3">
<label class="form-label">Unit <span class="required">*</span></label>
<input type="text" v-model="newProduct.unitname" class="premium-input" placeholder="e.g., 25kg" />
</div>
</div>
<div class="col-md-4">
<div class="premium-input-group mb-3">
<label class="form-label">Default Available <span class="required">*</span></label>
<input type="number" v-model="newProduct.available" class="premium-input" min="1" />
</div>
</div>
</div>
<div class="premium-input-group mb-3">
<label class="form-label">Barcode (12 digits)</label>
<input type="text" v-model="newProduct.barcode" class="premium-input" maxlength="12" placeholder="Optional" />
</div>
<div class="premium-input-group">
<label class="form-label">Product Photos <span class="required">*</span></label>
<button type="button" class="btn btn-outline-secondary btn-sm rounded-pill mb-2"
@click="showPhotoPicker = true">
<i class="fas fa-images me-1"></i> Search Stock Photos
</button>
<Dropzone ref="dropzoneRef" v-model:files="dropzoneFiles" @removed="handlePhotoRemoved" />
<StockPhotoPicker v-model="showPhotoPicker" :product-name="newProduct.name"
@photo-selected="onStockPhotoSelected" />
</div>
</CardSimple>
<div class="nav-bar mt-4">
<button class="btn-text" @click="goBack">
<i class="fas fa-chevron-left me-2"></i> Back
</button>
<button
class="btn btn-primary rounded-pill px-4"
:disabled="isFileUploading"
@click="advanceFromNewGlobal"
>
Next <i class="fas fa-chevron-right ms-2"></i>
</button>
</div>
</div>
<!-- STEP 2 (EXISTING): Description override -->
<div v-else-if="step === STEP.DESCRIPTION">
<CardSimple :title="pickedProduct?.name || 'Selected product'">
<div class="picked-preview mb-3">
<FileImage
:src="pickedProduct?.photourl && pickedProduct.photourl[0] ? pickedProduct.photourl[0] : ''"
:alt="pickedProduct?.name"
class="picked-thumb"
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin"
/>
<div class="text-muted small">
<span v-if="pickedProduct?.category">{{ pickedProduct.category }}<span v-if="pickedProduct.subcategory"> · {{ pickedProduct.subcategory }}</span> · </span>
<span>{{ pickedProduct?.price }} / {{ pickedProduct?.unitname }}</span>
</div>
</div>
<div class="premium-input-group">
<label class="form-label">Description for your listing <span class="required">*</span></label>
<textarea
v-model="overrideDescription"
class="premium-input"
rows="5"
placeholder="Describe how this product appears in your store..."
></textarea>
<p class="smallest text-muted mt-2">
<i class="fas fa-info-circle me-1"></i>
This description will be shown for this product across the stores you assign it to.
</p>
</div>
</CardSimple>
<div class="nav-bar mt-4">
<button class="btn-text" @click="goBack">
<i class="fas fa-chevron-left me-2"></i> Back
</button>
<button class="btn btn-primary rounded-pill px-4" @click="advanceFromDescription">
Next <i class="fas fa-chevron-right ms-2"></i>
</button>
</div>
</div>
<!-- STEP 3: Assign to stores -->
<div v-else-if="step === STEP.ASSIGN_STORES">
<CardSimple title="Which of your stores should sell this?">
<p class="text-muted small">Pick one or more stores.</p>
<div class="store-list">
<label
v-for="s in selectableStores"
:key="s.hashkey"
class="store-row"
:class="{ 'is-selected': assignedStoreHashes.includes(s.hashkey) }"
>
<input
type="checkbox"
:checked="assignedStoreHashes.includes(s.hashkey)"
@change="toggleStore(s.hashkey)"
/>
<div>
<div class="fw_6">{{ s.name }}</div>
<div class="text-muted smallest">{{ s.role }}<span v-if="s.category"> · {{ s.category }}</span></div>
</div>
</label>
</div>
</CardSimple>
<div class="nav-bar mt-4">
<button class="btn-text" @click="goBack">
<i class="fas fa-chevron-left me-2"></i> Back
</button>
<button class="btn btn-primary rounded-pill px-4" @click="advanceFromStores">
Next <i class="fas fa-chevron-right ms-2"></i>
</button>
</div>
</div>
<!-- STEP 4: Per-store overrides -->
<div v-else-if="step === STEP.PER_STORE">
<CardSimple title="Set price and stock for each store">
<p class="text-muted small">
Defaults come from the product's base price (₱{{ globalDefaultPrice }}). Adjust per store as needed.
</p>
<div class="per-store-list">
<div
v-for="hash in assignedStoreHashes"
:key="hash"
class="per-store-row"
>
<div class="per-store-name">
<i class="fas fa-store me-2 text-muted"></i>
<span class="fw_6">{{ storeName(hash) }}</span>
</div>
<div class="per-store-fields">
<div class="premium-input-group">
<label class="form-label smallest">Price (PHP)</label>
<input
type="number"
class="premium-input"
min="1"
step="0.01"
v-model="perStoreOverrides[hash].price"
/>
</div>
<div class="premium-input-group">
<label class="form-label smallest">Available</label>
<input
type="number"
class="premium-input"
min="0"
v-model="perStoreOverrides[hash].available"
/>
</div>
</div>
</div>
</div>
</CardSimple>
<div class="nav-bar mt-4">
<button class="btn-text" @click="goBack">
<i class="fas fa-chevron-left me-2"></i> Back
</button>
<AnimatedButton
@click="submit"
:disabled="isSubmitting"
:loading="isSubmitting"
btnClass="btn-premium-launch"
>
List Product
</AnimatedButton>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.csop-page {
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
}
.page-title {
font-family: 'Outfit', sans-serif;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
}
.premium-input-group { display: flex; flex-direction: column; }
.form-label { font-weight: 600; font-size: 0.9rem; color: #475569; margin-bottom: 6px; }
.required { color: #ef4444; margin-left: 4px; }
.premium-input, .premium-select {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #fff;
font-size: 0.95rem;
outline: none;
transition: all 0.2s;
}
.premium-input:focus, .premium-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
.premium-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
}
.glass-alert {
padding: 14px 18px;
border-radius: 14px;
font-weight: 500;
display: flex;
align-items: center;
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #b91c1c;
}
.results-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.result-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
}
.result-row:hover { border-color: #93c5fd; background: rgba(59, 130, 246, 0.04); }
.result-thumb {
width: 48px; height: 48px; border-radius: 10px;
object-fit: cover; flex-shrink: 0;
}
.result-info { flex: 1; min-width: 0; }
.picked-preview { display: flex; align-items: center; gap: 12px; }
.picked-thumb { width: 72px; height: 72px; border-radius: 12px; object-fit: cover; }
.store-list { display: flex; flex-direction: column; gap: 8px; }
.store-row {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
}
.store-row:hover { border-color: #93c5fd; }
.store-row.is-selected { border-color: #2563eb; background: rgba(37, 99, 235, 0.06); }
.per-store-list { display: flex; flex-direction: column; gap: 12px; }
.per-store-row {
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.per-store-name { display: flex; align-items: center; }
.per-store-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.nav-bar {
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-text {
background: transparent;
border: none;
color: #64748b;
font-weight: 500;
cursor: pointer;
}
.btn-text:hover { color: #1e293b; }
.btn-premium-launch {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
border: none;
padding: 12px 32px;
border-radius: 14px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.3);
}
.btn-premium-launch:disabled { background: #cbd5e1; cursor: not-allowed; box-shadow: none; }
:global(.dark-mode) .premium-input,
:global(.dark-mode) .premium-select {
background: #1e293b;
border-color: #334155;
color: #f8fafc;
}
:global(.dark-mode) .form-label { color: #94a3b8; }
:global(.dark-mode) .result-row,
:global(.dark-mode) .store-row,
:global(.dark-mode) .per-store-row { border-color: #334155; }
:global(.dark-mode) .page-title {
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
-webkit-background-clip: text;
background-clip: text;
}
</style>

View File

@@ -0,0 +1,954 @@
<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Create Product Ultimate');
import { ref, onMounted, watch, computed } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import LoadingSpinner from '../Components/LoadingSpinner.vue'
import CardSimple from '../Components/Core/CardSimple.vue'
import Dropzone from '../Components/Core/Dropzone.vue'
import FileImage from '../Components/Core/FileImage.vue'
import StockPhotoPicker from '../Components/Core/StockPhotoPicker.vue'
import { useFileUpload } from '../composables/useFileUpload.js'
import { useProductStore } from '../stores/product'
import { useAuth } from '../composables/Core/useAuth'
const productStore = useProductStore()
const { navigate } = useNavigate()
const modal = useModal()
const { isUltimate, isSuperOperator, isOperator } = useAuth()
const isBig3 = computed(() => isUltimate.value || isSuperOperator.value || isOperator.value)
const { uploadFile, removeHash, photoHashes, isUploading: isFileUploading } = useFileUpload({
category: 'ProductMarket',
maxSizeMB: 10
})
// Form state
const productName = ref('')
const productDescription = ref('')
const productCategory = ref('')
const productSubcategory = ref('')
const productPrice = ref(1)
const productUnitName = ref('')
const productAvailable = ref(1)
const productBarcode = ref('')
const selectedStore = ref('')
const selectableStores = ref([])
// Data lists
const categoryList = ref([])
const subcategoryList = ref([])
// Loading state
const isLoading = ref(false)
const showSuccessState = ref(false)
const showSuccessAnimation = ref(false)
const successMessage = ref('')
const error = ref(null)
// Initialize component
onMounted(() => {
document.title = 'New Product'
loadCategories()
fetchSelectableStores()
})
const fetchSelectableStores = async () => {
try {
const response = await axios.post('/Admin/Stores/Selectable')
if (response.data && response.data.success) {
selectableStores.value = response.data.data
}
} catch (error) {
console.error('Error loading stores:', error)
}
}
// Load categories
const loadCategories = async () => {
try {
const response = await axios.post('/Products/New/Category/Datalist', {})
if (response.data && Array.isArray(response.data)) {
categoryList.value = response.data.map(item => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : (item[1] || item[0])
}))
}
} catch (error) {
console.error('Error loading categories:', error)
}
}
// Load subcategories when category changes
const loadSubcategories = async () => {
if (!productCategory.value) {
subcategoryList.value = []
return
}
try {
const response = await axios.post('/Products/New/SubCategory/Datalist', {
category: productCategory.value
})
if (response.data && Array.isArray(response.data)) {
subcategoryList.value = response.data.map(item => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : (item[1] || item[0])
}))
}
} catch (error) {
console.error('Error loading subcategories:', error)
}
}
// Dropzone handling
const dropzoneRef = ref(null)
const dropzoneFiles = ref([])
// Stock photo picker
const showPhotoPicker = ref(false)
const onStockPhotoSelected = ({ hashkey, url }) => {
// Mirror the entry shape Dropzone produces: preview drives the thumbnail,
// hashkey is what handleSubmit filters on for the photourl payload.
dropzoneFiles.value.push({ file: null, name: 'stock-photo.jpg', preview: url, hashkey, uploading: false, progress: 100, error: null })
}
// Watch for new files in dropzone and upload them
watch(() => dropzoneFiles.value, async (newFiles, oldFiles) => {
// Find files that are not yet uploading and don't have a hashkey
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
for (const fileObj of filesToUpload) {
const index = newFiles.indexOf(fileObj);
if (index === -1) continue;
// Set uploading status
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
const result = await uploadFile(fileObj.file);
if (result && result.hashkey) {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 100,
hashkey: result.hashkey
});
} else {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 0,
error: 'Upload failed'
});
}
}
}, { deep: true });
const handlePhotoRemoved = (hashkey) => {
if (hashkey) {
removeHash(hashkey);
}
};
// Update subcategory list when category changes
const handleCategoryChange = () => {
loadSubcategories()
}
// Validate form
const validateForm = () => {
if (!productName.value) {
error.value = 'Product name is required'
return false
}
if (!productDescription.value) {
error.value = 'Product description is required'
return false
}
if (!productCategory.value) {
error.value = 'Category is required'
return false
}
if (!productSubcategory.value) {
error.value = 'Subcategory is required'
return false
}
if (!productPrice.value || parseFloat(productPrice.value) <= 0) {
error.value = 'Valid price is required'
return false
}
if (!productUnitName.value) {
error.value = 'Unit name is required'
return false
}
const hasFiles = dropzoneFiles.value.length > 0;
const hasHashes = photoHashes.value.length > 0 || dropzoneFiles.value.some(f => !!f.hashkey);
if (!hasFiles && !hasHashes) {
error.value = 'At least one photo is required'
return false
}
if (productBarcode.value && !/^\d{12}$/.test(productBarcode.value)) {
error.value = 'Barcode must be exactly 12 digits'
return false
}
error.value = null
return true
}
// Submit product
const handleSubmit = async () => {
if (!validateForm()) return
try {
isLoading.value = true
const response = await axios.post('/Products/Admin/New/', {
NewProductName: productName.value,
NewProductDescription: productDescription.value,
NewProductCategory: productCategory.value,
NewProductSubCategory: productSubcategory.value,
NewProductPrice: parseFloat(productPrice.value),
NewProductUnitName: productUnitName.value,
NewProductAvailable: parseInt(productAvailable.value),
NewProductBarcode: productBarcode.value,
TargetStore: selectedStore.value,
photourl: dropzoneFiles.value
.filter(f => f.hashkey)
.map(f => f.hashkey)
})
if (response.data && (response.data.success || typeof response.data === 'string')) {
showSuccessState.value = true;
showSuccessAnimation.value = true;
successMessage.value = 'Product created successfully!'
// Proactively prefetch products list
productStore.fetchProducts()
setTimeout(() => {
navigate({ page: 'ManageProductsAdmin' })
}, 1500)
} else {
error.value = response.data?.message || 'Failed to create product'
}
} catch (err) {
console.error('Error creating product:', err)
error.value = err.response?.data?.message || 'Failed to create product'
} finally {
isLoading.value = false
}
}
const isButtonDisabled = computed(() => {
return !!(isLoading.value || successMessage.value || isFileUploading.value);
});
// --- Fuzzy duplicate check + store-picker flow ---------------------------------
const fuzzyMatches = ref([])
const showMatchesModal = ref(false)
const showStorePickerModal = ref(false)
const isCheckingDuplicates = ref(false)
const isImporting = ref(false)
const pickerStore = ref('')
const checkDuplicatesAndProceed = async () => {
if (!validateForm()) return
isCheckingDuplicates.value = true
try {
const { data } = await axios.post('/Products/Admin/FuzzySearch', {
name: productName.value,
TargetStore: selectedStore.value || pickerStore.value || ''
})
const matches = (data && data.success && Array.isArray(data.data)) ? data.data : []
if (matches.length > 0) {
fuzzyMatches.value = matches
showMatchesModal.value = true
} else {
openStorePicker()
}
} catch (err) {
console.error('Fuzzy search failed:', err)
openStorePicker()
} finally {
isCheckingDuplicates.value = false
}
}
const openStorePicker = () => {
showMatchesModal.value = false
pickerStore.value = isBig3.value ? '' : (selectedStore.value || (selectableStores.value[0]?.hashkey ?? ''))
showStorePickerModal.value = true
}
const confirmAndCreate = async () => {
if (!isBig3.value && selectableStores.value.length > 0 && !pickerStore.value) {
error.value = 'Please select a store to assign this product to.'
return
}
selectedStore.value = pickerStore.value
showStorePickerModal.value = false
await handleSubmit()
}
const importExistingProduct = async (match) => {
if (match.already_in_store) return
const targetStore = selectedStore.value || pickerStore.value
if (!targetStore) {
// Need to pick a store first.
showMatchesModal.value = false
pickerStore.value = selectableStores.value[0]?.hashkey ?? ''
showStorePickerModal.value = true
return
}
try {
isImporting.value = true
const { data } = await axios.post('/Products/AssignToStore/', {
target: match.hashkey,
TargetStore: targetStore,
price: parseFloat(productPrice.value) || match.price,
available: parseInt(productAvailable.value) || 0,
})
if (data && data.success) {
showMatchesModal.value = false
showSuccessState.value = true
showSuccessAnimation.value = true
successMessage.value = `${match.name} imported to your store.`
productStore.fetchProducts()
setTimeout(() => navigate({ page: 'ManageProductsAdmin' }), 1500)
} else {
error.value = data?.message || 'Failed to import product.'
}
} catch (err) {
console.error('Import failed:', err)
error.value = err.response?.data?.message || 'Failed to import product.'
} finally {
isImporting.value = false
}
}
</script>
<template>
<div class="create-product-page pb-5">
<div class="tf-container mt-5 mb-4 text-center">
<h1 class="fw_8 premium-title">Create New Product</h1>
<p class="text-muted">Fill in the details to list your product in the market</p>
</div>
<div v-if="successMessage" class="tf-container mb-4">
<div class="glass-alert alert-success animate-fade-in">
<i class="fas fa-check-circle me-2"></i>
{{ successMessage }}
</div>
</div>
<div v-if="error" class="tf-container mb-4">
<div class="glass-alert alert-danger animate-shake">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ error }}
</div>
</div>
<div class="tf-container">
<div class="form-grid">
<!-- Left Column: Basic Info -->
<div class="form-section">
<CardSimple title="Product Details">
<div class="premium-input-group mb-4">
<label for="productName" class="form-label">Product Name <span class="required">*</span></label>
<input
type="text"
id="productName"
v-model="productName"
class="premium-input"
placeholder="e.g., Premium Rice"
>
</div>
<div class="premium-input-group mb-4">
<label for="productDescription" class="form-label">Description <span class="required">*</span></label>
<textarea
id="productDescription"
v-model="productDescription"
class="premium-input"
rows="4"
placeholder="Describe your product..."
></textarea>
</div>
<div class="row">
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="category" class="form-label">Category <span class="required">*</span></label>
<select
id="category"
v-model="productCategory"
class="premium-select"
@change="handleCategoryChange"
>
<option value="" disabled>Select Category</option>
<option v-for="cat in categoryList" :key="cat.value" :value="cat.value">
{{ cat.label }}
</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="subcategory" class="form-label">Subcategory <span class="required">*</span></label>
<select
id="subcategory"
v-model="productSubcategory"
class="premium-select"
:disabled="subcategoryList.length === 0"
>
<option value="" disabled>Select Subcategory</option>
<option v-for="sub in subcategoryList" :key="sub.value" :value="sub.value">
{{ sub.label }}
</option>
</select>
</div>
</div>
</div>
<div v-if="selectableStores.length > 0" class="premium-input-group mb-4 mt-2 border-top pt-4">
<label for="targetStore" class="form-label">Assign to Store (Optional)</label>
<select
id="targetStore"
v-model="selectedStore"
class="premium-select shadow-sm"
>
<option value="">No Store (Global Product Template)</option>
<option v-for="store in selectableStores" :key="store.hashkey" :value="store.hashkey">
{{ store.name }} ({{ store.role }})
</option>
</select>
<p class="smallest text-muted mt-2">
<i class="fas fa-info-circle me-1"></i>
Select a store to list this product immediately after creation.
</p>
</div>
</CardSimple>
</div>
<!-- Right Column: Inventory & Photos -->
<div class="form-section">
<CardSimple title="Inventory & Pricing">
<div class="row">
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="price" class="form-label">Price (PHP) <span class="required">*</span></label>
<input
type="number"
id="price"
v-model="productPrice"
class="premium-input"
min="1"
step="0.01"
>
</div>
</div>
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="unit" class="form-label">Unit <span class="required">*</span></label>
<input
type="text"
id="unit"
v-model="productUnitName"
class="premium-input"
placeholder="e.g., 25kg"
>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="available" class="form-label">Available Stock <span class="required">*</span></label>
<input
type="number"
id="available"
v-model="productAvailable"
class="premium-input"
min="1"
>
</div>
</div>
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="barcode" class="form-label">Barcode (12 Digits)</label>
<input
type="text"
id="barcode"
v-model="productBarcode"
class="premium-input"
maxlength="12"
placeholder="Optional"
>
</div>
</div>
</div>
<div class="premium-input-group mb-2">
<label class="form-label">Product Photos <span class="required">*</span></label>
<button type="button" class="btn btn-outline-secondary btn-sm rounded-pill mb-2"
@click="showPhotoPicker = true">
<i class="fas fa-images me-1"></i> Search Stock Photos
</button>
<Dropzone
ref="dropzoneRef"
v-model:files="dropzoneFiles"
@removed="handlePhotoRemoved"
/>
<StockPhotoPicker v-model="showPhotoPicker" :product-name="productName"
@photo-selected="onStockPhotoSelected" />
</div>
</CardSimple>
</div>
</div>
<div class="action-bar mt-5 text-center">
<AnimatedButton
@click="checkDuplicatesAndProceed"
:disabled="isButtonDisabled || isCheckingDuplicates"
btnClass="btn-premium-launch"
:loading="isLoading || isCheckingDuplicates"
:success="showSuccessState"
>
Create Product
</AnimatedButton>
<div class="mt-4">
<button
@click="navigate({ page: 'ManageProductsAdmin' })"
class="btn-text"
>
<i class="fas fa-chevron-left me-2"></i> Cancel and Return
</button>
</div>
</div>
</div>
<!-- Fuzzy Match Modal -->
<div v-if="showMatchesModal" class="bb-modal-backdrop" @click.self="showMatchesModal = false">
<div class="bb-modal">
<div class="bb-modal-header">
<div>
<h4 class="fw_7 mb-1">Similar products already exist</h4>
<p class="text-muted small mb-0">Import one of these into your store instead of creating a duplicate, or continue creating a new product.</p>
</div>
<button class="bb-modal-close" @click="showMatchesModal = false" aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="bb-modal-body">
<div v-for="m in fuzzyMatches" :key="m.hashkey" class="match-row">
<div class="match-row-top">
<FileImage
:src="m.photourl && m.photourl[0] ? m.photourl[0] : ''"
:alt="m.name"
class="match-thumb"
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin"
/>
<div class="match-info">
<div class="fw_6">{{ m.name }}</div>
<div class="text-muted small">
<span v-if="m.category">{{ m.category }}<span v-if="m.subcategory"> · {{ m.subcategory }}</span> · </span>
<span>{{ m.price }} / {{ m.unitname }}</span>
</div>
<div v-if="m.already_in_store" class="text-success smallest mt-1">
<i class="fas fa-check-circle me-1"></i> Already in your store
</div>
</div>
</div>
<button
class="btn btn-sm btn-primary rounded-pill w-100"
:disabled="m.already_in_store || isImporting"
@click="importExistingProduct(m)"
>
<span v-if="isImporting"><LoadingSpinner size="small" /></span>
<span v-else-if="m.already_in_store">In Store</span>
<span v-else>Import to Store</span>
</button>
</div>
</div>
<div class="bb-modal-footer">
<button class="btn btn-link text-muted" @click="showMatchesModal = false">Cancel</button>
<button class="btn btn-outline-primary rounded-pill px-4" @click="openStorePicker">
None of these Create new
</button>
</div>
</div>
</div>
<!-- Store Picker Modal -->
<div v-if="showStorePickerModal" class="bb-modal-backdrop" @click.self="showStorePickerModal = false">
<div class="bb-modal bb-modal-small">
<div class="bb-modal-header">
<div>
<h4 class="fw_7 mb-1">Assign new product to a store</h4>
<p class="text-muted small mb-0">
Pick the store this product will be listed in.
<span v-if="isBig3" class="ms-1 badge bg-info-subtle text-info rounded-pill" style="font-size:0.7em">Optional for your account</span>
</p>
</div>
<button class="bb-modal-close" @click="showStorePickerModal = false" aria-label="Close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="bb-modal-body">
<div v-if="selectableStores.length === 0" class="text-muted">
You don't have any stores yet. Create one first.
</div>
<div v-else class="store-picker-list">
<label
v-for="store in selectableStores"
:key="store.hashkey"
class="store-picker-row"
:class="{ 'is-selected': pickerStore === store.hashkey }"
>
<input type="radio" :value="store.hashkey" v-model="pickerStore" />
<div>
<div class="fw_6">{{ store.name }}</div>
<div class="text-muted smallest">{{ store.role }}<span v-if="store.category"> · {{ store.category }}</span></div>
</div>
</label>
</div>
<p v-if="isBig3 && !pickerStore" class="text-muted small mt-2 mb-0">
<i class="fas fa-info-circle me-1"></i>No store selected product will be created as a global listing only.
</p>
</div>
<div class="bb-modal-footer">
<button class="btn btn-link text-muted" @click="showStorePickerModal = false">Cancel</button>
<button
class="btn btn-primary rounded-pill px-4"
:disabled="isLoading || (!isBig3 && selectableStores.length > 0 && !pickerStore)"
@click="confirmAndCreate"
>
<span v-if="isLoading"><LoadingSpinner size="small" class="me-2" /> Creating...</span>
<span v-else>Confirm &amp; Create</span>
</button>
</div>
</div>
</div>
<!-- Success Animation Overlay -->
<div v-if="showSuccessAnimation" class="success-overlay">
<div class="text-center animate-bounce-in">
<LottiePlayer
path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/11999b7bb57c.json"
:loop="false"
width="250px"
height="250px"
/>
<h2 class="fw_8 mt-4 text-primary headline-gradient">Product Created!</h2>
<p class="text-muted">Your product is now listed in the market.</p>
</div>
</div>
</div>
</template>
<style scoped>
.premium-title {
font-family: 'Outfit', sans-serif;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 992px) {
.form-grid {
grid-template-columns: 1fr;
}
}
.premium-input-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 600;
font-size: 0.9rem;
color: #475569;
margin-bottom: 8px;
display: flex;
align-items: center;
}
.required {
color: #ef4444;
margin-left: 4px;
}
.premium-input, .premium-select {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #fff;
font-size: 0.95rem;
transition: all 0.2s;
outline: none;
}
.premium-input:focus, .premium-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
.premium-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
}
.glass-alert {
padding: 16px 20px;
border-radius: 16px;
backdrop-filter: blur(8px);
font-weight: 500;
display: flex;
align-items: center;
}
.alert-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #15803d;
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #b91c1c;
}
.btn-premium-launch {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
border: none;
padding: 16px 48px;
border-radius: 14px;
font-weight: 700;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.3);
}
.btn-premium-launch:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(37, 99, 235, 0.4);
filter: brightness(1.1);
}
.btn-premium-launch:disabled {
background: #cbd5e1;
cursor: not-allowed;
box-shadow: none;
}
.btn-loading {
padding: 12px 48px;
background: #3b82f6;
}
.btn-text {
background: transparent;
border: none;
color: #64748b;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
}
.btn-text:hover {
color: #1e293b;
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
:global(.dark-mode) .premium-input, :global(.dark-mode) .premium-select {
background: #1e293b;
border-color: #334155;
color: #f8fafc;
}
:global(.dark-mode) .premium-title {
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
-webkit-background-clip: text;
background-clip: text;
}
:global(.dark-mode) .form-label {
color: #94a3b8;
}
.success-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.98);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(15px);
}
:global(.dark-mode) .success-overlay {
background: rgba(18, 20, 24, 0.98);
}
.animate-bounce-in {
animation: bounce-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes bounce-in {
0% { transform: scale(0.3); opacity: 0; }
50% { transform: scale(1.05); opacity: 1; }
70% { transform: scale(0.9); }
100% { transform: scale(1); }
}
.bb-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 16px;
}
.bb-modal {
background: #fff;
border-radius: 16px;
width: 100%;
max-width: 640px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.35);
}
.bb-modal-small { max-width: 480px; }
.bb-modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
}
.bb-modal-close {
background: transparent;
border: none;
color: #64748b;
cursor: pointer;
font-size: 1rem;
padding: 4px 8px;
}
.bb-modal-body {
padding: 16px 24px;
overflow-y: auto;
}
.bb-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
}
.match-row {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid #f1f5f9;
}
.match-row-top {
display: flex;
align-items: center;
gap: 12px;
}
.match-row:last-child { border-bottom: none; }
.match-thumb {
width: 56px;
height: 56px;
border-radius: 10px;
object-fit: cover;
flex-shrink: 0;
}
.match-info { flex: 1; min-width: 0; }
.store-picker-list { display: flex; flex-direction: column; gap: 8px; }
.store-picker-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
}
.store-picker-row:hover { border-color: #93c5fd; }
.store-picker-row.is-selected {
border-color: #2563eb;
background: rgba(37, 99, 235, 0.06);
}
:global(.dark-mode) .bb-modal {
background: #1e293b;
color: #f8fafc;
}
:global(.dark-mode) .bb-modal-header,
:global(.dark-mode) .bb-modal-footer { border-color: #334155; }
:global(.dark-mode) .store-picker-row { border-color: #334155; }
:global(.dark-mode) .match-row { border-bottom-color: #334155; }
.headline-gradient {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
</style>

View File

@@ -0,0 +1,733 @@
<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Create Store');
import { ref, computed, onMounted, watch } from 'vue';
import axios from 'axios';
import { useNavigate } from '../composables/Core/useNavigate.js';
import { useAuth } from '../composables/Core/useAuth.js';
import LoadingSpinner from '../Components/LoadingSpinner.vue';
import CardSimple from '../Components/Core/CardSimple.vue';
import Dropzone from '../Components/Core/Dropzone.vue';
import { useFileUpload } from '../composables/useFileUpload.js';
import { usePrefetchStore } from '../stores/prefetch';
const prefetchStore = usePrefetchStore();
const { navigate } = useNavigate();
const { isStoreOwner } = useAuth();
const { uploadFile, removeHash, photoHashes } = useFileUpload({
category: 'StoreMarket',
maxSizeMB: 10
});
const storeName = ref('');
const description = ref('');
const category = ref('');
const subcategory = ref('');
const categories = ref([]);
const subcategories = ref([]);
const address = ref('');
const owner = ref('');
const managers = ref([]);
const cooperatives = ref([]);
const cooperativeOptions = ref([]);
const cooperativeSearch = ref('');
const status = ref('active');
const remarks = ref('');
const loading = ref(false);
const showSuccessState = ref(false);
const showSuccessAnimation = ref(false);
const error = ref(null);
const successMessage = ref('');
const users = ref([]);
const userSearch = ref('');
const filteredUsers = computed(() => {
if (!userSearch.value) return users.value;
return users.value.filter(u =>
u.name.toLowerCase().includes(userSearch.value.toLowerCase()) ||
u.username.toLowerCase().includes(userSearch.value.toLowerCase())
);
});
const fetchCategories = async () => {
try {
const response = await axios.post('/Store/New/Category/Datalist', {});
if (response.data && Array.isArray(response.data)) {
categories.value = response.data.map(item => ({
label: typeof item === 'string' ? item : (item[1] || item[0]),
value: typeof item === 'string' ? item : item[0]
}));
}
} catch (error) {
console.error('Failed to fetch categories:', error);
}
};
const fetchSubcategories = async () => {
if (!category.value) {
subcategories.value = [];
return;
}
try {
const response = await axios.post('/Store/New/SubCategory/Datalist', {
category: category.value
});
if (response.data && Array.isArray(response.data)) {
subcategories.value = response.data.map(item => ({
label: typeof item === 'string' ? item : (item[1] || item[0]),
value: typeof item === 'string' ? item : item[0]
}));
}
} catch (error) {
console.error('Failed to fetch subcategories:', error);
}
};
const handleCategoryChange = () => {
subcategory.value = '';
fetchSubcategories();
};
const filteredCooperatives = computed(() => {
if (!cooperativeSearch.value) return cooperativeOptions.value;
const q = cooperativeSearch.value.toLowerCase();
return cooperativeOptions.value.filter(c =>
(c.name || '').toLowerCase().includes(q) ||
(c.cooperative_type || '').toLowerCase().includes(q)
);
});
const fetchCooperatives = async () => {
try {
const response = await axios.post('/Store/Cooperatives/List', {});
if (response.data && response.data.success && Array.isArray(response.data.data)) {
cooperativeOptions.value = response.data.data;
}
} catch (error) {
console.error('Failed to fetch cooperatives:', error);
}
};
const fetchUsers = async () => {
try {
// STORE_OWNER may only see STORE_MANAGER descendants as eligible additional managers.
const payload = isStoreOwner.value ? { type: 'store manager' } : {};
const response = await axios.post('/admin/user/list/numbers/hash', payload);
if (response.data && Array.isArray(response.data)) {
users.value = response.data;
}
} catch (error) {
console.error('Failed to fetch users:', error);
}
};
const hasManagerCandidates = computed(() => users.value.length > 0);
const isButtonDisabled = computed(() => {
return !!(loading.value || successMessage.value || !storeName.value || !description.value || !address.value);
});
const dropzoneRef = ref(null);
const dropzoneFiles = ref([]);
// Watch for new files in dropzone and upload them
watch(() => dropzoneFiles.value, async (newFiles, oldFiles) => {
// Find files that are not yet uploading and don't have a hashkey
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
for (const fileObj of filesToUpload) {
const index = newFiles.indexOf(fileObj);
if (index === -1) continue;
// Set uploading status
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
const result = await uploadFile(fileObj.file);
if (result && result.hashkey) {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 100,
hashkey: result.hashkey
});
} else {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 0,
error: 'Upload failed'
});
}
}
}, { deep: true });
const handlePhotoRemoved = (hashkey) => {
removeHash(hashkey);
};
const handleSubmit = async () => {
error.value = null;
successMessage.value = '';
if (!storeName.value || !description.value || !address.value) {
error.value = 'Please fill in all required fields';
return;
}
loading.value = true;
try {
const payload = {
name: storeName.value,
description: description.value,
address: address.value,
category: category.value,
subcategory: subcategory.value,
managers: managers.value,
photourl: dropzoneFiles.value
.filter(f => f.hashkey)
.map(f => f.hashkey),
};
if (!isStoreOwner.value) {
payload.owner = owner.value || undefined;
payload.cooperatives = cooperatives.value;
payload.status = status.value;
payload.remarks = remarks.value;
}
const response = await axios.post('/Store/New', payload);
if (response.data && response.data.success) {
showSuccessState.value = true;
showSuccessAnimation.value = true;
successMessage.value = 'Store created successfully!';
// Proactively prefetch stores list so ListStores shows fresh data immediately
try {
const listResp = await axios.post('/ListStores/List/data', {});
if (listResp.data) {
const fresh = listResp.data.props || listResp.data;
prefetchStore.setCache('POST:/ListStores/List/data:{}', fresh);
}
} catch (e) {
console.warn('Failed to prefetch stores list:', e);
}
const newStoreHash = response.data.hashkey;
setTimeout(() => {
if (newStoreHash) {
navigate({ page: 'AddProductsToStore', props: { target: newStoreHash } });
} else {
navigate({ page: 'ListStores' });
}
}, 1500);
} else {
error.value = response.data?.message || 'Failed to create store';
}
} catch (err) {
console.error('Failed to create store:', err);
error.value = err.response?.data?.message || 'Failed to create store. Please try again.';
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchUsers();
fetchCategories();
if (!isStoreOwner.value) {
fetchCooperatives();
}
});
</script>
<template>
<div class="create-store-page pb-5">
<div class="tf-container mt-5 mb-4 text-center">
<h1 class="fw_8 premium-title">Launch Your Store</h1>
<p class="text-muted">Set up your marketplace presence in just a few clicks</p>
</div>
<div v-if="successMessage" class="tf-container mb-4">
<div class="glass-alert alert-success animate-fade-in">
<i class="fas fa-check-circle me-2"></i>
{{ successMessage }}
</div>
</div>
<div v-if="error" class="tf-container mb-4">
<div class="glass-alert alert-danger animate-shake">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ error }}
</div>
</div>
<div class="tf-container">
<div class="form-grid">
<!-- Left Column: Basic Info -->
<div class="form-section">
<CardSimple title="Essential Information">
<div class="premium-input-group mb-4">
<label for="storeName" class="form-label">Store Name <span class="required">*</span></label>
<input
type="text"
id="storeName"
v-model="storeName"
class="premium-input"
placeholder="e.g., Organic Bounty Farm"
autocomplete="off"
>
</div>
<div class="row">
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="category" class="form-label">Category</label>
<select
id="category"
v-model="category"
class="premium-select"
@change="handleCategoryChange"
>
<option value="" disabled>Select a category</option>
<option v-for="cat in categories" :key="cat.value" :value="cat.value">
{{ cat.label }}
</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="subcategory" class="form-label">Subcategory</label>
<select
id="subcategory"
v-model="subcategory"
class="premium-select"
:disabled="subcategories.length === 0"
>
<option value="" disabled>Select subcategory</option>
<option v-for="sub in subcategories" :key="sub.value" :value="sub.value">
{{ sub.label }}
</option>
</select>
</div>
</div>
</div>
<div class="premium-input-group mb-4">
<label for="description" class="form-label">Description <span class="required">*</span></label>
<textarea
id="description"
v-model="description"
class="premium-input"
rows="5"
placeholder="Describe what makes your store special..."
></textarea>
</div>
<div v-if="!isStoreOwner" class="premium-input-group mb-4">
<label for="remarks" class="form-label">Internal Remarks</label>
<textarea
id="remarks"
v-model="remarks"
class="premium-input"
rows="2"
placeholder="Any internal notes or remarks..."
></textarea>
</div>
</CardSimple>
</div>
<!-- Right Column: Location & Photos -->
<div class="form-section">
<CardSimple title="Location & Visuals">
<div class="premium-input-group mb-4">
<label for="address" class="form-label">Address <span class="required">*</span></label>
<textarea
id="address"
v-model="address"
class="premium-input"
rows="2"
placeholder="Complete physical address"
></textarea>
</div>
<div v-if="!isStoreOwner" class="row">
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="owner" class="form-label">Store Owner</label>
<select
id="owner"
v-model="owner"
class="premium-select"
>
<option value="" disabled>Select owner</option>
<option v-for="user in users" :key="user.hashkey" :value="user.hashkey">
{{ user.name }} ({{ user.username }})
</option>
</select>
</div>
</div>
</div>
<div v-if="isStoreOwner" class="premium-input-group mb-4">
<label class="form-label">Store Owner</label>
<div class="premium-input" style="background:rgba(59,130,246,0.06); border-color:rgba(59,130,246,0.25);">
<i class="fas fa-user-shield me-2 text-primary"></i>
You will be the owner of this store.
</div>
</div>
<!-- Multi-Manager Selection -->
<div v-if="!isStoreOwner || hasManagerCandidates" class="premium-input-group mb-4">
<label class="form-label">Additional Store Managers</label>
<div class="multi-user-list glass-card p-3">
<div class="search-box mb-2">
<input type="text" v-model="userSearch" placeholder="Search users..." class="search-input">
</div>
<div class="user-selection-area">
<div v-for="user in filteredUsers" :key="user.hashkey" class="user-item">
<label class="custom-checkbox-label">
<input type="checkbox" v-model="managers" :value="user.hashkey" class="custom-checkbox">
<span class="user-name">{{ user.name }}</span>
<span class="user-meta">{{ user.username }}</span>
</label>
</div>
</div>
</div>
<p class="input-hint mt-2">Select one or more users to help manage this store.</p>
</div>
<!-- Cooperative Links (optional, many-to-many) -->
<div v-if="!isStoreOwner" class="premium-input-group mb-4">
<label class="form-label">Linked Cooperatives</label>
<div class="multi-user-list glass-card p-3">
<div class="search-box mb-2">
<input type="text" v-model="cooperativeSearch" placeholder="Search cooperatives..." class="search-input">
</div>
<div class="user-selection-area">
<div v-if="filteredCooperatives.length === 0" class="text-muted small p-2">
No cooperatives available.
</div>
<div v-for="coop in filteredCooperatives" :key="coop.hashkey" class="user-item">
<label class="custom-checkbox-label">
<input type="checkbox" v-model="cooperatives" :value="coop.hashkey" class="custom-checkbox">
<span class="user-name">{{ coop.name }}</span>
<span class="user-meta">{{ coop.cooperative_type || '' }}</span>
</label>
</div>
</div>
</div>
<p class="input-hint mt-2">Optional. Link this store to one or more cooperatives.</p>
</div>
<div v-if="!isStoreOwner" class="premium-input-group mb-4">
<label for="status" class="form-label">Store Status</label>
<select
id="status"
v-model="status"
class="premium-select"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="pending">Pending</option>
<option value="suspended">Suspended</option>
</select>
</div>
<div class="premium-input-group mb-2">
<label class="form-label">Store Photos</label>
<Dropzone
ref="dropzoneRef"
v-model:files="dropzoneFiles"
@removed="handlePhotoRemoved"
/>
<p class="input-hint mt-2">Upload high-quality images to attract more customers.</p>
</div>
</CardSimple>
</div>
</div>
<div class="action-bar mt-5 text-center">
<AnimatedButton
@click="handleSubmit"
:disabled="isButtonDisabled"
btnClass="btn-premium-launch"
:loading="loading"
:success="showSuccessState"
>
Create Store Now
</AnimatedButton>
<div class="mt-4">
<button
@click="navigate({ page: 'ListStores' })"
class="btn-text"
>
<i class="fas fa-chevron-left me-2"></i> Cancel and Return
</button>
</div>
</div>
</div>
<!-- Success Animation Overlay -->
<div v-if="showSuccessAnimation" class="success-overlay">
<div class="text-center animate-bounce-in">
<LottiePlayer
path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/11999b7bb57c.json"
:loop="false"
width="250px"
height="250px"
/>
<h2 class="fw_8 mt-4 text-primary headline-gradient">Congratulations!</h2>
<p class="text-muted">Your store is now ready for business.</p>
</div>
</div>
</div>
</template>
<style scoped>
.premium-title {
font-family: 'Outfit', sans-serif;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
}
.multi-user-list {
max-height: 250px;
overflow-y: auto;
border: 1px solid rgba(0,0,0,0.08);
border-radius: 12px;
}
.search-input {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
background: rgba(255,255,255,0.5);
}
.user-item {
padding: 6px 0;
border-bottom: 1px solid rgba(0,0,0,0.03);
}
.custom-checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
width: 100%;
}
.user-name {
font-weight: 500;
}
.user-meta {
font-size: 12px;
color: #64748b;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 992px) {
.form-grid {
grid-template-columns: 1fr;
}
}
.premium-input-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 600;
font-size: 0.9rem;
color: #475569;
margin-bottom: 8px;
display: flex;
align-items: center;
}
.required {
color: #ef4444;
margin-left: 4px;
}
.premium-input, .premium-select {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #fff;
font-size: 0.95rem;
transition: all 0.2s;
outline: none;
}
.premium-input:focus, .premium-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
.premium-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
}
.input-hint {
font-size: 0.8rem;
color: #94a3b8;
}
.glass-alert {
padding: 16px 20px;
border-radius: 16px;
backdrop-filter: blur(8px);
font-weight: 500;
display: flex;
align-items: center;
}
.alert-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #15803d;
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #b91c1c;
}
.btn-premium-launch {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
border: none;
padding: 16px 48px;
border-radius: 14px;
font-weight: 700;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.3);
}
.btn-premium-launch:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(37, 99, 235, 0.4);
filter: brightness(1.1);
}
.btn-premium-launch:disabled {
background: #cbd5e1;
cursor: not-allowed;
box-shadow: none;
}
.btn-loading {
padding: 12px 48px;
background: #3b82f6;
}
.btn-text {
background: transparent;
border: none;
color: #64748b;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
}
.btn-text:hover {
color: #1e293b;
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
:global(.dark-mode) .premium-input, :global(.dark-mode) .premium-select {
background: #1e293b;
border-color: #334155;
color: #f8fafc;
}
:global(.dark-mode) .premium-title {
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
-webkit-background-clip: text;
background-clip: text;
}
:global(.dark-mode) .form-label {
color: #94a3b8;
}
.success-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.98);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(15px);
}
:global(.dark-mode) .success-overlay {
background: rgba(18, 20, 24, 0.98);
}
.animate-bounce-in {
animation: bounce-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes bounce-in {
0% { transform: scale(0.3); opacity: 0; }
50% { transform: scale(1.05); opacity: 1; }
70% { transform: scale(0.9); }
100% { transform: scale(1); }
}
.headline-gradient {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
</style>

View File

@@ -0,0 +1,687 @@
<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Create User');
import { ref, computed, onMounted, h } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import { useUserStore } from '../stores/user'
const { navigate } = useNavigate()
const modal = useModal()
const userStore = useUserStore()
// Form state
const mobileNumber = ref('')
const nickname = ref('')
const name = ref('')
const username = ref('')
const fullname = ref('')
const userType = ref('')
const parent = ref('')
const password = ref('')
const confirmPassword = ref('')
// Validation states
const isMobileValid = ref(false)
const isUsernameValid = ref(false)
const isPasswordValid = ref(false)
const isConfirmPasswordValid = ref(false)
const isNameValid = computed(() => name.value.trim().length > 0)
const isUserTypeValid = computed(() => userType.value !== '')
// Computed for missing fields
const missingFields = computed(() => {
const fields = []
if (!mobileNumber.value) {
fields.push('Mobile Number')
} else if (!isMobileValid.value) {
if (isCheckingMobile.value) {
fields.push('Checking Mobile Number...')
} else if (!hasValidMobileNumberFormat(mobileNumber.value)) {
fields.push('Valid Mobile Number format (09XXXXXXXXX)')
} else {
fields.push('Unique/Available Mobile Number')
}
}
if (!name.value.trim()) fields.push('Name')
if (!username.value.trim()) {
fields.push('Username')
} else if (!isUsernameValid.value) {
if (isCheckingUsername.value) {
fields.push('Checking Username...')
} else {
fields.push('Unique/Available Username')
}
}
if (!userType.value) fields.push('User Type')
if (!parent.value) fields.push('Parent (Upline/Direct)')
if (password.value.length < 6) fields.push('Password (min 6 characters)')
if (password.value !== confirmPassword.value || !confirmPassword.value) {
fields.push('Passwords matching')
}
return fields
})
// Data lists
const userTypeList = ref([])
const parentList = ref([])
// Loading state
const isLoading = ref(false)
const isCheckingMobile = ref(false)
const isCheckingUsername = ref(false)
const showSuccessState = ref(false)
const showSuccessAnimation = ref(false)
// Initialize the component
onMounted(async () => {
document.title = 'Create New User'
// Ensure we have current user profile
if (!userStore.user) {
await userStore.fetchCurrentUser()
}
populateUserTypeList()
await populateParentList()
// Default parent to current user's hashkey if available
if (userStore.user?.hashkey && !parent.value) {
parent.value = userStore.user.hashkey
}
})
// Check if current user is ultimate account type
const isCurrentUserIdentityUltimate = computed(() => userStore.acctType === 'ult')
// Validate mobile number format only for non-ultimate accounts (Philippine format: 09XXXXXXXXX)
const hasValidMobileNumberFormat = (number) => {
// If current user is ultimate, no validation needed
if (isCurrentUserIdentityUltimate.value) {
return true
}
const pattern = /^(09|\+639)\d{9}$/
return pattern.test(number)
}
// Debounce helper
const debounce = (fn, delay) => {
let timeoutId;
return (...args) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
};
// Check if user exists
const checkUserExists = async () => {
const number = mobileNumber.value
if (number === '') {
isMobileValid.value = false
return false
}
if (!hasValidMobileNumberFormat(number)) {
isMobileValid.value = false
return false
}
try {
isCheckingMobile.value = true
const response = await axios.post('/admin/user/number/exists', { mobile_number: number })
if (response.data && response.data.exists === true) {
isMobileValid.value = false
return false
} else if (response.data.exists === false) {
isMobileValid.value = true
validateAllInputs()
return true
}
} catch (error) {
console.error('Error checking user existence:', error)
isMobileValid.value = false
} finally {
isCheckingMobile.value = false
}
}
const checkUsernameExists = async () => {
const usernameValue = username.value
// Username required
if (usernameValue === '') {
isUsernameValid.value = false
validateAllInputs()
return
}
try {
isCheckingUsername.value = true
const response = await axios.post('/admin/user/username/exists', { username: usernameValue })
if (response.data && response.data.exists === true) {
isUsernameValid.value = false
} else if (response.data.exists === false) {
isUsernameValid.value = true
}
} catch (error) {
console.error('Error checking username existence:', error)
isUsernameValid.value = false
} finally {
isCheckingUsername.value = false
}
}
// Check for missing fields and show modal
const handleFormSubmit = () => {
if (missingFields.value.length > 0) {
modal.open({
title: 'Missing Requirements',
body: h('div', { class: 'p-3' }, [
h('div', { class: 'd-flex align-items-center mb-3 text-warning' }, [
h('i', { class: 'fas fa-exclamation-circle fa-2x me-3' }),
h('span', { class: 'fw_7 h5 mb-0' }, 'Required Fields')
]),
h('p', { class: 'text-muted mb-3' }, 'Please complete the following missing or invalid fields:'),
h('div', { class: 'd-flex flex-wrap gap-2' },
missingFields.value.map(field =>
h('span', { class: 'badge bg-light text-danger border border-danger-subtle rounded-pill px-3 py-2 fw_6' }, field)
)
)
]),
footer: h('button', {
class: 'btn btn-primary w-100 py-3 rounded-xl fw_7 shadow-sm',
onClick: () => modal.close()
}, 'I Understand')
})
return
}
showConfirmationModal()
}
// Validate password
const validatePassword = () => {
if (password.value.length < 6) {
isPasswordValid.value = false
return false
}
isPasswordValid.value = true
validateAllInputs()
return true
}
// Validate confirm password
const validateConfirmPassword = () => {
if (password.value.length < 6 || confirmPassword.value.length < 6) {
isConfirmPasswordValid.value = false
return false
}
if (password.value !== confirmPassword.value) {
isConfirmPasswordValid.value = false
return false
}
isConfirmPasswordValid.value = true
validateAllInputs()
return true
}
// Validate all inputs
const validateAllInputs = () => {
// Logic preserved for reactive updates but button visibility no longer toggled by DOM
}
// Populate user type dropdown
const populateUserTypeList = async () => {
try {
const response = await axios.post('/admin/list/usertype/create', {})
if (response.data && Array.isArray(response.data)) {
userTypeList.value = response.data.map(item => ({
value: item[0],
label: item[1]
}))
}
} catch (error) {
console.error('Error populating user type list:', error)
}
}
// Populate parent dropdown
const populateParentList = async () => {
try {
const response = await axios.post('/admin/user/list/numbers/hash', {})
if (response.data && Array.isArray(response.data)) {
parentList.value = response.data.map(user => ({
value: user.hashkey,
label: `${user.name} (${user.mobile_number}) [${user.username}] ${user.fullname ?? ''}`
}))
}
} catch (error) {
console.error('Error populating parent list:', error)
} finally {
// Backend now handles including current user in hierarchy
if (userStore.user?.hashkey && !parent.value) {
parent.value = userStore.user.hashkey
}
}
}
// Show confirmation modal
const showConfirmationModal = () => {
modal.yesNoModal({
title: 'Create New User?',
body: 'Are you sure you want to Create a New User?',
onYes: registerUser,
yesText: 'Register',
noText: 'Cancel'
})
}
// Register the user
const registerUser = async () => {
// Validate all inputs first
await checkUserExists()
if (!isMobileValid.value) {
modal.open({
title: 'Error',
body: 'Please enter a valid mobile number (09XXXXXXXXX format)',
footer: null
})
return
}
if (!validatePassword() || !validateConfirmPassword()) {
modal.open({
title: 'Error',
body: 'Password must be at least 6 characters and passwords must match',
footer: null
})
return
}
try {
isLoading.value = true
const response = await axios.post('/admin/user/create', {
mobile_number: String(mobileNumber.value),
password: password.value,
nickname: nickname.value,
type: userType.value,
parent: parent.value,
fullname: fullname.value,
name: name.value,
username: username.value
})
if (response.data && response.data.success) {
showSuccessState.value = true
showSuccessAnimation.value = true
userStore.fetchUsers()
setTimeout(() => {
showSuccessAnimation.value = false
navigate({ page: 'UserList' })
}, 2000)
} else {
showErrorModal('User was not created.')
}
} catch (error) {
console.error('Registration error:', error)
const data = error.response?.data
const messages = []
if (data?.errors && typeof data.errors === 'object') {
for (const fieldMessages of Object.values(data.errors)) {
if (Array.isArray(fieldMessages)) {
messages.push(...fieldMessages)
} else if (typeof fieldMessages === 'string') {
messages.push(fieldMessages)
}
}
}
if (data?.error) messages.push(data.error)
if (data?.message) messages.push(data.message)
showErrorModal(messages.length ? messages : ['Error creating user'])
} finally {
isLoading.value = false
}
}
// Show success modal
const showSuccessModal = (hashKey) => {
// Proactively prefetch users list
userStore.fetchUsers()
modal.continueCancelModal({
title: 'Success',
body: 'User Created Successfully',
onContinue: () => {
navigate({ page: 'UserList' })
},
continueText: 'OK',
continueClass: 'btn btn-primary',
showCancel: false
})
}
// Show error modal
const showErrorModal = (message) => {
const messages = Array.isArray(message) ? message : [message]
modal.open({
title: 'Failed to Create User',
body: h('div', { class: 'p-3' }, [
h('div', { class: 'd-flex align-items-center mb-3 text-danger' }, [
h('i', { class: 'fas fa-exclamation-circle fa-2x me-3' }),
h('span', { class: 'fw_7 h5 mb-0' }, 'Please fix the following:')
]),
h('ul', { class: 'mb-0 ps-3' },
messages.map(msg => h('li', { class: 'text-danger fw_6 mb-1' }, msg))
)
]),
footer: null
})
}
// Handle form input changes
const handleMobileNumberChange = (event) => {
// Ensure mobile number stays as string to preserve leading zeros
const input = event.target || event
if (input && typeof input === 'object') {
mobileNumber.value = String(mobileNumber.value)
}
// Reset validity while typing if not empty, so the checkmark goes away
if (mobileNumber.value !== '') {
isMobileValid.value = false
}
debouncedCheckUserExists()
validateAllInputs()
}
const handleUsernameChange = () => {
// Reset validity while typing
if (username.value !== '') {
isUsernameValid.value = false
}
debouncedCheckUsernameExists()
}
const debouncedCheckUserExists = debounce(checkUserExists, 600)
const debouncedCheckUsernameExists = debounce(checkUsernameExists, 600)
const handlePasswordChange = () => {
validatePassword()
validateAllInputs()
}
const handleConfirmPasswordChange = () => {
validateConfirmPassword()
validateAllInputs()
}
// Clear form
const clearForm = () => {
mobileNumber.value = String('')
nickname.value = ''
name.value = ''
username.value = ''
fullname.value = ''
userType.value = ''
parent.value = userStore.user?.hashkey || ''
password.value = ''
confirmPassword.value = ''
isMobileValid.value = false
isUsernameValid.value = false
isPasswordValid.value = false
isConfirmPasswordValid.value = false
validateAllInputs()
}
</script>
<template>
<div class="create-user-page pb-5">
<br><br>
<div class="tf-container">
<h2 class="fw_6 text-center mb-4">Create New User</h2>
<div class="card shadow-sm">
<div class="card-body">
<div class="row g-3">
<!-- Mobile Number -->
<div class="col-12">
<div class="input-group">
<span class="input-group-text" :class="isMobileValid ? 'bg-success' : ''">
<i v-if="isCheckingMobile" class="fas fa-spinner fa-spin"></i>
<i v-else-if="isMobileValid" class="fas fa-check text-white"></i>
</span>
<input
type="text"
id="usernumber"
class="form-control"
placeholder="Mobile Number (e.g., 09123456789)"
v-model="mobileNumber"
@input="handleMobileNumberChange"
>
</div>
</div>
<!-- Nickname -->
<div class="col-12">
<input
type="text"
id="nickname"
class="form-control"
placeholder="Nick Name (Optional)"
v-model.trim="nickname"
>
</div>
<!-- Name -->
<div class="col-12">
<input
type="text"
id="name"
class="form-control"
placeholder="Name (Required)"
v-model.trim="name"
@input="validateAllInputs"
:disabled="isLoading"
>
</div>
<!-- Username -->
<div class="col-12">
<div class="input-group">
<span class="input-group-text" :class="isUsernameValid && username !== '' ? 'bg-success' : ''">
<i v-if="isCheckingUsername" class="fas fa-spinner fa-spin"></i>
<i v-else-if="isUsernameValid && username !== ''" class="fas fa-check text-white"></i>
</span>
<input
type="text"
id="username"
class="form-control"
placeholder="Username (Required)"
v-model.trim="username"
@input="handleUsernameChange"
>
</div>
</div>
<!-- Fullname -->
<div class="col-12">
<input
type="text"
id="fullname"
class="form-control"
placeholder="Full Name"
v-model.trim="fullname"
:disabled="isLoading"
>
</div>
<!-- User Type -->
<div class="col-12">
<select
class="form-select"
id="usertype"
v-model="userType"
required
@change="validateAllInputs"
:disabled="isLoading || userTypeList.length === 0"
>
<option value="" disabled>Select User Type</option>
<option v-for="type in userTypeList" :key="type.value" :value="type.value">
{{ type.label }}
</option>
</select>
</div>
<!-- Parent -->
<div class="col-12">
<select
class="form-select"
id="userparent"
v-model="parent"
:disabled="isLoading"
required
>
<option value="" disabled>Select Parent (Required)</option>
<option v-for="parentUser in parentList" :key="parentUser.value" :value="parentUser.value">
{{ parentUser.label }}
</option>
</select>
</div>
<!-- Password -->
<div class="col-12">
<input
type="password"
id="userpassword"
class="form-control"
placeholder="Password (min 6 characters)"
v-model.trim="password"
@input="handlePasswordChange"
:disabled="isLoading"
>
</div>
<!-- Confirm Password -->
<div class="col-12">
<div class="input-group">
<span class="input-group-text" :class="isConfirmPasswordValid ? 'bg-success' : ''"></span>
<input
type="password"
id="userpasswordconfirm"
class="form-control"
placeholder="Confirm Password"
v-model.trim="confirmPassword"
@input="handleConfirmPasswordChange"
:disabled="isLoading"
>
</div>
</div>
<div class="col-12 mt-3">
<AnimatedButton
id="RegisterNowButtonVisible"
btnClass="btn btn-primary w-100 py-3 shadow-lg rounded-xl fw_7"
@click="handleFormSubmit"
:loading="isLoading"
:success="showSuccessState"
>
Create User Account
</AnimatedButton>
</div>
</div>
</div>
</div>
<div class="text-center mt-4">
<button class="btn btn-outline-secondary" @click="navigate({ page: 'UserList' })">
Cancel
</button>
</div>
</div>
<!-- Success Animation Overlay -->
<div v-if="showSuccessAnimation" class="success-overlay">
<div class="text-center animate-bounce-in">
<LottiePlayer
path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/11999b7bb57c.json"
:loop="false"
width="250px"
height="250px"
/>
<h2 class="fw_8 mt-4 text-primary headline-gradient">Welcome Aboard!</h2>
<p class="text-muted">The user account has been created successfully.</p>
</div>
</div>
</div>
</template>
<style scoped>
.card {
border-radius: 12px;
}
.success-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.98);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(15px);
}
:global(.dark-mode) .success-overlay {
background: rgba(18, 20, 24, 0.98);
}
.animate-bounce-in {
animation: bounce-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes bounce-in {
0% { transform: scale(0.3); opacity: 0; }
50% { transform: scale(1.05); opacity: 1; }
70% { transform: scale(0.9); }
100% { transform: scale(1); }
}
.headline-gradient {
background: linear-gradient(135deg, #42b983 0%, #2c3e50 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.rounded-xl {
border-radius: 15px;
}
</style>

View File

@@ -0,0 +1,617 @@
<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Edit Product Ultimate');
import { ref, onMounted, watch, computed } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import LoadingSpinner from '../Components/LoadingSpinner.vue'
import CardSimple from '../Components/Core/CardSimple.vue'
import Dropzone from '../Components/Core/Dropzone.vue'
import { useFileUpload } from '../composables/useFileUpload.js'
import { useProductStore } from '../stores/product'
const productStore = useProductStore()
const props = defineProps({
target: { type: String, default: null },
payload: { type: Object, default: null }
})
const { navigate } = useNavigate()
const modal = useModal()
const { uploadFile, removeHash, photoHashes, setInitialHashes, isUploading: isFileUploading } = useFileUpload({
category: 'ProductMarket',
maxSizeMB: 10
})
// Form state
const productId = ref(null)
const productName = ref('')
const productDescription = ref('')
const productCategory = ref('')
const productSubcategory = ref('')
const productPrice = ref(0)
const productUnitName = ref('')
const productAvailable = ref(0)
const productBarcode = ref('')
const storeHash = ref(null)
// Data lists
const categoryList = ref([])
const subcategoryList = ref([])
// Loading state
const isLoading = ref(false)
const successMessage = ref('')
const error = ref(null)
// Initialize component
onMounted(async () => {
document.title = 'Edit Product'
const urlParams = new URLSearchParams(window.location.search)
storeHash.value = urlParams.get('store_hash') || urlParams.get('store')
await loadCategories()
await loadProductData()
})
// Load categories
const loadCategories = async () => {
try {
const response = await axios.post('/Products/New/Category/Datalist', {})
const data = response.data.categories || response.data
if (data && Array.isArray(data)) {
categoryList.value = data.map(item => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : (item[1] || item[0])
}))
}
} catch (err) {
console.error('Error loading categories:', err)
}
}
// Load subcategories when category changes
const loadSubcategories = async () => {
if (!productCategory.value) {
subcategoryList.value = []
return
}
try {
const response = await axios.post('/Products/New/SubCategory/Datalist', {
category: productCategory.value
})
const data = response.data.subcategories || response.data
if (data && Array.isArray(data)) {
subcategoryList.value = data.map(item => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : (item[1] || item[0])
}))
}
} catch (err) {
console.error('Error loading subcategories:', err)
}
}
// Load product data
const loadProductData = async () => {
try {
isLoading.value = true
// Extract info from props first, then from URL
if (props.payload) {
productId.value = props.payload.product_hashkey || props.payload.product_hash || props.payload.target;
storeHash.value = props.payload.store_hashkey || props.payload.store_hash;
} else {
const urlParams = new URLSearchParams(window.location.search)
productId.value = props.target || urlParams.get('product_id') || urlParams.get('id') || urlParams.get('hashkey')
storeHash.value = urlParams.get('store_hash') || urlParams.get('store')
}
if (!productId.value) {
error.value = 'Product ID not found'
return
}
const response = await axios.post('/View/Product/Details/data', {
target: productId.value,
data: {
product_id: productId.value,
store_hash: storeHash.value
}
})
if (response.data && response.data.success && response.data.data) {
const product = response.data.data
productName.value = product.name || ''
productDescription.value = product.description || ''
productCategory.value = product.category || ''
productSubcategory.value = product.subcategory || ''
productPrice.value = product.price || 0
productUnitName.value = product.unitname || ''
productBarcode.value = product.barcode || ''
productAvailable.value = product.available || 0
// Load subcategories for the initial category
if (productCategory.value) {
await loadSubcategories()
productSubcategory.value = product.subcategory || ''
}
// Handle photos
if (product.photourlDropzone && Array.isArray(product.photourlDropzone)) {
const initialFiles = product.photourlDropzone.map(f => ({
file: { name: f.name || 'Image' },
hashkey: f.hashkey,
progress: 100,
uploading: false,
preview: f.url
}));
dropzoneFiles.value = initialFiles;
setInitialHashes(product.photourlDropzone.map(f => f.hashkey));
}
}
} catch (err) {
console.error('Error loading product data:', err)
error.value = 'Failed to load product data'
} finally {
isLoading.value = false
}
}
// Dropzone handling
const dropzoneRef = ref(null)
const dropzoneFiles = ref([])
// Watch for new files in dropzone and upload them
watch(() => dropzoneFiles.value, async (newFiles, oldFiles) => {
// Find files that are not yet uploading and don't have a hashkey
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
for (const fileObj of filesToUpload) {
const index = newFiles.indexOf(fileObj);
if (index === -1) continue;
// Set uploading status
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
const result = await uploadFile(fileObj.file);
if (result && result.hashkey) {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 100,
hashkey: result.hashkey
});
} else {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 0,
error: 'Upload failed'
});
}
}
}, { deep: true });
const handlePhotoRemoved = (hashkey) => {
removeHash(hashkey);
};
const handleCategoryChange = () => {
loadSubcategories()
}
const validateForm = () => {
if (!productName.value) {
error.value = 'Product name is required'
return false
}
if (!productCategory.value) {
error.value = 'Category is required'
return false
}
if (productPrice.value === null || productPrice.value === undefined || productPrice.value < 0) {
error.value = 'Valid price is required'
return false
}
error.value = null
return true
}
const handleSubmit = async () => {
if (!validateForm()) return
try {
isLoading.value = true
const response = await axios.post('/Products/Admin/Edit/', {
target: productId.value,
data: {
store_hash: storeHash.value
},
EditProductName: productName.value,
EditProductDescription: productDescription.value,
EditProductCategory: productCategory.value,
EditProductSubCategory: productSubcategory.value,
EditProductPrice: parseFloat(productPrice.value),
EditProductUnitName: productUnitName.value,
EditProductAvailable: parseInt(productAvailable.value),
EditProductBarcode: productBarcode.value,
status: true, // Assuming active if editing, can be bound to a checkbox if needed
photourl: dropzoneFiles.value
.filter(f => f.hashkey)
.map(f => f.hashkey)
})
if (response.data && response.data.success) {
successMessage.value = 'Product updated successfully!'
// Proactively prefetch products list
productStore.fetchProducts()
setTimeout(() => {
navigate({ page: 'ManageProductsAdmin' })
}, 1500)
} else {
error.value = response.data?.message || 'Failed to update product'
}
} catch (err) {
console.error('Error updating product:', err)
error.value = err.response?.data?.message || err.message || 'Failed to update product'
// Scroll to error if it occurs
window.scrollTo({ top: 0, behavior: 'smooth' })
} finally {
isLoading.value = false
}
}
</script>
<template>
<div class="edit-product-page pb-5">
<div class="tf-container mt-5 mb-4 text-center">
<h1 class="fw_8 premium-title">Edit Product</h1>
<p class="text-muted">Update your product details and availability</p>
</div>
<div v-if="successMessage" class="tf-container mb-4">
<div class="glass-alert alert-success animate-fade-in">
<i class="fas fa-check-circle me-2"></i>
{{ successMessage }}
</div>
</div>
<div v-if="error" class="tf-container mb-4">
<div class="glass-alert alert-danger animate-shake">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ error }}
</div>
</div>
<div class="tf-container">
<div class="form-grid" v-if="!isLoading || productName">
<!-- Left Column: Basic Info -->
<div class="form-section">
<CardSimple title="Product Details">
<div class="premium-input-group mb-4">
<label for="productName" class="form-label">Product Name <span class="required">*</span></label>
<input
type="text"
id="productName"
v-model="productName"
class="premium-input"
placeholder="Product Name"
>
</div>
<div class="premium-input-group mb-4">
<label for="productDescription" class="form-label">Description</label>
<textarea
id="productDescription"
v-model="productDescription"
class="premium-input"
rows="4"
placeholder="Description..."
></textarea>
</div>
<div class="row">
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="category" class="form-label">Category <span class="required">*</span></label>
<select
id="category"
v-model="productCategory"
class="premium-select"
@change="handleCategoryChange"
>
<option value="" disabled>Select Category</option>
<option v-for="cat in categoryList" :key="cat.value" :value="cat.value">
{{ cat.label }}
</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="subcategory" class="form-label">Subcategory</label>
<select
id="subcategory"
v-model="productSubcategory"
class="premium-select"
:disabled="subcategoryList.length === 0"
>
<option value="" disabled>Select Subcategory</option>
<option v-for="sub in subcategoryList" :key="sub.value" :value="sub.value">
{{ sub.label }}
</option>
</select>
</div>
</div>
</div>
</CardSimple>
</div>
<!-- Right Column: Inventory & Photos -->
<div class="form-section">
<CardSimple title="Inventory & Pricing">
<div class="row">
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="price" class="form-label">Price (PHP) <span class="required">*</span></label>
<input
type="number"
id="price"
v-model="productPrice"
class="premium-input"
step="0.01"
>
</div>
</div>
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="unit" class="form-label">Unit</label>
<input
type="text"
id="unit"
v-model="productUnitName"
class="premium-input"
placeholder="e.g., 25kg"
>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="available" class="form-label">Available Stock</label>
<input
type="number"
id="available"
v-model="productAvailable"
class="premium-input"
>
</div>
</div>
<div class="col-md-6">
<div class="premium-input-group mb-4">
<label for="barcode" class="form-label">Barcode</label>
<input
type="text"
id="barcode"
v-model="productBarcode"
class="premium-input"
maxlength="12"
>
</div>
</div>
</div>
<div class="premium-input-group mb-2">
<label class="form-label">Product Photos</label>
<Dropzone
ref="dropzoneRef"
v-model:files="dropzoneFiles"
@removed="handlePhotoRemoved"
/>
</div>
</CardSimple>
</div>
</div>
<div v-else class="text-center py-5">
<LoadingSpinner />
<p class="mt-2">Loading product details...</p>
</div>
<div class="action-bar mt-5 text-center">
<button
@click="handleSubmit"
:disabled="isLoading || successMessage || isFileUploading"
class="btn-premium-launch"
:class="{ 'btn-loading': isLoading }"
>
<span v-if="!isLoading">Update Product</span>
<LoadingSpinner v-else size="small" color="white" />
</button>
<div class="mt-4">
<button
@click="navigate({ page: 'ManageProductsAdmin' })"
class="btn-text"
>
<i class="fas fa-chevron-left me-2"></i> Cancel and Return
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.premium-title {
font-family: 'Outfit', sans-serif;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 992px) {
.form-grid {
grid-template-columns: 1fr;
}
}
.premium-input-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 600;
font-size: 0.9rem;
color: #475569;
margin-bottom: 8px;
display: flex;
align-items: center;
}
.required {
color: #ef4444;
margin-left: 4px;
}
.premium-input, .premium-select {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #fff;
font-size: 0.95rem;
transition: all 0.2s;
outline: none;
}
.premium-input:focus, .premium-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
.premium-select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
}
.glass-alert {
padding: 16px 20px;
border-radius: 16px;
backdrop-filter: blur(8px);
font-weight: 500;
display: flex;
align-items: center;
}
.alert-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #15803d;
}
.alert-danger {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #b91c1c;
}
.btn-premium-launch {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
border: none;
padding: 16px 48px;
border-radius: 14px;
font-weight: 700;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.3);
}
.btn-premium-launch:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(37, 99, 235, 0.4);
filter: brightness(1.1);
}
.btn-premium-launch:disabled {
background: #cbd5e1;
cursor: not-allowed;
box-shadow: none;
}
.btn-loading {
padding: 12px 48px;
background: #3b82f6;
}
.btn-text {
background: transparent;
border: none;
color: #64748b;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
}
.btn-text:hover {
color: #1e293b;
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
:global(.dark-mode) .premium-input, :global(.dark-mode) .premium-select {
background: #1e293b;
border-color: #334155;
color: #f8fafc;
}
:global(.dark-mode) .premium-title {
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
-webkit-background-clip: text;
background-clip: text;
}
:global(.dark-mode) .form-label {
color: #94a3b8;
}
</style>

View File

@@ -0,0 +1,500 @@
<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Edit Store Ultimate');
import { ref, computed, onMounted, watch } from 'vue';
import axios from 'axios';
import { useNavigate } from '../composables/Core/useNavigate.js';
import LoadingSpinner from '../Components/LoadingSpinner.vue';
import CardSimple from '../Components/Core/CardSimple.vue';
import Dropzone from '../Components/Core/Dropzone.vue';
import InputGroup from '../Components/Core/Forms/InputGroup.vue';
import InputGroupSelect from '../Components/Core/Forms/InputGroupSelect.vue';
import InputGroupButton from '../Components/Core/Forms/InputGroupButton.vue';
import InputGroupTextarea from '../Components/Core/Forms/InputGroupTextarea.vue';
import InputGroupCheckbox from '../Components/Core/Forms/InputGroupCheckbox.vue';
import { useFileUpload } from '../composables/useFileUpload.js';
import { usePrefetchStore } from '../stores/prefetch';
const prefetchStore = usePrefetchStore();
const props = defineProps({
target: { type: String, default: null }
});
const { navigate } = useNavigate();
const { uploadFile, removeHash, photoHashes, setInitialHashes } = useFileUpload({
category: 'StoreMarket',
maxSizeMB: 10
});
const storeId = ref(null);
const storeName = ref('');
const description = ref('');
const category = ref('');
const subcategory = ref('');
const subcategories = ref([]);
const address = ref('');
const remarks = ref('');
const owner = ref('');
const status = ref('active');
const managers = ref([]);
const cooperatives = ref([]);
const cooperativeOptions = ref([]);
const cooperativeSearch = ref('');
const loading = ref(false);
const error = ref(null);
const successMessage = ref('');
const users = ref([]);
const userSearch = ref('');
const categories = ref([]);
const filteredUsers = computed(() => {
if (!userSearch.value) return users.value;
return users.value.filter(u =>
u.name.toLowerCase().includes(userSearch.value.toLowerCase()) ||
u.username.toLowerCase().includes(userSearch.value.toLowerCase())
);
});
const filteredCooperatives = computed(() => {
if (!cooperativeSearch.value) return cooperativeOptions.value;
const q = cooperativeSearch.value.toLowerCase();
return cooperativeOptions.value.filter(c =>
(c.name || '').toLowerCase().includes(q) ||
(c.cooperative_type || '').toLowerCase().includes(q)
);
});
const fetchCooperatives = async () => {
try {
const response = await axios.post('/Store/Cooperatives/List', {});
if (response.data && response.data.success && Array.isArray(response.data.data)) {
cooperativeOptions.value = response.data.data;
}
} catch (error) {
console.error('Failed to fetch cooperatives:', error);
}
};
const fetchUsers = async () => {
try {
const response = await axios.post('/admin/user/list/numbers/hash', {});
if (response.data && Array.isArray(response.data)) {
users.value = response.data;
}
} catch (error) {
console.error('Failed to fetch users:', error);
}
};
const fetchCategories = async () => {
try {
const response = await axios.post('/Store/New/Category/Datalist', {});
if (response.data && Array.isArray(response.data)) {
categories.value = response.data.map(item => ({
label: typeof item === 'string' ? item : (item[1] || item[0]),
value: typeof item === 'string' ? item : item[0]
}));
}
} catch (error) {
console.error('Failed to fetch categories:', error);
}
};
const fetchSubcategories = async () => {
if (!category.value) {
subcategories.value = [];
return;
}
try {
const response = await axios.post('/Store/New/SubCategory/Datalist', {
category: category.value
});
if (response.data && Array.isArray(response.data)) {
subcategories.value = response.data.map(item => ({
label: typeof item === 'string' ? item : (item[1] || item[0]),
value: typeof item === 'string' ? item : item[0]
}));
}
} catch (error) {
console.error('Failed to fetch subcategories:', error);
}
};
const handleCategoryChange = () => {
subcategory.value = '';
fetchSubcategories();
};
const fetchStoreData = async () => {
const urlParams = new URLSearchParams(window.location.search);
const hashkey = props.target || urlParams.get('hashkey') || urlParams.get('id');
if (!hashkey) {
error.value = 'Store ID is missing';
return;
}
storeId.value = hashkey;
loading.value = true;
try {
const response = await axios.post('/Edit/Store/Details/data', { target: hashkey });
if (response.data) {
const data = response.data;
storeName.value = data.name || '';
description.value = data.description || '';
category.value = data.category || '';
subcategory.value = data.subcategory || '';
address.value = data.address || '';
owner.value = data.owner_hashkey || '';
status.value = data.status || 'active';
remarks.value = data.remarks || '';
managers.value = data.managers_hashkeys || [];
cooperatives.value = data.cooperative_hashkeys || [];
// Load subcategories for initial category
if (category.value) {
await fetchSubcategories();
subcategory.value = data.subcategory || '';
}
// Set initial photos in dropzone
if (data.photourlDropzone && Array.isArray(data.photourlDropzone)) {
// Dropzone component handles initial files via v-model:files
const initialFiles = data.photourlDropzone.map(f => ({
file: { name: f.name || 'Image' },
hashkey: f.hashkey,
progress: 100,
uploading: false,
preview: f.url // Assuming url is provided for preview
}));
dropzoneFiles.value = initialFiles;
// Also update the file upload composable state
setInitialHashes(data.photourlDropzone.map(f => f.hashkey));
}
}
} catch (err) {
console.error('Failed to fetch store data:', err);
error.value = 'Failed to load store data';
} finally {
loading.value = false;
}
};
const isButtonDisabled = computed(() => {
return !!(loading.value || successMessage.value || !storeName.value || !description.value || !address.value);
});
const dropzoneRef = ref(null);
const dropzoneFiles = ref([]);
// Logic moved to Dropzone component and its v-model
// Watch for new files in dropzone and upload them
watch(() => dropzoneFiles.value, async (newFiles) => {
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
for (const fileObj of filesToUpload) {
const index = newFiles.indexOf(fileObj);
if (index === -1) continue;
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
const result = await uploadFile(fileObj.file);
if (result && result.hashkey) {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 100,
hashkey: result.hashkey
});
} else {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 0,
error: 'Upload failed'
});
}
}
}, { deep: true });
const handlePhotoRemoved = (hashkey) => {
removeHash(hashkey);
};
const handleSubmit = async () => {
error.value = null;
successMessage.value = '';
if (!storeName.value || !description.value || !address.value) {
error.value = 'Please fill in all required fields';
return;
}
loading.value = true;
try {
const response = await axios.post('/Store/Edit', {
target: storeId.value,
name: storeName.value,
description: description.value,
address: address.value,
category: category.value,
subcategory: subcategory.value,
owner: owner.value || undefined,
managers: managers.value,
cooperatives: cooperatives.value,
status: status.value,
remarks: remarks.value,
photourl: dropzoneFiles.value
.filter(f => f.hashkey)
.map(f => f.hashkey)
});
if (response.data && response.data.success) {
successMessage.value = 'Store updated successfully!';
// Proactively prefetch stores list so ListStores shows fresh data immediately
try {
const listResp = await axios.post('/ListStores/List/data', {});
if (listResp.data) {
const fresh = listResp.data.props || listResp.data;
prefetchStore.setCache('POST:/ListStores/List/data:{}', fresh);
}
} catch (e) {
console.warn('Failed to prefetch stores list:', e);
}
setTimeout(() => {
navigate({ page: 'ViewStoreMarket', props: { target: storeId.value } });
}, 1500);
} else {
error.value = response.data?.message || 'Failed to update store';
}
} catch (err) {
console.error('Failed to update store:', err);
error.value = err.response?.data?.message || 'Failed to update store. Please try again.';
} finally {
loading.value = false;
}
};
onMounted(async () => {
await Promise.all([
fetchUsers(),
fetchCategories(),
fetchCooperatives(),
fetchStoreData()
]);
});
</script>
<template>
<div class="edit-store-page pb-5">
<div class="tf-container mt-5 mb-4 text-center">
<h1 class="fw_8 premium-title">Edit Your Store</h1>
<p class="text-muted">Update your store information and visuals</p>
</div>
<div v-if="successMessage" class="tf-container mb-4">
<div class="glass-alert alert-success animate-fade-in">
<i class="fas fa-check-circle me-2"></i>
{{ successMessage }}
</div>
</div>
<div v-if="error" class="tf-container mb-4">
<div class="glass-alert alert-danger animate-shake">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ error }}
</div>
</div>
<div class="tf-container">
<div class="form-grid">
<!-- Left Column: Basic Info -->
<div class="form-section">
<CardSimple title="Essential Information">
<InputGroup
id="storeName"
label="Store Name"
v-model="storeName"
required
placeholder="Enter store name"
/>
<div class="row">
<div class="col-md-6">
<InputGroupSelect
id="category"
label="Category"
v-model="category"
:options="categories.map(c => ({ value: c.value, text: c.label }))"
placeholder="Select a category"
@update:modelValue="handleCategoryChange"
/>
</div>
<div class="col-md-6">
<InputGroupSelect
id="subcategory"
label="Subcategory"
v-model="subcategory"
:options="subcategories.map(s => ({ value: s.value, text: s.label }))"
placeholder="Select subcategory"
:disabled="subcategories.length === 0"
/>
</div>
</div>
<InputGroupTextarea
id="description"
label="Description"
v-model="description"
required
:rows="5"
placeholder="Store description..."
/>
<InputGroupTextarea
id="remarks"
label="Internal Remarks"
v-model="remarks"
:rows="2"
placeholder="Any internal notes or remarks..."
/>
</CardSimple>
</div>
<!-- Right Column: Location & Photos -->
<div class="form-section">
<CardSimple title="Location & Visuals">
<InputGroupTextarea
id="address"
label="Address"
v-model="address"
required
:rows="2"
placeholder="Store address"
/>
<div class="row">
<div class="col-md-6">
<InputGroupSelect
id="owner"
label="Store Owner"
v-model="owner"
:options="users.map(u => ({ value: u.hashkey, text: `${u.name} (${u.username})` }))"
placeholder="Select owner"
/>
</div>
</div>
<!-- Multi-Manager Selection -->
<div class="premium-input-group mb-4">
<label class="form-label">Additional Store Managers</label>
<div class="multi-user-list glass-card p-3">
<div class="search-box mb-2">
<InputGroup
id="userSearch"
v-model="userSearch"
placeholder="Search users..."
variant="soft"
:isPremium="false"
/>
</div>
<div class="user-selection-area">
<div v-for="user in filteredUsers" :key="user.hashkey" class="user-item">
<InputGroupCheckbox
:id="`manager-${user.hashkey}`"
:label="`${user.name} (${user.username})`"
v-model="managers"
:value="user.hashkey"
/>
</div>
</div>
</div>
<p class="input-hint mt-2">Select one or more users to help manage this store.</p>
</div>
<!-- Cooperative Links (optional, many-to-many) -->
<div class="premium-input-group mb-4">
<label class="form-label">Linked Cooperatives</label>
<div class="multi-user-list glass-card p-3">
<div class="search-box mb-2">
<InputGroup
id="cooperativeSearch"
v-model="cooperativeSearch"
placeholder="Search cooperatives..."
variant="soft"
:isPremium="false"
/>
</div>
<div class="user-selection-area">
<div v-if="filteredCooperatives.length === 0" class="text-muted small p-2">
No cooperatives available.
</div>
<div v-for="coop in filteredCooperatives" :key="coop.hashkey" class="user-item">
<InputGroupCheckbox
:id="`coop-${coop.hashkey}`"
:label="`${coop.name}${coop.cooperative_type ? ' — ' + coop.cooperative_type : ''}`"
v-model="cooperatives"
:value="coop.hashkey"
/>
</div>
</div>
</div>
<p class="input-hint mt-2">Optional. Link this store to one or more cooperatives.</p>
</div>
<InputGroupSelect
id="status"
label="Store Status"
v-model="status"
:options="[
{ value: 'active', text: 'Active' },
{ value: 'inactive', text: 'Inactive' },
{ value: 'pending', text: 'Pending' },
{ value: 'suspended', text: 'Suspended' }
]"
/>
<div class="premium-input-group mb-2">
<label class="form-label">Store Photos</label>
<Dropzone
ref="dropzoneRef"
v-model:files="dropzoneFiles"
@removed="handlePhotoRemoved"
/>
</div>
</CardSimple>
</div>
</div>
<div class="action-bar mt-5 text-center d-flex flex-column align-items-center gap-3">
<InputGroupButton
text="Update Store"
:loading="loading"
:disabled="isButtonDisabled"
size="lg"
variant="primary"
@click="handleSubmit"
/>
<InputGroupButton
text="Cancel and Return"
variant="text"
@click="navigate({ page: 'ViewStoreMarket', props: { target: storeId } })"
>
<i class="fas fa-chevron-left me-2"></i> Cancel and Return
</InputGroupButton>
</div>
</div>
</div>
</template>
<style scoped>
:global(.dark-mode) .form-label {
color: #94a3b8;
}
</style>

View File

@@ -0,0 +1,477 @@
<script setup>
import { usePageTitle } from '../composables/Core/usePageTitle';
usePageTitle('Edit User');
import { ref, computed, onMounted, getCurrentInstance, watch } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import { useUserStore } from '../stores/user'
// No imports needed - we use native browser functions for decoding
const { navigate } = useNavigate()
import BackButton from '../Components/Core/BackButton.vue'
const modal = useModal()
const userStore = useUserStore()
// Form state
const mobileNumber = ref('')
const nickname = ref('')
const name = ref('')
const username = ref('')
const fullname = ref('')
const userType = ref('')
const parent = ref('');
// Validation states
const isMobileValid = ref(false)
const isUsernameValid = ref(false)
const isLoading = ref(false)
// Data lists
const userTypeList = ref([])
const parentList = ref([])
// Route parameters
const urlParams = new URLSearchParams(window.location.search)
const userId = ref(null) // Defined below in onMounted or from props
// Function to extract hashkey from URL path
function getHashkeyFromUrl() {
const pathParts = window.location.pathname.split('--h:')
if (pathParts.length >= 2) {
// The second part contains the encoded hashkey after '--h:'
return pathParts[1]
}
return null
}
// Function to extract and decode hashkey from URL path
function extractHashkeyFromUrl() {
// Get the full URL path
const urlPath = window.location.pathname
// Check for hash format: --h:HASHKEY (the hashkey is encoded)
if (!urlPath.includes('--h:')) return null
try {
// The URL format is /edituser--h:ENCODED_HASHKEY
// We need to extract the part after --h:
const parts = urlPath.split('--h:')
if (parts.length < 2) return null
const encodedHash = parts[1]
// Decode the hashkey - it was encoded as base64 in useUrlEncoder.js encodeHash
// The format is h:ENCODED_HASHKEY where ENCODED_HASHKEY is base64 encoded
if (!encodedHash) return null
// Remove 'h:' prefix if present and decode from base64
let base64 = encodedHash.startsWith('h:') ? encodedHash.substring(2) : encodedHash
try {
const decoded = atob(base64)
// Decode the URI components
const result = decodeURIComponent(decoded)
return result
} catch (e) {
console.error('[EditUser] Error decoding hashkey:', e)
return null
}
} catch (e) {
console.error('[EditUser] Error extracting hashkey from URL:', e)
return null
}
}
// Initialize component
onMounted(async () => {
document.title = 'Edit User'
const instance = getCurrentInstance()
const props = instance?.proxy?.$attrs || {}
// Check for hashkey in multiple places:
// 1. URL path (for direct URL access like /edituser--h:HASHKEY)
// 2. Props from navigation
// 3. URL query parameters
let targetId = extractHashkeyFromUrl()
if (!targetId) {
targetId = props.hashkey || props.id || urlParams.get('userId') || urlParams.get('id')
}
if (!targetId) {
console.error('User ID not found')
navigate({ page: 'UserList' })
return
}
userId.value = targetId
populateUserTypeList()
await populateParentList()
await loadUserData()
})
// Check if username exists (for validation)
const checkUsernameExists = async (usernameValue) => {
if (!usernameValue || usernameValue === '') {
isUsernameValid.value = true
return false
}
try {
isLoading.value = true
const response = await axios.post('/admin/user/username/exists', { username: usernameValue })
if (response.data && response.data.exists === true) {
isUsernameValid.value = false
return false
} else if (response.data.exists === false) {
isUsernameValid.value = true
return true
}
} catch (error) {
console.error('Error checking username existence:', error)
isUsernameValid.value = false
} finally {
isLoading.value = false
}
}
// Populate user type dropdown
const populateUserTypeList = async () => {
try {
const response = await axios.post('/admin/list/usertype/create', {})
if (response.data && Array.isArray(response.data)) {
userTypeList.value = response.data.map(item => ({
value: item[0],
label: item[1]
}))
}
} catch (error) {
console.error('Error populating user type list:', error)
}
}
// Populate parent dropdown
const populateParentList = async () => {
try {
const response = await axios.post('/admin/user/list/numbers/hash', {
exclude_user: userId.value
})
if (response.data && Array.isArray(response.data)) {
parentList.value = response.data.map(user => ({
value: user.hashkey,
label: `${user.name} (${user.mobile_number}) [${user.username}] ${user.fullname ?? ''}`
}))
}
} catch (error) {
console.error('Error populating parent list:', error)
}
}
// Load user data
const loadUserData = async () => {
if (!userId.value) return
try {
isLoading.value = true
const response = await axios.post('/admin/user/details', {
target_user: userId.value
})
// Handle the response - backend returns user data directly without success wrapper
let userData
// Check for different possible response formats
if (response.data && typeof response.data === 'object') {
// Response might be wrapped in 'user' key or direct object
userData = response.data.user || response.data
mobileNumber.value = userData.mobile_number || ''
nickname.value = userData.nickname || ''
name.value = userData.name || ''
username.value = userData.username || ''
fullname.value = userData.fullname || ''
userType.value = userData.acct_type || ''
// Use parent_hashkey from backend to directly set the dropdown value
if (userData.parent_hashkey) {
parent.value = userData.parent_hashkey
} else {
parent.value = ''
}
} else {
console.error('Failed to load user data')
}
} catch (error) {
console.error('Error loading user data:', error)
} finally {
isLoading.value = false
}
}
// Check if current user is ultimate account type
const isCurrentUserIdentityUltimate = computed(() => userStore.acctType === 'ult')
// PH-format check (09XXXXXXXXX). Used only to detect PH attempts that look wrong;
// non-PH values (e.g. internal codes like "777", foreign numbers) are allowed as-is.
const looksLikePhAttempt = (number) => {
const digits = String(number).replace(/\D+/g, '')
// 10+ digits starting with 9 or 0 or "63" — user is clearly trying to enter a PH mobile
return /^(0?9\d{0,}|639\d{0,})$/.test(digits) && digits.length >= 10
}
const hasValidMobileNumberFormat = (number) => {
const value = String(number || '').trim()
if (!value) return false
// Admins may store non-PH identifiers as-is.
if (isCurrentUserIdentityUltimate.value) return true
// For non-ultimate editors: if it looks like a PH attempt, enforce canonical 09XXXXXXXXX.
if (looksLikePhAttempt(value)) {
return /^09\d{9}$/.test(value)
}
// Otherwise (short codes, foreign numbers), accept as-is.
return true
}
const validateMobileNumberBeforeUpdate = () => {
if (!hasValidMobileNumberFormat(mobileNumber.value)) {
modal.open({
title: 'Error',
body: 'Please enter a valid Philippine mobile number (09XXXXXXXXX format) or a non-PH identifier.',
footer: null
})
return false
}
return true
}
// Show confirmation modal for update
const showConfirmationModal = () => {
// Validate mobile number format first
if (!validateMobileNumberBeforeUpdate()) {
return
}
modal.yesNoModal({
title: 'Update User?',
body: 'Are you sure you want to update this user?',
onYes: updateUser,
yesText: 'Update',
noText: 'Cancel'
})
}
// Update the user
const updateUser = async () => {
try {
isLoading.value = true
const response = await axios.post('/admin/user/details/update', {
target_user: userId.value,
details: {
mobile_number: mobileNumber.value,
nickname: nickname.value,
name: name.value,
username: username.value,
fullname: fullname.value,
type: userType.value,
parent: parent.value
}
})
if (response.data && response.data.success) {
showSuccessModal()
} else if (response.data === true) {
showSuccessModal()
} else {
showErrorModal('User was not updated.')
}
} catch (error) {
console.error('Update error:', error)
showErrorModal(error.response?.data?.message || error.response?.data || 'Error updating user')
} finally {
isLoading.value = false
}
}
// Show success modal with quick dismiss (auto-closes after delay)
const showSuccessModal = () => {
// First close any existing modal
modal.close()
// Proactively prefetch users list
userStore.fetchUsers()
// Open success modal without buttons (just title/body)
modal.open({
title: 'Success',
body: 'User Updated Successfully'
})
// Auto-dismiss after 2 seconds
setTimeout(() => {
modal.close()
navigate({ page: 'UserList' })
}, 2000)
}
// Show error modal
const showErrorModal = (message) => {
modal.open({
title: 'Failed',
body: `Error Updating User. ${message}`,
footer: null
})
}
</script>
<template>
<div class="edit-user-page pb-5">
<br><br>
<div class="tf-container">
<!-- Back Button -->
<div class="mb-4">
<BackButton to="UserList" />
</div>
<h2 class="fw_6 text-center mb-4">Edit User</h2>
<div class="card shadow-sm">
<div class="card-body">
<div class="row g-3">
<!-- Mobile Number -->
<div class="col-12">
<div class="input-group">
<span class="input-group-text" :class="isMobileValid ? 'bg-success' : ''">
<i v-if="isLoading && !isMobileValid" class="fas fa-spinner fa-spin"></i>
<i v-else-if="isMobileValid" class="fas fa-check text-white"></i>
</span>
<input
type="tel"
inputmode="tel"
id="usernumber"
class="form-control"
placeholder="Mobile Number (e.g., 09123456789)"
v-model.trim="mobileNumber"
:disabled="isLoading"
>
</div>
</div>
<!-- Nickname -->
<div class="col-12">
<div class="input-group">
<span class="input-group-text" :class="isUsernameValid ? 'bg-success' : ''"></span>
<input
type="text"
id="nickname"
class="form-control"
placeholder="Nick Name (Optional)"
v-model.trim="nickname"
:disabled="isLoading"
>
</div>
</div>
<!-- Name -->
<div class="col-12">
<input
type="text"
id="name"
class="form-control"
placeholder="Name"
v-model.trim="name"
:disabled="isLoading"
>
</div>
<!-- Username -->
<div class="col-12">
<div class="input-group">
<span class="input-group-text"></span>
<input
type="text"
id="username"
class="form-control"
placeholder="Username (Optional)"
v-model.trim="username"
:disabled="isLoading"
>
</div>
</div>
<!-- Fullname -->
<div class="col-12">
<input
type="text"
id="fullname"
class="form-control"
placeholder="Full Name"
v-model.trim="fullname"
:disabled="isLoading"
>
</div>
<!-- User Type -->
<div class="col-12">
<select
class="form-select"
id="usertype"
v-model="userType"
required
:disabled="isLoading || userTypeList.length === 0"
>
<option value="" disabled>Select User Type</option>
<option v-for="type in userTypeList" :key="type.value" :value="type.value">
{{ type.label }}
</option>
</select>
</div>
<!-- Parent -->
<div class="col-12">
<select
class="form-select"
id="userparent"
v-model="parent"
:disabled="isLoading || parentList.length === 0"
>
<option value="" disabled>Select Parent (Optional)</option>
<option v-for="parentUser in parentList" :key="parentUser.value" :value="parentUser.value">
{{ parentUser.label }}
</option>
</select>
</div>
<!-- Update Button -->
<div class="col-12">
<button
id="UpdateUserButton"
class="btn btn-primary w-100 py-2"
@click="showConfirmationModal"
:disabled="isLoading"
>
Update User
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.card {
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,304 @@
<script setup>
import { ref, onMounted, computed } from "vue";
import axios from "axios";
import { usePageTitle } from "../composables/Core/usePageTitle";
import { useNavigate } from "../composables/Core/useNavigate";
import { useModal } from "../composables/Core/useModal";
usePageTitle("Enroll Farmer");
const { navigate } = useNavigate();
const modal = useModal();
const props = defineProps({
target: String,
});
// Cooperative
const cooperative = ref(null);
const loadingCoop = ref(true);
// Search
const searchQuery = ref("");
const searchResults = ref([]);
const isSearching = ref(false);
// Selected User
const selectedUser = ref(null);
// Form
const form = ref({
farm_name: "",
farm_location: "",
main_crops: [],
});
const cropInput = ref("");
const isSaving = ref(false);
const fetchCooperative = async () => {
if (!props.target) return;
loadingCoop.value = true;
try {
const response = await axios.post("/Cooperatives/Get", {
hashkey: props.target,
});
if (response.data.success) {
cooperative.value = response.data.data;
}
} catch (error) {
console.error("Failed to fetch cooperative", error);
} finally {
loadingCoop.value = false;
}
};
const searchUsers = async () => {
if (searchQuery.value.length < 2) {
searchResults.value = [];
return;
}
isSearching.value = true;
try {
const response = await axios.post("/Farmers/List", {
search: searchQuery.value,
});
if (response.data.success) {
searchResults.value = response.data.data;
}
} catch (error) {
console.error("Search failed", error);
} finally {
isSearching.value = false;
}
};
const selectUser = (user) => {
selectedUser.value = user;
searchResults.value = [];
searchQuery.value = "";
};
const clearSelection = () => {
selectedUser.value = null;
};
const addCrop = () => {
if (
cropInput.value &&
!form.value.main_crops.includes(cropInput.value)
) {
form.value.main_crops.push(cropInput.value);
cropInput.value = "";
}
};
const removeCrop = (crop) => {
form.value.main_crops = form.value.main_crops.filter((c) => c !== crop);
};
const enrollFarmer = async () => {
if (!selectedUser.value) {
modal.open({ title: "Error", body: "Please select a user first." });
return;
}
isSaving.value = true;
try {
const response = await axios.post("/Farmers/Register", {
...form.value,
organization_hash: props.target,
});
if (response.data.success) {
modal.open({
title: "Success",
body: "Farmer enrolled successfully!",
onClose: () =>
navigate({ page: "CooperativeDetail", target: props.target }),
});
} else {
modal.open({
title: "Error",
body: response.data.message || "Enrollment failed. Please try again.",
});
}
} catch (error) {
console.error("Enrollment failed", error);
modal.open({
title: "Error",
body:
error.response?.data?.message ||
"Failed to enroll farmer. Please try again.",
});
} finally {
isSaving.value = false;
}
};
onMounted(fetchCooperative);
</script>
<template>
<div class="enroll-farmer pb-5">
<div class="tf-container mt-4">
<div class="mb-4">
<button
@click="navigate({ page: 'CooperativeDetail', target: target })"
class="btn btn-link text-decoration-none p-0 mb-2 text-primary"
>
<i class="fas fa-arrow-left me-1"></i> Back to Cooperative
</button>
<h3 class="fw_6 mb-0">Enroll Farmer</h3>
<p v-if="cooperative" class="text-muted">
Adding farmer to <strong>{{ cooperative.name }}</strong>
</p>
</div>
<div v-if="loadingCoop" class="text-center py-5">
<i class="fas fa-spinner fa-spin fa-2x text-primary"></i>
</div>
<div v-else>
<!-- User Selection -->
<div class="card border-0 shadow-sm rounded-20 p-4 mb-4">
<h5 class="fw_6 mb-3">1. Select User</h5>
<div v-if="!selectedUser">
<div class="position-relative">
<input
v-model="searchQuery"
@input="searchUsers"
type="text"
class="form-control rounded-pill"
placeholder="Search user by name or mobile..."
/>
<div
v-if="searchResults.length > 0"
class="search-results card border-0 shadow-sm mt-2 position-absolute w-100 z-1"
>
<div
v-for="user in searchResults"
:key="user.hashkey"
@click="selectUser(user)"
class="p-3 border-bottom cursor-pointer hover-bg"
>
<div class="fw-bold">
{{ user.fullname || user.firstname + " " + user.lastname }}
</div>
<small class="text-muted">{{ user.mobile }}</small>
</div>
</div>
<div v-if="isSearching" class="text-center mt-2">
<small class="text-muted"
><i class="fas fa-spinner fa-spin me-1"></i> Searching...</small
>
</div>
</div>
</div>
<div v-else class="d-flex align-items-center justify-content-between bg-light p-3 rounded-15">
<div>
<div class="fw-bold">
{{
selectedUser.fullname ||
selectedUser.firstname + " " + selectedUser.lastname
}}
</div>
<small class="text-muted">{{ selectedUser.mobile }}</small>
</div>
<button @click="clearSelection" class="btn btn-sm btn-outline-danger rounded-pill">
Change
</button>
</div>
</div>
<!-- Farmer Details -->
<div class="card border-0 shadow-sm rounded-20 p-4">
<h5 class="fw_6 mb-3">2. Farm Details</h5>
<div class="mb-3">
<label class="form-label small fw-bold">Farm Name</label>
<input
v-model="form.farm_name"
type="text"
class="form-control rounded-pill"
placeholder="Enter farm name"
/>
</div>
<div class="mb-3">
<label class="form-label small fw-bold">Farm Location</label>
<textarea
v-model="form.farm_location"
class="form-control rounded-15"
rows="2"
placeholder="Barangay, Municipality, Province"
></textarea>
</div>
<div class="mb-4">
<label class="form-label small fw-bold">Main Crops</label>
<div class="d-flex gap-2 mb-2">
<input
v-model="cropInput"
@keyup.enter="addCrop"
type="text"
class="form-control rounded-pill"
placeholder="e.g. Rice, Corn"
/>
<button
@click="addCrop"
type="button"
class="btn btn-primary rounded-pill px-4"
>
Add
</button>
</div>
<div class="d-flex flex-wrap gap-2">
<span
v-for="crop in form.main_crops"
:key="crop"
class="badge bg-light text-dark rounded-pill border px-3 py-2"
>
{{ crop }}
<i
@click="removeCrop(crop)"
class="fas fa-times ms-2 cursor-pointer text-danger"
></i>
</span>
</div>
</div>
<button
:disabled="isSaving || !selectedUser"
@click="enrollFarmer"
class="btn btn-primary w-100 rounded-pill py-3 fw-bold"
>
<span v-if="isSaving"
><i class="fas fa-spinner fa-spin me-2"></i> Enrolling...</span
>
<span v-else>Enroll Farmer</span>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.rounded-20 {
border-radius: 20px;
}
.rounded-15 {
border-radius: 15px;
}
.cursor-pointer {
cursor: pointer;
}
.hover-bg:hover {
background-color: #f8f9fa;
}
.search-results {
max-height: 200px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,122 @@
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import { usePageTitle } from '../composables/Core/usePageTitle';
import { useNavigate } from '../composables/Core/useNavigate';
import { useModal } from '../composables/Core/useModal';
import BackButton from '../Components/Core/BackButton.vue';
usePageTitle('Farmer Profile');
const { navigate } = useNavigate();
const modal = useModal();
const form = ref({
farm_name: '',
farm_location: '',
organization_hash: '',
main_crops: []
});
const organizations = ref([]);
const loading = ref(false);
const cropInput = ref('');
const fetchOrganizations = async () => {
try {
const response = await axios.post('/Organizations/List');
if (response.data.success) {
organizations.value = response.data.data;
}
} catch (error) {
console.error('Failed to fetch organizations');
}
};
const addCrop = () => {
if (cropInput.value && !form.value.main_crops.includes(cropInput.value)) {
form.value.main_crops.push(cropInput.value);
cropInput.value = '';
}
};
const handleSubmit = async () => {
loading.value = true;
try {
const response = await axios.post('/Farmers/Register', form.value);
if (response.data.success) {
modal.open({
title: 'Success',
body: 'Profile submitted for verification!',
onClose: () => navigate({ page: 'Home' })
});
}
} catch (error) {
modal.open({
title: 'Error',
body: 'Failed to register. Please try again.'
});
} finally {
loading.value = false;
}
};
onMounted(fetchOrganizations);
</script>
<template>
<div class="farmer-profile-edit pb-5">
<div class="tf-container mt-4">
<BackButton to="Home" />
<h3 class="fw_6 mb-4">Farmer Registration</h3>
<div class="card border-0 shadow-sm rounded-20 p-4 mb-4">
<form @submit.prevent="handleSubmit">
<div class="mb-3">
<label class="form-label small fw-bold">Farm Name</label>
<input v-model="form.farm_name" type="text" class="form-control rounded-pill" required placeholder="Enter farm name">
</div>
<div class="mb-3">
<label class="form-label small fw-bold">Farm Location</label>
<textarea v-model="form.farm_location" class="form-control rounded-15" rows="2" placeholder="Barangay, Municipality, Province"></textarea>
</div>
<div class="mb-3">
<label class="form-label small fw-bold">Cooperative/Organization</label>
<select v-model="form.organization_hash" class="form-select rounded-pill">
<option value="">None / Independent</option>
<option v-for="org in organizations" :key="org.hashkey" :value="org.hashkey">
{{ org.name }}
</option>
</select>
</div>
<div class="mb-4">
<label class="form-label small fw-bold">Main Crops</label>
<div class="d-flex gap-2 mb-2">
<input v-model="cropInput" @keyup.enter="addCrop" type="text" class="form-control rounded-pill" placeholder="e.g. Rice, Corn">
<button @click="addCrop" type="button" class="btn btn-primary rounded-pill px-4">Add</button>
</div>
<div class="d-flex flex-wrap gap-2">
<span v-for="crop in form.main_crops" :key="crop" class="badge bg-light text-dark rounded-pill border px-3 py-2">
{{ crop }}
<i @click="form.main_crops = form.main_crops.filter(c => c !== crop)" class="fas fa-times ms-2 cursor-pointer text-danger"></i>
</span>
</div>
</div>
<button :disabled="loading" type="submit" class="btn btn-primary w-100 rounded-pill py-3 fw-bold">
<span v-if="loading"><i class="fas fa-spinner fa-spin me-2"></i> Submitting...</span>
<span v-else>Submit for Verification</span>
</button>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.rounded-20 { border-radius: 20px; }
.cursor-pointer { cursor: pointer; }
</style>

View File

@@ -0,0 +1,214 @@
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
const props = defineProps({
orgHash: String
});
const documents = ref([]);
const isLoading = ref(false);
const fileInput = ref(null);
const revisionInput = ref(null);
const activeDocForRevision = ref(null);
const expandedHistory = ref({});
const fetchDocuments = async () => {
if (!props.orgHash) return;
isLoading.value = true;
try {
const response = await axios.post('/Cooperatives/Documents/List', { orgHash: props.orgHash });
if (response.data.success) {
documents.value = response.data.data;
}
} catch (error) {
console.error('Failed to fetch documents:', error);
} finally {
isLoading.value = false;
}
};
const triggerUpload = () => {
fileInput.value.click();
};
const handleFileUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('orgHash', props.orgHash);
formData.append('type', 'OTHERS');
try {
if (window.toastr) window.toastr.info('Uploading document...');
const response = await axios.post('/Cooperatives/Documents/Upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data.success) {
if (window.toastr) window.toastr.success('Document uploaded successfully');
fetchDocuments();
} else {
if (window.toastr) window.toastr.error(response.data.error || 'Upload failed');
}
} catch (error) {
if (window.toastr) window.toastr.error('Failed to upload document');
console.error(error);
} finally {
event.target.value = ''; // Reset input
}
};
const triggerRevision = (doc) => {
activeDocForRevision.value = doc;
revisionInput.value.click();
};
const handleRevisionUpload = async (event) => {
const file = event.target.files[0];
if (!file || !activeDocForRevision.value) return;
const formData = new FormData();
formData.append('file', file);
formData.append('parentHash', activeDocForRevision.value.hashkey);
formData.append('note', 'New version');
try {
if (window.toastr) window.toastr.info('Uploading revision...');
const response = await axios.post('/Cooperatives/Documents/Revise', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data.success) {
if (window.toastr) window.toastr.success('Revision uploaded successfully');
fetchDocuments();
} else {
if (window.toastr) window.toastr.error(response.data.error || 'Revision failed');
}
} catch (error) {
if (window.toastr) window.toastr.error('Failed to upload revision');
console.error(error);
} finally {
event.target.value = ''; // Reset input
activeDocForRevision.value = null;
}
};
const toggleHistory = (doc) => {
expandedHistory.value[doc.hashkey] = !expandedHistory.value[doc.hashkey];
};
const downloadDoc = (doc) => {
if (doc.url) {
window.open(doc.url, '_blank');
}
};
const getFileIcon = (type) => {
if (type === 'PDF') return 'fas fa-file-pdf';
if (['JPG', 'PNG', 'JPEG'].includes(type)) return 'fas fa-file-image';
return 'fas fa-file-alt';
};
const getIconBg = (type) => {
if (type === 'PDF') return 'bg-soft-danger text-danger';
if (['JPG', 'PNG', 'JPEG'].includes(type)) return 'bg-soft-primary text-primary';
return 'bg-soft-secondary text-secondary';
};
onMounted(() => {
fetchDocuments();
});
</script>
<template>
<div class="document-repository mt-3">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw_7 mb-0">Documents & Records</h5>
<div>
<input type="file" ref="fileInput" class="d-none" @change="handleFileUpload">
<button class="btn btn-primary rounded-pill btn-sm px-3 shadow-sm" @click="triggerUpload" :disabled="isLoading">
<i class="fas fa-upload me-1"></i> Upload Document
</button>
</div>
</div>
<div v-if="isLoading" class="text-center py-5">
<div class="spinner-border text-primary spinner-border-sm" role="status"></div>
<p class="text-muted smallest mt-2">Loading documents...</p>
</div>
<div v-else-if="documents.length === 0" class="text-center py-5 bg-light rounded-20 opacity-75">
<i class="fas fa-folder-open fa-3x text-muted mb-3 opacity-25"></i>
<p class="text-muted mb-0">No documents found</p>
<p class="smallest text-muted">Upload important files for this organization</p>
</div>
<div v-else class="document-list">
<input type="file" ref="revisionInput" class="d-none" @change="handleRevisionUpload">
<div v-for="doc in documents" :key="doc.hashkey" class="mb-3">
<div class="card border-0 shadow-sm rounded-20 p-3 hover-card" @click="downloadDoc(doc)">
<div class="d-flex align-items-center gap-3">
<div :class="[getIconBg(doc.type), 'rounded-circle p-2 flex-shrink-0']" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;">
<i :class="getFileIcon(doc.type)"></i>
</div>
<div class="flex-grow-1 overflow-hidden">
<h6 class="fw_6 mb-1 text-truncate">{{ doc.name }}</h6>
<div class="d-flex flex-wrap gap-2 text-muted smallest">
<span class="badge bg-light text-dark rounded-pill px-2">V{{ doc.version }}</span>
<span>{{ doc.date }}</span>
<span>{{ doc.size }}</span>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-primary rounded-pill px-3" @click.stop="triggerRevision(doc)">
<i class="fas fa-edit me-1"></i> Revise
</button>
<button v-if="doc.history && doc.history.length > 1"
class="btn btn-icon btn-light rounded-circle shadow-sm flex-shrink-0"
style="width: 32px; height: 32px;"
:class="{'rotate-180': expandedHistory[doc.hashkey]}"
@click.stop="toggleHistory(doc)">
<i class="fas fa-chevron-down smallest"></i>
</button>
<button class="btn btn-icon btn-primary rounded-circle shadow-sm flex-shrink-0"
style="width: 36px; height: 36px;"
@click.stop="downloadDoc(doc)">
<i class="fas fa-download small"></i>
</button>
</div>
</div>
</div>
<!-- History Section -->
<div v-if="expandedHistory[doc.hashkey]" class="history-list mt-2 ms-4 border-start ps-3">
<div v-for="h in doc.history.slice(1)" :key="h.hashkey" class="history-item d-flex align-items-center gap-2 mb-2 p-2 bg-light rounded-15" @click="downloadDoc(h)">
<span class="smallest fw_6 text-muted">V{{ h.version }}</span>
<div class="flex-grow-1 overflow-hidden">
<p class="smallest mb-0 text-truncate">{{ h.name }}</p>
<p class="smallest text-muted mb-0">{{ h.date }} {{ h.note || 'No note' }}</p>
</div>
<i class="fas fa-download smallest text-muted"></i>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.rounded-20 { border-radius: 20px; }
.rounded-15 { border-radius: 15px; }
.bg-soft-danger { background-color: rgba(220, 53, 69, 0.1); }
.bg-soft-primary { background-color: rgba(13, 110, 253, 0.1); }
.bg-soft-secondary { background-color: rgba(108, 117, 125, 0.1); }
.hover-card { cursor: pointer; transition: all 0.2s; }
.hover-card:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(0,0,0,0.1) !important; }
.smallest { font-size: 0.75rem; }
.rotate-180 { transform: rotate(180deg); }
.history-item { cursor: pointer; transition: background 0.2s; }
.history-item:hover { background-color: #e9ecef !important; }
</style>

Some files were not shown because too many files have changed in this diff Show More