- Nouveau composant DataTable réutilisable avec tri par en-têtes, pagination, filtres colonnes - Nouveau composable useDataTable (sort/page/search/perPage/columnFilters + persistance URL) - Migration des 9 tables : constructeurs, comments, admin, pieces-catalog, component-catalog, product-catalog, documents, activity-log, ManagementView (catégories) - Filtres "Type de" server-side (ipartial) pour pièces, composants, produits Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
249 lines
7.8 KiB
Vue
249 lines
7.8 KiB
Vue
<template>
|
|
<main class="container mx-auto px-6 py-8 space-y-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-3xl font-bold">
|
|
Fournisseurs
|
|
</h1>
|
|
<p class="text-sm text-gray-500">
|
|
Gérez les fournisseurs et leurs coordonnées.
|
|
</p>
|
|
</div>
|
|
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
|
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
|
Nouveau fournisseur
|
|
</button>
|
|
</div>
|
|
|
|
<div class="card bg-base-100 shadow-lg">
|
|
<div class="card-body space-y-4">
|
|
<DataTable
|
|
:columns="columns"
|
|
:rows="filteredConstructeurs"
|
|
:loading="loading"
|
|
:sort="currentSort"
|
|
:show-counter="false"
|
|
empty-message="Aucun fournisseur trouvé."
|
|
no-results-message="Aucun fournisseur trouvé."
|
|
@sort="handleSort"
|
|
>
|
|
<template #toolbar>
|
|
<label class="w-full sm:w-72">
|
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
|
<input
|
|
v-model="searchTerm"
|
|
type="search"
|
|
class="input input-bordered input-sm w-full mt-1"
|
|
placeholder="Nom, email ou téléphone"
|
|
@input="debouncedSearch"
|
|
/>
|
|
</label>
|
|
</template>
|
|
|
|
<template #cell-phone="{ row }">
|
|
{{ formatPhoneDisplay(row.phone) }}
|
|
</template>
|
|
|
|
<template #cell-createdAt="{ row }">
|
|
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
|
</template>
|
|
|
|
<template #cell-actions="{ row }">
|
|
<div class="flex justify-end gap-2">
|
|
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
|
|
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
|
</button>
|
|
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(row)">
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</DataTable>
|
|
</div>
|
|
</div>
|
|
|
|
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
|
<div class="modal-box">
|
|
<h3 class="font-bold text-lg mb-4">
|
|
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
|
|
</h3>
|
|
<form class="space-y-4" @submit.prevent="saveConstructeur">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text">Nom</span></label>
|
|
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
|
|
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
|
|
</div>
|
|
<div class="modal-action">
|
|
<button type="button" class="btn" @click="closeModal">
|
|
Annuler
|
|
</button>
|
|
<button type="submit" class="btn btn-primary" :disabled="!canEdit || saving">
|
|
<span v-if="saving" class="loading loading-spinner loading-xs mr-2" />
|
|
{{ editingConstructeur ? 'Enregistrer' : 'Créer' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</dialog>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import DataTable from '~/components/common/DataTable.vue'
|
|
import FieldEmail from '~/components/form/FieldEmail.vue'
|
|
import FieldPhone from '~/components/form/FieldPhone.vue'
|
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { usePersistedValue } from '~/composables/usePersistedValue'
|
|
import { formatPhone } from '~/utils/formatters/phone'
|
|
import IconLucidePlus from '~icons/lucide/plus'
|
|
|
|
const { canEdit } = usePermissions()
|
|
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
|
const { showError } = useToast()
|
|
|
|
const columns = [
|
|
{ key: 'name', label: 'Nom', sortable: true },
|
|
{ key: 'email', label: 'Email', sortable: true },
|
|
{ key: 'phone', label: 'Téléphone', sortable: true },
|
|
{ key: 'createdAt', label: 'Date de création', sortable: true },
|
|
{ key: 'actions', label: 'Actions', align: 'right' },
|
|
]
|
|
|
|
const searchTerm = ref('')
|
|
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
|
const sortDir = ref('asc')
|
|
|
|
const currentSort = computed(() => ({
|
|
field: sortKey.value,
|
|
direction: sortDir.value,
|
|
}))
|
|
|
|
const handleSort = (sort) => {
|
|
sortKey.value = sort.field
|
|
sortDir.value = sort.direction
|
|
}
|
|
|
|
const modalOpen = ref(false)
|
|
const saving = ref(false)
|
|
const editingConstructeur = ref(null)
|
|
const form = ref({ name: '', email: '', phone: '' })
|
|
|
|
const filteredConstructeurs = computed(() => {
|
|
const key = sortKey.value
|
|
const dir = sortDir.value === 'desc' ? -1 : 1
|
|
const sorted = [...constructeurs.value].sort((a, b) => {
|
|
if (key === 'createdAt') {
|
|
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
|
|
}
|
|
return dir * (a[key] || '').localeCompare(b[key] || '')
|
|
})
|
|
if (!searchTerm.value) { return sorted }
|
|
const term = searchTerm.value.toLowerCase()
|
|
return sorted.filter(item =>
|
|
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
|
|
)
|
|
})
|
|
|
|
const debouncedSearch = debounce(async () => {
|
|
await searchConstructeurs(searchTerm.value)
|
|
}, 300)
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return '—'
|
|
const date = new Date(dateStr)
|
|
if (Number.isNaN(date.getTime())) return '—'
|
|
return new Intl.DateTimeFormat('fr-FR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
}).format(date)
|
|
}
|
|
|
|
const formatPhoneDisplay = (value) => {
|
|
const formatted = formatPhone(value)
|
|
if (formatted) {
|
|
return formatted
|
|
}
|
|
return value || '—'
|
|
}
|
|
|
|
function debounce(fn, delay) {
|
|
let timeout
|
|
return (...args) => {
|
|
clearTimeout(timeout)
|
|
timeout = setTimeout(() => fn(...args), delay)
|
|
}
|
|
}
|
|
|
|
const resetForm = () => {
|
|
form.value = { name: '', email: '', phone: '' }
|
|
editingConstructeur.value = null
|
|
}
|
|
|
|
const openCreateModal = () => {
|
|
resetForm()
|
|
modalOpen.value = true
|
|
}
|
|
|
|
const openEditModal = (constructeur) => {
|
|
editingConstructeur.value = constructeur
|
|
form.value = {
|
|
name: constructeur.name,
|
|
email: constructeur.email || '',
|
|
phone: constructeur.phone || '',
|
|
}
|
|
modalOpen.value = true
|
|
}
|
|
|
|
const closeModal = () => {
|
|
modalOpen.value = false
|
|
resetForm()
|
|
}
|
|
|
|
const saveConstructeur = async () => {
|
|
const trimmedName = form.value.name.trim()
|
|
const duplicate = constructeurs.value.find(
|
|
c => c.name.toLowerCase() === trimmedName.toLowerCase()
|
|
&& c.id !== editingConstructeur.value?.id,
|
|
)
|
|
if (duplicate) {
|
|
showError(`Un fournisseur "${duplicate.name}" existe déjà.`)
|
|
return
|
|
}
|
|
|
|
saving.value = true
|
|
const payload = { ...form.value, name: trimmedName }
|
|
if (!payload.email) { delete payload.email }
|
|
if (!payload.phone) { delete payload.phone }
|
|
let result
|
|
if (editingConstructeur.value) {
|
|
result = await updateConstructeur(editingConstructeur.value.id, payload)
|
|
}
|
|
else {
|
|
result = await createConstructeur(payload)
|
|
}
|
|
saving.value = false
|
|
if (result.success) {
|
|
closeModal()
|
|
await searchConstructeurs(searchTerm.value)
|
|
}
|
|
}
|
|
|
|
const { confirm } = useConfirm()
|
|
|
|
const confirmDelete = async (constructeur) => {
|
|
if (!await confirm({ message: `Supprimer le fournisseur "${constructeur.name}" ?` })) { return }
|
|
const result = await deleteConstructeur(constructeur.id)
|
|
if (!result.success && result.error) {
|
|
showError(result.error)
|
|
}
|
|
}
|
|
|
|
onMounted(() => loadConstructeurs())
|
|
</script>
|