Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Backend
- Nouveau ConstructeurSearchFilter : LIKE insensible casse sur name/email + LEFT JOIN telephones.numero, accessible via ?search=
- Constructeur entity : ApiFilter ConstructeurSearchFilter, SearchFilter (categories.id exact), OrderFilter (name, email, createdAt)
- paginationMaximumItemsPerPage 200 -> 2000 (pour ConstructeurSelect et MachineDetail qui chargent l'ensemble en cache)
Frontend
- useConstructeurs : nouvelle fonction fetchConstructeursPage({ page, itemsPerPage, search, categoryId, orderField, orderDirection }) renvoyant { items, totalItems, totalPages, currentPage }
- constructeurs.vue : suppression du filtre/tri client, état page/perPage/totalItems/totalPages, watchers sur search/filter/sort qui reset page=1 et rechargent, prop pagination du DataTable câblée, recharge après create/update/delete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
471 lines
16 KiB
Vue
471 lines
16 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, leurs coordonnées et leurs catégories.
|
|
</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-sm">
|
|
<div class="card-body space-y-4">
|
|
<DataTable
|
|
:columns="columns"
|
|
:rows="pageItems"
|
|
:loading="loading"
|
|
:sort="currentSort"
|
|
:pagination="paginationState"
|
|
:show-counter="true"
|
|
:show-per-page="true"
|
|
empty-message="Aucun fournisseur trouvé."
|
|
no-results-message="Aucun fournisseur trouvé."
|
|
@sort="handleSort"
|
|
@update:current-page="onPageChange"
|
|
@update:per-page="onPerPageChange"
|
|
>
|
|
<template #toolbar>
|
|
<div class="flex flex-col sm:flex-row gap-3 w-full">
|
|
<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>
|
|
<label class="w-full sm:w-64">
|
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Catégorie</span>
|
|
<select
|
|
v-model="selectedCategoryId"
|
|
class="select select-bordered select-sm w-full mt-1"
|
|
>
|
|
<option value="">
|
|
Toutes les catégories
|
|
</option>
|
|
<option v-for="cat in allCategories" :key="cat.id" :value="cat.id">
|
|
{{ cat.name }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</template>
|
|
|
|
<template #cell-telephones="{ row }">
|
|
<div v-if="rowPhones(row).length" class="flex flex-col gap-0.5">
|
|
<span v-for="(tel, idx) in rowPhones(row)" :key="idx" class="whitespace-nowrap text-sm">
|
|
{{ formatPhoneDisplay(tel.numero) }}
|
|
<span v-if="tel.label" class="text-xs text-base-content/50">({{ tel.label }})</span>
|
|
</span>
|
|
</div>
|
|
<span v-else class="text-base-content/30">—</span>
|
|
</template>
|
|
|
|
<template #cell-categories="{ row }">
|
|
<div v-if="row.categories && row.categories.length" class="flex flex-wrap gap-1">
|
|
<span
|
|
v-for="cat in row.categories"
|
|
:key="cat.id"
|
|
class="badge badge-ghost badge-sm cursor-pointer hover:badge-primary transition-colors"
|
|
@click="selectedCategoryId = cat.id"
|
|
>
|
|
{{ cat.name }}
|
|
</span>
|
|
</div>
|
|
<span v-else class="text-base-content/30">—</span>
|
|
</template>
|
|
|
|
<template #cell-createdAt="{ row }">
|
|
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
|
</template>
|
|
|
|
<template #cell-composantCount="{ row }">
|
|
<NuxtLink
|
|
v-if="stats[row.id]?.composantCount"
|
|
:to="`/catalogues/composants?constructeur=${row.id}`"
|
|
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
|
|
>
|
|
{{ stats[row.id].composantCount }}
|
|
</NuxtLink>
|
|
<span v-else class="text-base-content/30">—</span>
|
|
</template>
|
|
|
|
<template #cell-pieceCount="{ row }">
|
|
<NuxtLink
|
|
v-if="stats[row.id]?.pieceCount"
|
|
:to="`/catalogues/pieces?constructeur=${row.id}`"
|
|
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
|
|
>
|
|
{{ stats[row.id].pieceCount }}
|
|
</NuxtLink>
|
|
<span v-else class="text-base-content/30">—</span>
|
|
</template>
|
|
|
|
<template #cell-machineCount="{ row }">
|
|
<NuxtLink
|
|
v-if="stats[row.id]?.machineCount"
|
|
:to="`/machines?constructeur=${row.id}`"
|
|
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
|
|
>
|
|
{{ stats[row.id].machineCount }}
|
|
</NuxtLink>
|
|
<span v-else class="text-base-content/30">—</span>
|
|
</template>
|
|
|
|
<template #cell-actions="{ row }">
|
|
<div class="flex items-center 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-ghost btn-xs text-error" @click="confirmDelete(row)">
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</DataTable>
|
|
</div>
|
|
</div>
|
|
|
|
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
|
<div class="modal-box max-w-2xl">
|
|
<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>
|
|
|
|
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
|
|
|
|
<div class="form-control">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="label-text">Téléphones</span>
|
|
<button
|
|
v-if="canEdit"
|
|
type="button"
|
|
class="btn btn-ghost btn-xs"
|
|
@click="addTelephoneRow"
|
|
>
|
|
<IconLucidePlus class="w-3 h-3 mr-1" aria-hidden="true" />
|
|
Ajouter
|
|
</button>
|
|
</div>
|
|
<p v-if="!form.telephones.length" class="text-sm text-base-content/50">
|
|
Aucun téléphone.
|
|
</p>
|
|
<div v-for="(tel, idx) in form.telephones" :key="idx" class="flex items-end gap-2 mb-2">
|
|
<div class="flex-1">
|
|
<FieldPhone v-model="tel.numero" label="" :disabled="!canEdit" placeholder="Ex: 05 49 00 00 00" />
|
|
</div>
|
|
<input
|
|
v-model="tel.label"
|
|
type="text"
|
|
class="input input-bordered input-sm md:input-md w-40"
|
|
placeholder="Libellé (optionnel)"
|
|
:disabled="!canEdit"
|
|
>
|
|
<button
|
|
v-if="canEdit"
|
|
type="button"
|
|
class="btn btn-ghost btn-sm text-error"
|
|
aria-label="Supprimer ce téléphone"
|
|
@click="removeTelephoneRow(idx)"
|
|
>
|
|
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text">Catégories</span></label>
|
|
<ConstructeurCategorieSelect v-model="form.categories" :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 lang="ts">
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
import DataTable from '~/components/common/DataTable.vue'
|
|
import FieldEmail from '~/components/form/FieldEmail.vue'
|
|
import FieldPhone from '~/components/form/FieldPhone.vue'
|
|
import ConstructeurCategorieSelect from '~/components/form/ConstructeurCategorieSelect.vue'
|
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
|
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { usePersistedValue } from '~/composables/usePersistedValue'
|
|
import { constructeurPhones } from '~/shared/constructeurUtils'
|
|
import { formatPhone } from '~/utils/formatters/phone'
|
|
import { formatFrenchDate } from '~/utils/date'
|
|
import IconLucidePlus from '~icons/lucide/plus'
|
|
import IconLucideX from '~icons/lucide/x'
|
|
|
|
interface TelephoneFormRow { '@id'?: string, numero: string, label: string }
|
|
interface ConstructeurFormState {
|
|
name: string
|
|
email: string
|
|
telephones: TelephoneFormRow[]
|
|
categories: ConstructeurCategorie[]
|
|
}
|
|
|
|
const api = useApi()
|
|
const { canEdit } = usePermissions()
|
|
const { constructeurs, loading, createConstructeur, updateConstructeur, deleteConstructeur, fetchConstructeursPage } = useConstructeurs()
|
|
const { categories: allCategories, loadCategories } = useConstructeurCategories()
|
|
const { showError } = useToast()
|
|
|
|
const pageItems = ref<typeof constructeurs.value>([])
|
|
const totalItems = ref(0)
|
|
const totalPages = ref(0)
|
|
const currentPage = ref(1)
|
|
const perPage = ref(30)
|
|
const perPageOptions = [15, 30, 50, 100]
|
|
|
|
const columns = [
|
|
{ key: 'name', label: 'Nom', sortable: true },
|
|
{ key: 'email', label: 'Email', sortable: true },
|
|
{ key: 'telephones', label: 'Téléphones' },
|
|
{ key: 'categories', label: 'Catégories' },
|
|
{ key: 'createdAt', label: 'Date de création', sortable: true },
|
|
{ key: 'composantCount', label: 'Composants', align: 'center' },
|
|
{ key: 'pieceCount', label: 'Pièces', align: 'center' },
|
|
{ key: 'machineCount', label: 'Machines', align: 'center' },
|
|
{ key: 'actions', label: 'Actions', align: 'right' },
|
|
]
|
|
|
|
const searchTerm = ref('')
|
|
const selectedCategoryId = ref('')
|
|
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
|
const sortDir = ref('asc')
|
|
const stats = ref<Record<string, { composantCount?: number, pieceCount?: number, machineCount?: number }>>({})
|
|
|
|
const currentSort = computed(() => ({
|
|
field: sortKey.value,
|
|
direction: sortDir.value,
|
|
}))
|
|
|
|
const handleSort = (sort) => {
|
|
sortKey.value = sort.field
|
|
sortDir.value = sort.direction
|
|
}
|
|
|
|
const paginationState = computed(() => ({
|
|
currentPage: currentPage.value,
|
|
totalPages: totalPages.value,
|
|
totalItems: totalItems.value,
|
|
pageItems: pageItems.value.length,
|
|
perPage: perPage.value,
|
|
perPageOptions,
|
|
}))
|
|
|
|
const SORTABLE_FIELDS = new Set(['name', 'email', 'createdAt'])
|
|
|
|
const loadPage = async () => {
|
|
const orderField = SORTABLE_FIELDS.has(sortKey.value)
|
|
? (sortKey.value as 'name' | 'email' | 'createdAt')
|
|
: 'name'
|
|
const result = await fetchConstructeursPage({
|
|
page: currentPage.value,
|
|
itemsPerPage: perPage.value,
|
|
search: searchTerm.value,
|
|
categoryId: selectedCategoryId.value || undefined,
|
|
orderField,
|
|
orderDirection: sortDir.value === 'desc' ? 'desc' : 'asc',
|
|
})
|
|
if (!result.success) {
|
|
if (result.error) showError(result.error)
|
|
pageItems.value = []
|
|
totalItems.value = 0
|
|
totalPages.value = 0
|
|
return
|
|
}
|
|
pageItems.value = result.items
|
|
totalItems.value = result.totalItems
|
|
totalPages.value = result.totalPages
|
|
if (currentPage.value > result.totalPages && result.totalPages > 0) {
|
|
currentPage.value = result.totalPages
|
|
}
|
|
}
|
|
|
|
const modalOpen = ref(false)
|
|
const saving = ref(false)
|
|
const editingConstructeur = ref<Record<string, any> | null>(null)
|
|
const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], categories: [] })
|
|
|
|
const rowPhones = constructeurPhones
|
|
|
|
const debouncedSearch = debounce(() => {
|
|
currentPage.value = 1
|
|
loadPage()
|
|
}, 300)
|
|
|
|
watch(selectedCategoryId, () => {
|
|
currentPage.value = 1
|
|
loadPage()
|
|
})
|
|
|
|
watch([sortKey, sortDir], () => {
|
|
currentPage.value = 1
|
|
loadPage()
|
|
})
|
|
|
|
const onPageChange = (page: number) => {
|
|
currentPage.value = page
|
|
loadPage()
|
|
}
|
|
|
|
const onPerPageChange = (value: number) => {
|
|
perPage.value = value
|
|
currentPage.value = 1
|
|
loadPage()
|
|
}
|
|
|
|
const formatDate = formatFrenchDate
|
|
|
|
const formatPhoneDisplay = value => formatPhone(value) || value || '—'
|
|
|
|
function debounce(fn, delay) {
|
|
let timeout
|
|
return (...args) => {
|
|
clearTimeout(timeout)
|
|
timeout = setTimeout(() => fn(...args), delay)
|
|
}
|
|
}
|
|
|
|
const resetForm = () => {
|
|
form.value = { name: '', email: '', telephones: [], categories: [] }
|
|
editingConstructeur.value = null
|
|
}
|
|
|
|
const openCreateModal = () => {
|
|
resetForm()
|
|
modalOpen.value = true
|
|
}
|
|
|
|
const openEditModal = (constructeur) => {
|
|
editingConstructeur.value = constructeur
|
|
form.value = {
|
|
name: constructeur.name,
|
|
email: constructeur.email || '',
|
|
telephones: (constructeur.telephones || []).map(t => ({
|
|
'@id': t['@id'],
|
|
numero: t.numero || '',
|
|
label: t.label || '',
|
|
})),
|
|
categories: (constructeur.categories || []).map(c => ({ ...c })),
|
|
}
|
|
modalOpen.value = true
|
|
}
|
|
|
|
const closeModal = () => {
|
|
modalOpen.value = false
|
|
resetForm()
|
|
}
|
|
|
|
const addTelephoneRow = () => {
|
|
form.value.telephones.push({ numero: '', label: '' })
|
|
}
|
|
|
|
const removeTelephoneRow = (idx) => {
|
|
form.value.telephones.splice(idx, 1)
|
|
}
|
|
|
|
const saveConstructeur = async () => {
|
|
const trimmedName = form.value.name.trim()
|
|
if (!trimmedName) {
|
|
showError('Le nom est obligatoire.')
|
|
return
|
|
}
|
|
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 = {
|
|
name: trimmedName,
|
|
email: form.value.email?.trim() || null,
|
|
telephones: form.value.telephones
|
|
.filter(t => t.numero && t.numero.trim())
|
|
.map((t) => {
|
|
const entry: { numero: string, label: string | null, '@id'?: string } = {
|
|
numero: t.numero.trim(),
|
|
label: t.label?.trim() || null,
|
|
}
|
|
if (t['@id']) { entry['@id'] = t['@id'] }
|
|
return entry
|
|
}),
|
|
categories: form.value.categories
|
|
.map(c => c['@id'] || (c.id ? `/api/constructeur_categories/${c.id}` : null))
|
|
.filter((iri): iri is string => Boolean(iri)),
|
|
}
|
|
|
|
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 loadPage()
|
|
}
|
|
}
|
|
|
|
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)
|
|
return
|
|
}
|
|
if (result.success) {
|
|
await loadPage()
|
|
}
|
|
}
|
|
|
|
const loadStats = async () => {
|
|
const result = await api.get('/constructeurs/stats')
|
|
if (result.success && result.data) {
|
|
stats.value = result.data
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadPage()
|
|
loadCategories()
|
|
loadStats()
|
|
})
|
|
</script>
|