Intégration fonctionnalites V1

This commit is contained in:
bastien
2026-07-04 22:30:18 +02:00
parent 55a558b536
commit 10ee859602
54 changed files with 4802 additions and 1018 deletions
@@ -0,0 +1,67 @@
<script setup>
import DangerButton from '@/Components/DangerButton.vue';
import Modal from '@/Components/Modal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
defineProps({
show: {
type: Boolean,
default: false,
},
title: {
type: String,
default: 'Confirmer la suppression',
},
message: {
type: String,
required: true,
},
confirmLabel: {
type: String,
default: 'Supprimer',
},
warning: {
type: String,
default: 'Cette action est irréversible.',
},
processing: {
type: Boolean,
default: false,
},
danger: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['close', 'confirm']);
</script>
<template>
<Modal :show="show" max-width="md" @close="emit('close')">
<div class="p-6">
<h2 class="text-lg font-semibold text-slate-900">
{{ title }}
</h2>
<p class="mt-2 text-sm text-slate-600">
{{ message }}
</p>
<p v-if="warning" class="mt-2 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">
{{ warning }}
</p>
<div class="mt-6 flex justify-end gap-3">
<SecondaryButton type="button" :disabled="processing" @click="emit('close')">
Annuler
</SecondaryButton>
<DangerButton v-if="danger" type="button" :disabled="processing" @click="emit('confirm')">
{{ confirmLabel }}
</DangerButton>
<PrimaryButton v-else type="button" :disabled="processing" @click="emit('confirm')">
{{ confirmLabel }}
</PrimaryButton>
</div>
</div>
</Modal>
</template>
+38 -22
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
const props = defineProps({
align: {
@@ -16,47 +16,65 @@ const props = defineProps({
},
});
const open = ref(false);
const dropdownRef = ref(null);
const closeOnEscape = (e) => {
if (open.value && e.key === 'Escape') {
open.value = false;
}
};
onMounted(() => document.addEventListener('keydown', closeOnEscape));
onUnmounted(() => document.removeEventListener('keydown', closeOnEscape));
const closeOnClickOutside = (e) => {
if (open.value && dropdownRef.value && !dropdownRef.value.contains(e.target)) {
open.value = false;
}
};
watch(open, (isOpen) => {
if (isOpen) {
document.addEventListener('keydown', closeOnEscape);
document.addEventListener('click', closeOnClickOutside);
} else {
document.removeEventListener('keydown', closeOnEscape);
document.removeEventListener('click', closeOnClickOutside);
}
});
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape);
document.removeEventListener('click', closeOnClickOutside);
});
const widthClass = computed(() => {
return {
const widths = {
48: 'w-48',
}[props.width.toString()];
56: 'w-56',
64: 'w-64',
72: 'w-72',
};
return widths[props.width.toString()] ?? 'w-48';
});
const alignmentClasses = computed(() => {
if (props.align === 'left') {
return 'ltr:origin-top-left rtl:origin-top-right start-0';
} else if (props.align === 'right') {
return 'ltr:origin-top-right rtl:origin-top-left end-0';
} else {
return 'origin-top';
}
});
if (props.align === 'right') {
return 'ltr:origin-top-right rtl:origin-top-left end-0';
}
const open = ref(false);
return 'origin-top';
});
</script>
<template>
<div class="relative">
<div @click="open = !open">
<div ref="dropdownRef" class="relative">
<div @click.stop="open = !open">
<slot name="trigger" />
</div>
<!-- Full Screen Dropdown Overlay -->
<div
v-show="open"
class="fixed inset-0 z-40"
@click="open = false"
></div>
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 scale-95"
@@ -69,8 +87,6 @@ const open = ref(false);
v-show="open"
class="absolute z-50 mt-2 rounded-md shadow-lg"
:class="[widthClass, alignmentClasses]"
style="display: none"
@click="open = false"
>
<div
class="rounded-md ring-1 ring-black ring-opacity-5"
+7
View File
@@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-hidden="true">
<path
d="M18 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2ZM8 4h8v2H8V4Zm-2 4h12v12H6V8Zm6 4a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z"
/>
</svg>
</template>
+1
View File
@@ -70,6 +70,7 @@ const maxWidthClass = computed(() => {
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
}[props.maxWidth];
});
</script>
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<button
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"
class="inline-flex items-center rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:ring-offset-2 active:bg-indigo-800 disabled:opacity-50"
>
<slot />
</button>
+1 -1
View File
@@ -10,7 +10,7 @@ defineProps({
<template>
<button
:type="type"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25"
class="inline-flex items-center rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:ring-offset-2 disabled:opacity-50"
>
<slot />
</button>
@@ -0,0 +1,162 @@
<script setup>
import { filterInputClass } from '@/Components/Supervisor/ui.js';
import { onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps({
address: {
type: String,
default: '',
},
city: {
type: String,
default: '',
},
zipCode: {
type: String,
default: '',
},
required: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:address', 'update:city', 'update:zipCode']);
const root = ref(null);
const query = ref(props.address ?? '');
const suggestions = ref([]);
const isOpen = ref(false);
const isLoading = ref(false);
const activeIndex = ref(-1);
let debounceTimer = null;
watch(
() => props.address,
(value) => {
if (value !== query.value) {
query.value = value ?? '';
}
},
);
const closeSuggestions = () => {
isOpen.value = false;
activeIndex.value = -1;
};
const searchAddresses = async () => {
if (query.value.trim().length < 3) {
suggestions.value = [];
closeSuggestions();
return;
}
isLoading.value = true;
try {
const response = await window.axios.get(route('supervisor.organizations.addresses.search'), {
params: { q: query.value.trim() },
});
suggestions.value = response.data ?? [];
isOpen.value = suggestions.value.length > 0;
activeIndex.value = -1;
} catch {
suggestions.value = [];
closeSuggestions();
} finally {
isLoading.value = false;
}
};
const onInput = () => {
emit('update:address', query.value);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(searchAddresses, 300);
};
const selectSuggestion = (suggestion) => {
query.value = suggestion.address;
emit('update:address', suggestion.address);
emit('update:city', suggestion.city ?? '');
emit('update:zipCode', suggestion.zip_code ?? '');
closeSuggestions();
};
const onKeydown = (event) => {
if (!isOpen.value || suggestions.value.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
activeIndex.value = (activeIndex.value + 1) % suggestions.value.length;
} else if (event.key === 'ArrowUp') {
event.preventDefault();
activeIndex.value = activeIndex.value <= 0
? suggestions.value.length - 1
: activeIndex.value - 1;
} else if (event.key === 'Enter' && activeIndex.value >= 0) {
event.preventDefault();
selectSuggestion(suggestions.value[activeIndex.value]);
} else if (event.key === 'Escape') {
closeSuggestions();
}
};
const onClickOutside = (event) => {
if (root.value && !root.value.contains(event.target)) {
closeSuggestions();
}
};
onMounted(() => document.addEventListener('click', onClickOutside));
onUnmounted(() => {
document.removeEventListener('click', onClickOutside);
clearTimeout(debounceTimer);
});
</script>
<template>
<div ref="root" class="relative">
<input
v-model="query"
type="text"
autocomplete="off"
placeholder="Rechercher une adresse…"
:required="required"
:class="filterInputClass"
@input="onInput"
@focus="searchAddresses"
@keydown="onKeydown"
/>
<p v-if="isLoading" class="mt-1 text-xs text-slate-400">
Recherche en cours
</p>
<ul
v-if="isOpen && suggestions.length > 0"
class="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-xl border border-slate-200 bg-white py-1 shadow-lg"
>
<li
v-for="(suggestion, index) in suggestions"
:key="`${suggestion.label}-${index}`"
>
<button
type="button"
class="block w-full px-3 py-2 text-left text-sm transition hover:bg-indigo-50"
:class="index === activeIndex ? 'bg-indigo-50 text-indigo-700' : 'text-slate-700'"
@mousedown.prevent="selectSuggestion(suggestion)"
>
<span class="block font-medium">{{ suggestion.address }}</span>
<span class="block text-xs text-slate-400">
{{ [suggestion.zip_code, suggestion.city].filter(Boolean).join(' ') }}
</span>
</button>
</li>
</ul>
</div>
</template>
@@ -0,0 +1,23 @@
<script setup>
defineProps({
type: {
type: String,
default: 'error',
},
});
const styles = {
error: 'border-red-200 bg-red-50 text-red-800',
success: 'border-emerald-200 bg-emerald-50 text-emerald-800',
info: 'border-sky-200 bg-sky-50 text-sky-800',
};
</script>
<template>
<div
class="mb-4 flex items-start gap-2 rounded-xl border px-4 py-3 text-sm"
:class="styles[type] ?? styles.error"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cardClass } from '@/Components/Supervisor/ui.js';
</script>
<template>
<div :class="cardClass">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-100">
<thead class="bg-slate-50/80">
<slot name="head" />
</thead>
<tbody
class="divide-y divide-slate-100 bg-white [&_tr_td]:border-l-2 [&_tr_td]:border-l-transparent [&_tr_td]:transition-colors [&_tr_td]:duration-150 [&_tr:hover_td]:bg-indigo-50/70 [&_tr:hover_td:first-child]:border-l-indigo-500"
>
<slot />
</tbody>
</table>
</div>
<slot name="footer" />
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
import { labelClass } from '@/Components/Supervisor/ui.js';
defineProps({
label: {
type: String,
required: true,
},
hint: {
type: String,
default: '',
},
fieldClass: {
type: String,
default: '',
},
});
</script>
<template>
<div :class="fieldClass">
<label class="block">
<span :class="labelClass">{{ label }}</span>
<slot />
</label>
<p v-if="hint" class="mt-1 text-xs text-slate-400">{{ hint }}</p>
</div>
</template>
@@ -0,0 +1,55 @@
<script setup>
import Modal from '@/Components/Modal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
defineProps({
show: {
type: Boolean,
default: false,
},
title: {
type: String,
required: true,
},
submitLabel: {
type: String,
default: 'Enregistrer',
},
processing: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: '2xl',
},
});
const emit = defineEmits(['close', 'submit']);
</script>
<template>
<Modal :show="show" :max-width="maxWidth" @close="emit('close')">
<form class="flex max-h-[calc(100vh-6rem)] flex-col" @submit.prevent="emit('submit')">
<div class="border-b border-slate-200 px-6 py-4">
<h2 class="text-lg font-semibold text-slate-900">
{{ title }}
</h2>
</div>
<div class="overflow-y-auto px-6 py-5">
<slot />
</div>
<div class="flex justify-end gap-3 border-t border-slate-200 bg-slate-50 px-6 py-4">
<SecondaryButton type="button" :disabled="processing" @click="emit('close')">
Annuler
</SecondaryButton>
<PrimaryButton type="submit" :disabled="processing">
{{ submitLabel }}
</PrimaryButton>
</div>
</form>
</Modal>
</template>
@@ -0,0 +1,266 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
options: {
type: Array,
required: true,
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'Sélectionner…',
},
variant: {
type: String,
default: 'dark',
validator: (value) => ['dark', 'light'].includes(value),
},
bordered: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'change']);
const root = ref(null);
const isOpen = ref(false);
const activeIndex = ref(-1);
const normalizedValue = computed(() => {
const value = props.modelValue;
return value === null || value === undefined ? '' : String(value);
});
const selectedLabel = computed(() => {
const selected = props.options.find((option) => String(option.value) === normalizedValue.value);
return selected?.label ?? props.placeholder;
});
const isDark = computed(() => props.variant === 'dark');
const wrapperClass = computed(() => (
props.bordered
? (isDark.value ? 'rounded-xl border border-white/25 p-4' : 'rounded-xl border border-slate-200 p-4')
: ''
));
const labelClass = computed(() => (
isDark.value
? 'flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-indigo-200'
: 'flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-500'
));
const triggerClass = computed(() => (
isDark.value
? 'border-white/20 bg-transparent text-white hover:border-white/30 focus:border-white/40 focus:ring-white/15'
: 'border-slate-200 bg-white text-slate-800 hover:border-slate-300 focus:border-indigo-400 focus:ring-indigo-500/20'
));
const menuClass = computed(() => (
isDark.value
? 'border-white/20 bg-slate-900/95 text-white shadow-xl shadow-black/30 backdrop-blur-md'
: 'border-slate-200 bg-white text-slate-800 shadow-lg shadow-slate-200/60'
));
const chevronClass = computed(() => (
isDark.value ? 'text-indigo-200' : 'text-slate-400'
));
const optionClass = (option, index) => {
const isSelected = String(option.value) === normalizedValue.value;
const isActive = index === activeIndex.value;
if (isDark.value) {
if (isSelected) {
return 'bg-indigo-500/25 text-white';
}
if (isActive) {
return 'bg-white/10 text-white';
}
return 'text-indigo-100 hover:bg-white/10 hover:text-white';
}
if (isSelected) {
return 'bg-indigo-50 text-indigo-700';
}
if (isActive) {
return 'bg-slate-50 text-slate-900';
}
return 'text-slate-700 hover:bg-slate-50';
};
const toggle = () => {
isOpen.value = !isOpen.value;
if (isOpen.value) {
const selectedIndex = props.options.findIndex((option) => String(option.value) === normalizedValue.value);
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
}
};
const close = () => {
isOpen.value = false;
activeIndex.value = -1;
};
const selectOption = (option) => {
emit('update:modelValue', option.value);
emit('change', option.value);
close();
};
const onClickOutside = (event) => {
if (isOpen.value && root.value && !root.value.contains(event.target)) {
close();
}
};
const onKeydown = (event) => {
if (!isOpen.value) {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) {
event.preventDefault();
isOpen.value = true;
activeIndex.value = Math.max(
0,
props.options.findIndex((option) => String(option.value) === normalizedValue.value),
);
}
return;
}
if (event.key === 'Escape') {
event.preventDefault();
close();
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, props.options.length - 1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (props.options[activeIndex.value]) {
selectOption(props.options[activeIndex.value]);
}
}
};
onMounted(() => {
document.addEventListener('click', onClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', onClickOutside);
});
</script>
<template>
<div ref="root" :class="wrapperClass">
<label
v-if="label"
:for="id"
:class="labelClass"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
{{ label }}
</label>
<div class="relative" :class="label ? 'mt-2' : ''">
<button
:id="id"
type="button"
class="flex w-full items-center justify-between gap-3 rounded-lg border py-2.5 pl-3 pr-3 text-left text-sm font-medium transition focus:outline-none focus:ring-2"
:class="triggerClass"
:aria-expanded="isOpen"
aria-haspopup="listbox"
@click.stop="toggle"
@keydown="onKeydown"
>
<span class="truncate">{{ selectedLabel }}</span>
<svg
class="h-4 w-4 shrink-0 transition-transform duration-200"
:class="[chevronClass, isOpen ? 'rotate-180' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Transition
enter-active-class="transition ease-out duration-150"
enter-from-class="opacity-0 -translate-y-1 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
>
<ul
v-show="isOpen"
class="absolute z-[100] mt-2 max-h-60 w-full overflow-auto rounded-xl border p-1.5"
:class="menuClass"
role="listbox"
:aria-labelledby="id"
>
<li
v-for="(option, index) in options"
:key="`${option.value}-${index}`"
role="option"
:aria-selected="String(option.value) === normalizedValue"
class="flex cursor-pointer items-center justify-between gap-2 rounded-lg px-3 py-2.5 text-sm transition"
:class="optionClass(option, index)"
@click.stop="selectOption(option)"
@mouseenter="activeIndex = index"
>
<span class="truncate">{{ option.label }}</span>
<svg
v-if="String(option.value) === normalizedValue"
class="h-4 w-4 shrink-0"
:class="isDark ? 'text-indigo-300' : 'text-indigo-500'"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</li>
</ul>
</Transition>
</div>
</div>
</template>
@@ -0,0 +1,18 @@
<script setup>
defineProps({
description: {
type: String,
default: '',
},
});
</script>
<template>
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p v-if="description" class="text-sm text-slate-500">{{ description }}</p>
<div v-else class="flex-1" />
<div class="flex shrink-0 items-center gap-3">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,96 @@
<script setup>
import { filterSelectClass } from '@/Components/Supervisor/ui.js';
import { listPerPageOptions } from '@/Components/Supervisor/useListFilters.js';
import { Link } from '@inertiajs/vue3';
defineProps({
paginator: {
type: Object,
required: true,
},
perPage: {
type: [Number, String],
default: 10,
},
});
const emit = defineEmits(['update:perPage']);
const formatLabel = (label) => {
const text = String(label)
.replace(/&laquo;/g, '')
.replace(/&raquo;/g, '')
.trim();
if (/previous/i.test(text)) {
return 'Précédent';
}
if (/next/i.test(text)) {
return 'Suivant';
}
return text;
};
const onPerPageChange = (event) => {
emit('update:perPage', Number(event.target.value));
};
</script>
<template>
<div
v-if="paginator.total > 0"
class="flex flex-wrap items-center justify-between gap-x-6 gap-y-3 border-t border-slate-100 px-6 py-4"
>
<p class="shrink-0 text-sm text-slate-500">
{{ paginator.from ?? 0 }}{{ paginator.to ?? 0 }}
<span class="text-slate-400">sur</span>
{{ paginator.total }}
<span class="text-slate-400">résultat(s)</span>
</p>
<div class="flex flex-wrap items-center gap-4">
<label class="flex shrink-0 items-center gap-2 text-sm text-slate-600">
<span class="whitespace-nowrap">Lignes par page</span>
<select
:value="perPage"
:class="filterSelectClass"
@change="onPerPageChange"
>
<option v-for="option in listPerPageOptions" :key="option" :value="option">
{{ option }}
</option>
</select>
</label>
<nav
v-if="paginator.last_page > 1"
class="flex flex-wrap items-center gap-1"
>
<template v-for="link in paginator.links" :key="link.label">
<Link
v-if="link.url"
:href="link.url"
class="rounded-lg px-3 py-1.5 text-sm font-medium transition"
:class="
link.active
? 'bg-indigo-600 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100'
"
preserve-state
>
{{ formatLabel(link.label) }}
</Link>
<span
v-else
class="cursor-not-allowed rounded-lg px-3 py-1.5 text-sm font-medium text-slate-300"
:class="link.active ? 'bg-indigo-600 text-white shadow-sm' : ''"
>
{{ formatLabel(link.label) }}
</span>
</template>
</nav>
</div>
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
colorClass: {
type: String,
default: 'bg-slate-100 text-slate-700 ring-slate-500/20',
},
});
</script>
<template>
<span
class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset"
:class="colorClass"
>
{{ label }}
</span>
</template>
@@ -0,0 +1,96 @@
<script setup>
import { filterInputClass, filterLabelClass, thFilterClass } from '@/Components/Supervisor/ui.js';
const props = defineProps({
label: {
type: String,
required: true,
},
align: {
type: String,
default: 'left',
},
sortable: {
type: Boolean,
default: false,
},
sortKey: {
type: String,
default: '',
},
activeSort: {
type: String,
default: '',
},
sortDirection: {
type: String,
default: 'asc',
},
});
const emit = defineEmits(['sort']);
const isActive = () => props.sortable && props.activeSort === props.sortKey;
const handleSort = () => {
if (props.sortable && props.sortKey) {
emit('sort', props.sortKey);
}
};
</script>
<template>
<th
:class="[
thFilterClass,
align === 'right' ? 'text-right' : 'text-left',
]"
>
<button
v-if="sortable"
type="button"
class="mb-1.5 flex items-center gap-1 text-xs font-semibold uppercase tracking-wide transition"
:class="[
align === 'right' ? 'ml-auto' : '',
isActive() ? 'text-indigo-600' : 'text-slate-600 hover:text-indigo-600',
]"
@click="handleSort"
>
<span>{{ label }}</span>
<svg
v-if="isActive() && sortDirection === 'asc'"
class="h-3.5 w-3.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
</svg>
<svg
v-else-if="isActive() && sortDirection === 'desc'"
class="h-3.5 w-3.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
<svg
v-else
class="h-3.5 w-3.5 flex-shrink-0 text-slate-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4M8 15l4 4 4-4" />
</svg>
</button>
<span v-else :class="filterLabelClass">{{ label }}</span>
<slot>
<span class="block h-[34px]" aria-hidden="true" />
</slot>
</th>
</template>
+67
View File
@@ -0,0 +1,67 @@
export const labelClass = 'block text-xs font-semibold uppercase tracking-wide text-slate-500';
export const inputClass =
'mt-1.5 block w-full rounded-xl border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-800 shadow-sm transition placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/20';
export const selectClass = inputClass;
export const cardClass = 'overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm';
export const filterBarClass =
'mb-6 flex flex-wrap gap-4 rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm';
export const thFilterClass = 'px-6 py-3 align-bottom';
export const filterLabelClass =
'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-600';
export const filterInputClass =
'block w-full min-w-[7rem] rounded-lg border-slate-200 bg-white px-2.5 py-1.5 text-sm text-slate-800 shadow-sm transition placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/20';
export const filterSelectClass = filterInputClass;
export const formPanelClass =
'mb-6 rounded-2xl border border-indigo-200/60 bg-white p-6 shadow-sm ring-1 ring-indigo-50';
export const thClass =
'px-6 py-4 text-left text-xs font-semibold uppercase tracking-wide text-slate-600';
export const tdClass = 'px-6 py-4 text-sm text-slate-600';
export const rowClass = '';
export const linkClass = 'font-medium text-indigo-600 transition hover:text-indigo-800';
export const machineStatusColors = {
available: 'bg-emerald-100 text-emerald-800 ring-emerald-600/20',
reserved: 'bg-amber-100 text-amber-800 ring-amber-600/20',
running: 'bg-sky-100 text-sky-800 ring-sky-600/20',
maintenance: 'bg-orange-100 text-orange-800 ring-orange-600/20',
offline: 'bg-slate-100 text-slate-600 ring-slate-500/20',
error: 'bg-red-100 text-red-800 ring-red-600/20',
};
export const bookingStatusColors = {
pending: 'bg-slate-100 text-slate-600 ring-slate-500/20',
confirmed: 'bg-sky-100 text-sky-800 ring-sky-600/20',
expired: 'bg-orange-100 text-orange-800 ring-orange-600/20',
active: 'bg-emerald-100 text-emerald-800 ring-emerald-600/20',
completed: 'bg-indigo-100 text-indigo-800 ring-indigo-600/20',
cancelled: 'bg-amber-100 text-amber-800 ring-amber-600/20',
no_show: 'bg-red-100 text-red-800 ring-red-600/20',
};
export const washStatusColors = {
pending_start: 'bg-slate-100 text-slate-600 ring-slate-500/20',
running: 'bg-sky-100 text-sky-800 ring-sky-600/20',
completed: 'bg-emerald-100 text-emerald-800 ring-emerald-600/20',
failed: 'bg-red-100 text-red-800 ring-red-600/20',
cancelled: 'bg-amber-100 text-amber-800 ring-amber-600/20',
};
export const activeStatusColors = {
active: 'bg-emerald-100 text-emerald-800 ring-emerald-600/20',
inactive: 'bg-slate-100 text-slate-600 ring-slate-500/20',
};
export const getStatusColor = (status, map) => map[status] ?? 'bg-slate-100 text-slate-700 ring-slate-500/20';
@@ -0,0 +1,75 @@
import { router } from '@inertiajs/vue3';
import { reactive, watch } from 'vue';
export const listPerPageOptions = [10, 25, 50];
export function useListFilters(routeName, initialFilters, { debounceKeys = [] } = {}) {
const localFilters = reactive({ ...initialFilters });
const buildParams = ({ resetPage = false } = {}) => {
const params = { ...localFilters };
if (resetPage) {
delete params.page;
}
Object.keys(params).forEach((key) => {
if (params[key] === '' || params[key] === null || params[key] === undefined) {
delete params[key];
}
});
return params;
};
const fetchList = ({ resetPage = false } = {}) => {
router.get(route(routeName), buildParams({ resetPage }), {
preserveState: true,
replace: true,
});
};
let debounceTimer = null;
if (debounceKeys.length > 0) {
debounceKeys.forEach((key) => {
watch(
() => localFilters[key],
() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fetchList({ resetPage: true }), 300);
},
);
});
}
const immediateKeys = Object.keys(initialFilters).filter(
(key) => !debounceKeys.includes(key) && key !== 'per_page',
);
if (immediateKeys.length > 0) {
watch(
() => immediateKeys.map((key) => localFilters[key]),
() => fetchList({ resetPage: true }),
);
}
if ('per_page' in initialFilters) {
watch(
() => localFilters.per_page,
() => fetchList({ resetPage: true }),
);
}
const toggleSort = (key, descFirstKeys = []) => {
if (localFilters.sort === key) {
localFilters.direction = localFilters.direction === 'asc' ? 'desc' : 'asc';
return;
}
localFilters.sort = key;
localFilters.direction = descFirstKeys.includes(key) ? 'desc' : 'asc';
};
return { localFilters, toggleSort };
}