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>
|
||||
|
||||
Reference in New Issue
Block a user