initial: bootstrap from BukidBountyApp base
This commit is contained in:
149
resources/js/Components/Core/Animations/AnimatedButton.vue
Normal file
149
resources/js/Components/Core/Animations/AnimatedButton.vue
Normal 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>
|
||||
83
resources/js/Components/Core/Animations/LottiePlayer.vue
Normal file
83
resources/js/Components/Core/Animations/LottiePlayer.vue
Normal 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>
|
||||
44
resources/js/Components/Core/Animations/RouteTransition.vue
Normal file
44
resources/js/Components/Core/Animations/RouteTransition.vue
Normal 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>
|
||||
124
resources/js/Components/Core/BackButton.vue
Normal file
124
resources/js/Components/Core/BackButton.vue
Normal 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>
|
||||
97
resources/js/Components/Core/BaseModal.vue
Normal file
97
resources/js/Components/Core/BaseModal.vue
Normal 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>
|
||||
104
resources/js/Components/Core/CardSimple.vue
Normal file
104
resources/js/Components/Core/CardSimple.vue
Normal 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>
|
||||
115
resources/js/Components/Core/ConfirmModal.vue
Normal file
115
resources/js/Components/Core/ConfirmModal.vue
Normal 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>
|
||||
423
resources/js/Components/Core/Dropzone.vue
Normal file
423
resources/js/Components/Core/Dropzone.vue
Normal 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>
|
||||
38
resources/js/Components/Core/FileImage.vue
Normal file
38
resources/js/Components/Core/FileImage.vue
Normal 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>
|
||||
184
resources/js/Components/Core/Forms/InputGroup.vue
Normal file
184
resources/js/Components/Core/Forms/InputGroup.vue
Normal 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>
|
||||
162
resources/js/Components/Core/Forms/InputGroupButton.vue
Normal file
162
resources/js/Components/Core/Forms/InputGroupButton.vue
Normal 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>
|
||||
168
resources/js/Components/Core/Forms/InputGroupCheckbox.vue
Normal file
168
resources/js/Components/Core/Forms/InputGroupCheckbox.vue
Normal 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>
|
||||
51
resources/js/Components/Core/Forms/InputGroupFileUpload.vue
Normal file
51
resources/js/Components/Core/Forms/InputGroupFileUpload.vue
Normal 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>
|
||||
48
resources/js/Components/Core/Forms/InputGroupNumber.vue
Normal file
48
resources/js/Components/Core/Forms/InputGroupNumber.vue
Normal 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>
|
||||
191
resources/js/Components/Core/Forms/InputGroupSelect.vue
Normal file
191
resources/js/Components/Core/Forms/InputGroupSelect.vue
Normal 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>
|
||||
133
resources/js/Components/Core/Forms/InputGroupTextarea.vue
Normal file
133
resources/js/Components/Core/Forms/InputGroupTextarea.vue
Normal 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>
|
||||
34
resources/js/Components/Core/IconImage.vue
Normal file
34
resources/js/Components/Core/IconImage.vue
Normal 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>
|
||||
42
resources/js/Components/Core/Layouts/Buttons/BaseButton.vue
Normal file
42
resources/js/Components/Core/Layouts/Buttons/BaseButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
11
resources/js/Components/Core/Layouts/Row/Col.vue
Normal file
11
resources/js/Components/Core/Layouts/Row/Col.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="col" :class="colClass">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
colClass: { type: String, default: '' }
|
||||
})
|
||||
</script>
|
||||
21
resources/js/Components/Core/Layouts/Row/DualColRow.vue
Normal file
21
resources/js/Components/Core/Layouts/Row/DualColRow.vue
Normal 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>
|
||||
22
resources/js/Components/Core/Layouts/Row/Row.vue
Normal file
22
resources/js/Components/Core/Layouts/Row/Row.vue
Normal 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>
|
||||
130
resources/js/Components/Core/Search/SearchBar.vue
Normal file
130
resources/js/Components/Core/Search/SearchBar.vue
Normal 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>
|
||||
133
resources/js/Components/Core/Search/SearchableList.vue
Normal file
133
resources/js/Components/Core/Search/SearchableList.vue
Normal 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>
|
||||
135
resources/js/Components/Core/Search/SearchableListItem.vue
Normal file
135
resources/js/Components/Core/Search/SearchableListItem.vue
Normal 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>
|
||||
116
resources/js/Components/Core/SearchableTable.vue
Normal file
116
resources/js/Components/Core/SearchableTable.vue
Normal 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>
|
||||
206
resources/js/Components/Core/SearchableTableWrapper.vue
Normal file
206
resources/js/Components/Core/SearchableTableWrapper.vue
Normal 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>
|
||||
31
resources/js/Components/Core/Services/ListArrowButton.vue
Normal file
31
resources/js/Components/Core/Services/ListArrowButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
20
resources/js/Components/Core/Services/ServiceButton.vue
Normal file
20
resources/js/Components/Core/Services/ServiceButton.vue
Normal 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>
|
||||
48
resources/js/Components/Core/Services/ServiceButtonGrid.vue
Normal file
48
resources/js/Components/Core/Services/ServiceButtonGrid.vue
Normal 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>
|
||||
60
resources/js/Components/Core/Services/SideTextButton.vue
Normal file
60
resources/js/Components/Core/Services/SideTextButton.vue
Normal 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>
|
||||
32
resources/js/Components/Core/Services/SideTextButtonList.vue
Normal file
32
resources/js/Components/Core/Services/SideTextButtonList.vue
Normal 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>
|
||||
66
resources/js/Components/Core/Skeleton/HomeSkeleton.vue
Normal file
66
resources/js/Components/Core/Skeleton/HomeSkeleton.vue
Normal 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>
|
||||
@@ -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>
|
||||
17
resources/js/Components/Core/Skeleton/SkeletonAvatar.vue
Normal file
17
resources/js/Components/Core/Skeleton/SkeletonAvatar.vue
Normal 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>
|
||||
68
resources/js/Components/Core/Skeleton/SkeletonBlock.vue
Normal file
68
resources/js/Components/Core/Skeleton/SkeletonBlock.vue
Normal 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>
|
||||
38
resources/js/Components/Core/Skeleton/SkeletonCard.vue
Normal file
38
resources/js/Components/Core/Skeleton/SkeletonCard.vue
Normal 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>
|
||||
44
resources/js/Components/Core/Skeleton/SkeletonStats.vue
Normal file
44
resources/js/Components/Core/Skeleton/SkeletonStats.vue
Normal 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>
|
||||
35
resources/js/Components/Core/Skeleton/SkeletonTable.vue
Normal file
35
resources/js/Components/Core/Skeleton/SkeletonTable.vue
Normal 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>
|
||||
28
resources/js/Components/Core/Skeleton/SkeletonText.vue
Normal file
28
resources/js/Components/Core/Skeleton/SkeletonText.vue
Normal 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>
|
||||
15
resources/js/Components/Core/Skeleton/StoreListSkeleton.vue
Normal file
15
resources/js/Components/Core/Skeleton/StoreListSkeleton.vue
Normal 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>
|
||||
@@ -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>
|
||||
37
resources/js/Components/Core/Stats/BalanceBox.vue
Normal file
37
resources/js/Components/Core/Stats/BalanceBox.vue
Normal 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>
|
||||
19
resources/js/Components/Core/Stats/CardStatsDetails.vue
Normal file
19
resources/js/Components/Core/Stats/CardStatsDetails.vue
Normal 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>
|
||||
24
resources/js/Components/Core/Stats/StatsDetailsRow.vue
Normal file
24
resources/js/Components/Core/Stats/StatsDetailsRow.vue
Normal 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>
|
||||
29
resources/js/Components/Core/Stats/WalletFooter.vue
Normal file
29
resources/js/Components/Core/Stats/WalletFooter.vue
Normal 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>
|
||||
35
resources/js/Components/Core/Stats/WalletFooterItem.vue
Normal file
35
resources/js/Components/Core/Stats/WalletFooterItem.vue
Normal 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>
|
||||
151
resources/js/Components/Core/StockPhotoPicker.vue
Normal file
151
resources/js/Components/Core/StockPhotoPicker.vue
Normal 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>
|
||||
80
resources/js/Components/Core/TableDensityToggle.vue
Normal file
80
resources/js/Components/Core/TableDensityToggle.vue
Normal 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>
|
||||
196
resources/js/Components/GlobalAnnouncement.vue
Normal file
196
resources/js/Components/GlobalAnnouncement.vue
Normal 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>
|
||||
43
resources/js/Components/LoadingSpinner.vue
Normal file
43
resources/js/Components/LoadingSpinner.vue
Normal 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>
|
||||
259
resources/js/Components/Market/PosHistoryCard.vue
Normal file
259
resources/js/Components/Market/PosHistoryCard.vue
Normal 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>
|
||||
93
resources/js/Components/Market/PosHistoryList.vue
Normal file
93
resources/js/Components/Market/PosHistoryList.vue
Normal 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>
|
||||
108
resources/js/Components/Market/PosTodayStats.vue
Normal file
108
resources/js/Components/Market/PosTodayStats.vue
Normal 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>
|
||||
138
resources/js/Components/Market/ProductCard.vue
Normal file
138
resources/js/Components/Market/ProductCard.vue
Normal 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>
|
||||
128
resources/js/Components/Market/StoreCard.vue
Normal file
128
resources/js/Components/Market/StoreCard.vue
Normal 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>
|
||||
378
resources/js/Components/Market/UpdateProductModal.vue
Normal file
378
resources/js/Components/Market/UpdateProductModal.vue
Normal 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>
|
||||
150
resources/js/Components/SystemBroadcast.vue
Normal file
150
resources/js/Components/SystemBroadcast.vue
Normal 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>
|
||||
261
resources/js/Components/Ultimate/ApiTokensPanel.vue
Normal file
261
resources/js/Components/Ultimate/ApiTokensPanel.vue
Normal 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.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>
|
||||
194
resources/js/Components/Ultimate/UltimateQueryModal.vue
Normal file
194
resources/js/Components/Ultimate/UltimateQueryModal.vue
Normal 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>
|
||||
|
||||
1202
resources/js/Pages/AccountSettings.vue
Normal file
1202
resources/js/Pages/AccountSettings.vue
Normal file
File diff suppressed because it is too large
Load Diff
511
resources/js/Pages/AccountingDashboard.vue
Normal file
511
resources/js/Pages/AccountingDashboard.vue
Normal 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>
|
||||
466
resources/js/Pages/AddProductsToStore.vue
Normal file
466
resources/js/Pages/AddProductsToStore.vue
Normal 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>
|
||||
401
resources/js/Pages/AddTransaction.vue
Normal file
401
resources/js/Pages/AddTransaction.vue
Normal 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>
|
||||
|
||||
|
||||
183
resources/js/Pages/AssignChapterOfficer.vue
Normal file
183
resources/js/Pages/AssignChapterOfficer.vue
Normal 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>
|
||||
922
resources/js/Pages/AssignProductToStore.vue
Normal file
922
resources/js/Pages/AssignProductToStore.vue
Normal 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>
|
||||
274
resources/js/Pages/Auth/Login.vue
Normal file
274
resources/js/Pages/Auth/Login.vue
Normal 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>
|
||||
206
resources/js/Pages/BatchAddCooperativeMembers.vue
Normal file
206
resources/js/Pages/BatchAddCooperativeMembers.vue
Normal 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>
|
||||
201
resources/js/Pages/BatchAddCooperatives.vue
Normal file
201
resources/js/Pages/BatchAddCooperatives.vue
Normal 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>
|
||||
774
resources/js/Pages/BatchAddProducts.vue
Normal file
774
resources/js/Pages/BatchAddProducts.vue
Normal 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>
|
||||
183
resources/js/Pages/BatchAddStores.vue
Normal file
183
resources/js/Pages/BatchAddStores.vue
Normal 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>
|
||||
183
resources/js/Pages/BatchAddUsers.vue
Normal file
183
resources/js/Pages/BatchAddUsers.vue
Normal 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>
|
||||
323
resources/js/Pages/BuyViewProductMarket.vue
Normal file
323
resources/js/Pages/BuyViewProductMarket.vue
Normal 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>
|
||||
366
resources/js/Pages/CartProductMarket.vue
Normal file
366
resources/js/Pages/CartProductMarket.vue
Normal 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>
|
||||
145
resources/js/Pages/ChapterOrgChart.vue
Normal file
145
resources/js/Pages/ChapterOrgChart.vue
Normal 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>
|
||||
112
resources/js/Pages/CoopMemberSearch.vue
Normal file
112
resources/js/Pages/CoopMemberSearch.vue
Normal 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>
|
||||
238
resources/js/Pages/CooperativeDetail.vue
Normal file
238
resources/js/Pages/CooperativeDetail.vue
Normal 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>
|
||||
72
resources/js/Pages/CooperativeHub.vue
Normal file
72
resources/js/Pages/CooperativeHub.vue
Normal 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>
|
||||
216
resources/js/Pages/CooperativeList.vue
Normal file
216
resources/js/Pages/CooperativeList.vue
Normal 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>
|
||||
493
resources/js/Pages/CooperativeMemberRegister.vue
Normal file
493
resources/js/Pages/CooperativeMemberRegister.vue
Normal 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>
|
||||
137
resources/js/Pages/Core/Fragments/BottomNav.vue
Normal file
137
resources/js/Pages/Core/Fragments/BottomNav.vue
Normal 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>
|
||||
227
resources/js/Pages/Core/Fragments/TopHeader.vue
Normal file
227
resources/js/Pages/Core/Fragments/TopHeader.vue
Normal 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>
|
||||
120
resources/js/Pages/Core/NotFound.vue
Normal file
120
resources/js/Pages/Core/NotFound.vue
Normal 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>
|
||||
166
resources/js/Pages/CreateChapter.vue
Normal file
166
resources/js/Pages/CreateChapter.vue
Normal 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>
|
||||
198
resources/js/Pages/CreateCoopUser.vue
Normal file
198
resources/js/Pages/CreateCoopUser.vue
Normal 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>
|
||||
364
resources/js/Pages/CreateCooperative.vue
Normal file
364
resources/js/Pages/CreateCooperative.vue
Normal 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>
|
||||
289
resources/js/Pages/CreateOrganization.vue
Normal file
289
resources/js/Pages/CreateOrganization.vue
Normal 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>
|
||||
880
resources/js/Pages/CreateProductStoreOwner.vue
Normal file
880
resources/js/Pages/CreateProductStoreOwner.vue
Normal 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>
|
||||
954
resources/js/Pages/CreateProductUltimate.vue
Normal file
954
resources/js/Pages/CreateProductUltimate.vue
Normal 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 & 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>
|
||||
|
||||
733
resources/js/Pages/CreateStore.vue
Normal file
733
resources/js/Pages/CreateStore.vue
Normal 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>
|
||||
687
resources/js/Pages/CreateUser.vue
Normal file
687
resources/js/Pages/CreateUser.vue
Normal 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>
|
||||
617
resources/js/Pages/EditProductUltimate.vue
Normal file
617
resources/js/Pages/EditProductUltimate.vue
Normal 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>
|
||||
500
resources/js/Pages/EditStoreUltimate.vue
Normal file
500
resources/js/Pages/EditStoreUltimate.vue
Normal 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>
|
||||
477
resources/js/Pages/EditUser.vue
Normal file
477
resources/js/Pages/EditUser.vue
Normal 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>
|
||||
304
resources/js/Pages/EnrollFarmer.vue
Normal file
304
resources/js/Pages/EnrollFarmer.vue
Normal 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>
|
||||
122
resources/js/Pages/FarmerProfileEdit.vue
Normal file
122
resources/js/Pages/FarmerProfileEdit.vue
Normal 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>
|
||||
214
resources/js/Pages/Fragments/DocumentRepository.vue
Normal file
214
resources/js/Pages/Fragments/DocumentRepository.vue
Normal 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
Reference in New Issue
Block a user