feat(fournisseurs) : categories (M2M) + telephones (1-N) + import customer.json
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
- Nouvelles entites ConstructeurCategorie (referentiel M2M) et ConstructeurTelephone (1-N) - Constructeur : retrait colonne phone, ajout collections telephones/categories, groupes de serialisation constructeur:read/write - Migration : cree les 3 tables, migre la colonne phone existante vers constructeur_telephone, drop phone - Commande app:import-fournisseurs (dry-run par defaut, --force) : non destructive, find-or-create par nom, ne touche jamais un ID existant, ajout-seulement pour telephones/categories - MAJ MCP tools / MachineStructureController / audit subscriber / tests - Frontend : page constructeurs avec telephones multiples + categories (tableau, filtre, formulaire), composable useConstructeurCategories, composant ConstructeurCategorieSelect Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -124,6 +124,7 @@ import IconLucideCheck from '~icons/lucide/check'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import {
|
||||
type ConstructeurSummary,
|
||||
constructeurPhones,
|
||||
formatConstructeurContact,
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
@@ -193,7 +194,7 @@ const filteredOptions = computed(() => {
|
||||
return options.value.filter((option) =>
|
||||
(option.name ?? '').toLowerCase().includes(term)
|
||||
|| (option.email && option.email.toLowerCase().includes(term))
|
||||
|| (option.phone && option.phone.toLowerCase().includes(term))
|
||||
|| constructeurPhones(option).some(t => t.numero.toLowerCase().includes(term))
|
||||
)
|
||||
})
|
||||
|
||||
@@ -293,14 +294,14 @@ const handleCreate = async () => {
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
const payload: { name: string; email?: string; phone?: string } = {
|
||||
const payload: { name: string; email?: string; telephones?: Array<{ numero: string }> } = {
|
||||
name: trimmedName,
|
||||
}
|
||||
if (createForm.value.email) {
|
||||
payload.email = createForm.value.email
|
||||
}
|
||||
if (createForm.value.phone) {
|
||||
payload.phone = createForm.value.phone
|
||||
if (createForm.value.phone && createForm.value.phone.trim()) {
|
||||
payload.telephones = [{ numero: createForm.value.phone.trim() }]
|
||||
}
|
||||
const result = await createConstructeur(payload)
|
||||
creating.value = false
|
||||
|
||||
153
frontend/app/components/form/ConstructeurCategorieSelect.vue
Normal file
153
frontend/app/components/form/ConstructeurCategorieSelect.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="constructeur-categorie-select space-y-2">
|
||||
<div class="flex flex-wrap gap-2 min-h-[1.75rem]">
|
||||
<span v-if="!selected.length" class="text-sm text-base-content/50">
|
||||
Aucune catégorie
|
||||
</span>
|
||||
<span
|
||||
v-for="cat in selected"
|
||||
:key="cat.id || cat.name"
|
||||
class="badge badge-outline badge-lg gap-1"
|
||||
>
|
||||
<span>{{ cat.name }}</span>
|
||||
<button
|
||||
v-if="!disabled"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs p-0 h-auto min-h-0"
|
||||
aria-label="Retirer la catégorie"
|
||||
@click="removeCategory(cat)"
|
||||
>
|
||||
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!disabled" class="relative">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md w-full"
|
||||
:placeholder="placeholder"
|
||||
@focus="open = true; ensureLoaded()"
|
||||
@keydown.escape="open = false"
|
||||
>
|
||||
<div
|
||||
v-if="open && (matches.length || canCreate)"
|
||||
class="absolute z-30 mt-1 w-full max-h-56 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="cat in matches"
|
||||
:key="cat.id"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm"
|
||||
@click="addCategory(cat)"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canCreate"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm text-primary"
|
||||
@click="createAndAdd"
|
||||
>
|
||||
+ Créer « {{ searchTerm.trim() }} »
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<ConstructeurCategorie[]>,
|
||||
default: () => [],
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Rechercher ou créer une catégorie…',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: ConstructeurCategorie[]): void
|
||||
}>()
|
||||
|
||||
const { categories, loadCategories, createCategory } = useConstructeurCategories()
|
||||
|
||||
const searchTerm = ref('')
|
||||
const open = ref(false)
|
||||
const loadedOnce = ref(false)
|
||||
|
||||
const selected = computed<ConstructeurCategorie[]>(() => props.modelValue || [])
|
||||
|
||||
const selectedKeys = computed(() => new Set(selected.value.map(c => (c.name || '').toLowerCase())))
|
||||
|
||||
const matches = computed<ConstructeurCategorie[]>(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
return categories.value
|
||||
.filter(c => !selectedKeys.value.has((c.name || '').toLowerCase()))
|
||||
.filter(c => !term || (c.name || '').toLowerCase().includes(term))
|
||||
.slice(0, 50)
|
||||
})
|
||||
|
||||
const canCreate = computed(() => {
|
||||
const term = searchTerm.value.trim()
|
||||
if (!term) {
|
||||
return false
|
||||
}
|
||||
const lower = term.toLowerCase()
|
||||
return !categories.value.some(c => (c.name || '').toLowerCase() === lower)
|
||||
&& !selectedKeys.value.has(lower)
|
||||
})
|
||||
|
||||
const ensureLoaded = async () => {
|
||||
if (loadedOnce.value) {
|
||||
return
|
||||
}
|
||||
loadedOnce.value = true
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
const emitSelection = (value: ConstructeurCategorie[]) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
const addCategory = (cat: ConstructeurCategorie) => {
|
||||
if (selectedKeys.value.has((cat.name || '').toLowerCase())) {
|
||||
return
|
||||
}
|
||||
emitSelection([...selected.value, cat])
|
||||
searchTerm.value = ''
|
||||
}
|
||||
|
||||
const removeCategory = (cat: ConstructeurCategorie) => {
|
||||
emitSelection(selected.value.filter(c => c !== cat && c.id !== cat.id))
|
||||
}
|
||||
|
||||
const createAndAdd = async () => {
|
||||
const created = await createCategory(searchTerm.value)
|
||||
if (created) {
|
||||
addCategory(created)
|
||||
}
|
||||
}
|
||||
|
||||
const onDocumentClick = (event: Event) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target && !target.closest('.constructeur-categorie-select')) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
|
||||
</script>
|
||||
63
frontend/app/composables/useConstructeurCategories.ts
Normal file
63
frontend/app/composables/useConstructeurCategories.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface ConstructeurCategorie {
|
||||
'@id'?: string
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const categories = ref<ConstructeurCategorie[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const sortByName = (items: ConstructeurCategorie[]): ConstructeurCategorie[] =>
|
||||
[...items].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
|
||||
export function useConstructeurCategories() {
|
||||
const { get, post } = useApi()
|
||||
const { showError } = useToast()
|
||||
|
||||
const loadCategories = async (force = false): Promise<ConstructeurCategorie[]> => {
|
||||
if (loaded.value && !force) {
|
||||
return categories.value
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/constructeur_categories?itemsPerPage=1000')
|
||||
if (result.success) {
|
||||
categories.value = sortByName(extractCollection<ConstructeurCategorie>(result.data))
|
||||
loaded.value = true
|
||||
}
|
||||
return categories.value
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createCategory = async (name: string): Promise<ConstructeurCategorie | null> => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const existing = categories.value.find(c => c.name.toLowerCase() === trimmed.toLowerCase())
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const result = await post('/constructeur_categories', { name: trimmed })
|
||||
if (result.success && result.data && !Array.isArray(result.data)) {
|
||||
const created = result.data as ConstructeurCategorie
|
||||
categories.value = sortByName([...categories.value, created])
|
||||
return created
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return { categories, loading, loadCategories, createCategory }
|
||||
}
|
||||
@@ -3,11 +3,28 @@ import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface ConstructeurTelephone {
|
||||
'@id'?: string
|
||||
id?: string
|
||||
numero: string
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
export interface ConstructeurCategorieRef {
|
||||
'@id'?: string
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Constructeur {
|
||||
'@id'?: string
|
||||
id: string
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
telephones?: ConstructeurTelephone[]
|
||||
categories?: ConstructeurCategorieRef[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface ConstructeurResult {
|
||||
@@ -87,7 +104,7 @@ export function useConstructeurs() {
|
||||
return loadConstructeurs(search)
|
||||
}
|
||||
|
||||
const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/constructeurs', data)
|
||||
@@ -161,7 +178,7 @@ export function useConstructeurs() {
|
||||
.filter((item): item is Constructeur => item !== null)
|
||||
}
|
||||
|
||||
const updateConstructeur = async (id: string, data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
const updateConstructeur = async (id: string, data: Record<string, unknown>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/constructeurs/${id}`, data)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
Fournisseurs
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500">
|
||||
Gérez les fournisseurs et leurs coordonnées.
|
||||
Gérez les fournisseurs, leurs coordonnées et leurs catégories.
|
||||
</p>
|
||||
</div>
|
||||
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
|
||||
@@ -28,20 +28,56 @@
|
||||
@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>
|
||||
<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-phone="{ row }">
|
||||
{{ formatPhoneDisplay(row.phone) }}
|
||||
<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 }">
|
||||
@@ -96,7 +132,7 @@
|
||||
</div>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
||||
<div class="modal-box">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
|
||||
</h3>
|
||||
@@ -105,10 +141,53 @@
|
||||
<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" />
|
||||
|
||||
<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
|
||||
@@ -129,22 +208,36 @@ 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 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, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
||||
const { categories: allCategories, loadCategories } = useConstructeurCategories()
|
||||
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: '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' },
|
||||
@@ -153,9 +246,10 @@ const columns = [
|
||||
]
|
||||
|
||||
const searchTerm = ref('')
|
||||
const selectedCategoryId = ref('')
|
||||
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
||||
const sortDir = ref('asc')
|
||||
const stats = ref({})
|
||||
const stats = ref<Record<string, { composantCount?: number, pieceCount?: number, machineCount?: number }>>({})
|
||||
|
||||
const currentSort = computed(() => ({
|
||||
field: sortKey.value,
|
||||
@@ -169,23 +263,29 @@ const handleSort = (sort) => {
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingConstructeur = ref(null)
|
||||
const form = ref({ name: '', email: '', phone: '' })
|
||||
const editingConstructeur = ref<Record<string, any> | null>(null)
|
||||
const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], categories: [] })
|
||||
|
||||
const rowPhones = constructeurPhones
|
||||
|
||||
const filteredConstructeurs = computed(() => {
|
||||
const key = sortKey.value
|
||||
const dir = sortDir.value === 'desc' ? -1 : 1
|
||||
const sorted = [...constructeurs.value].sort((a, b) => {
|
||||
let 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 (selectedCategoryId.value) {
|
||||
sorted = sorted.filter(item => (item.categories || []).some(cat => cat.id === selectedCategoryId.value))
|
||||
}
|
||||
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)),
|
||||
)
|
||||
return sorted.filter((item) => {
|
||||
const haystack = [item.name, item.email, ...rowPhones(item).map(t => t.numero)]
|
||||
return haystack.some(value => value && String(value).toLowerCase().includes(term))
|
||||
})
|
||||
})
|
||||
|
||||
const debouncedSearch = debounce(async () => {
|
||||
@@ -194,13 +294,7 @@ const debouncedSearch = debounce(async () => {
|
||||
|
||||
const formatDate = formatFrenchDate
|
||||
|
||||
const formatPhoneDisplay = (value) => {
|
||||
const formatted = formatPhone(value)
|
||||
if (formatted) {
|
||||
return formatted
|
||||
}
|
||||
return value || '—'
|
||||
}
|
||||
const formatPhoneDisplay = value => formatPhone(value) || value || '—'
|
||||
|
||||
function debounce(fn, delay) {
|
||||
let timeout
|
||||
@@ -211,7 +305,7 @@ function debounce(fn, delay) {
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = { name: '', email: '', phone: '' }
|
||||
form.value = { name: '', email: '', telephones: [], categories: [] }
|
||||
editingConstructeur.value = null
|
||||
}
|
||||
|
||||
@@ -225,7 +319,12 @@ const openEditModal = (constructeur) => {
|
||||
form.value = {
|
||||
name: constructeur.name,
|
||||
email: constructeur.email || '',
|
||||
phone: constructeur.phone || '',
|
||||
telephones: (constructeur.telephones || []).map(t => ({
|
||||
'@id': t['@id'],
|
||||
numero: t.numero || '',
|
||||
label: t.label || '',
|
||||
})),
|
||||
categories: (constructeur.categories || []).map(c => ({ ...c })),
|
||||
}
|
||||
modalOpen.value = true
|
||||
}
|
||||
@@ -235,8 +334,20 @@ const closeModal = () => {
|
||||
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,
|
||||
@@ -247,9 +358,24 @@ const saveConstructeur = async () => {
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
const payload = { ...form.value, name: trimmedName }
|
||||
if (!payload.email) { delete payload.email }
|
||||
if (!payload.phone) { delete payload.phone }
|
||||
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)
|
||||
@@ -283,6 +409,7 @@ const loadStats = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadConstructeurs()
|
||||
loadCategories()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
import { formatPhone } from '~/utils/formatters/phone';
|
||||
|
||||
export interface ConstructeurTelephoneSummary {
|
||||
numero?: string | null;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
export interface ConstructeurSummary {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
// Legacy single-phone string: still exposed by the machine-structure normalization.
|
||||
phone?: string | null;
|
||||
// Multi-phone list: exposed by the /constructeurs API resource.
|
||||
telephones?: ConstructeurTelephoneSummary[] | null;
|
||||
}
|
||||
|
||||
type ConstructeurPhoneSource = {
|
||||
phone?: string | null;
|
||||
telephones?: ConstructeurTelephoneSummary[] | null;
|
||||
} | null | undefined;
|
||||
|
||||
export const constructeurPhones = (
|
||||
constructeur: ConstructeurPhoneSource,
|
||||
): Array<{ numero: string; label: string | null }> => {
|
||||
if (!constructeur) {
|
||||
return [];
|
||||
}
|
||||
const list = Array.isArray(constructeur.telephones)
|
||||
? constructeur.telephones
|
||||
.filter((t): t is ConstructeurTelephoneSummary => Boolean(t && t.numero && String(t.numero).trim()))
|
||||
.map(t => ({ numero: String(t.numero).trim(), label: (t.label ?? null) || null }))
|
||||
: [];
|
||||
if (!list.length && constructeur.phone && constructeur.phone.trim()) {
|
||||
return [{ numero: constructeur.phone.trim(), label: null }];
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
export const constructeurPrimaryPhone = (
|
||||
constructeur: ConstructeurPhoneSource,
|
||||
): string | null => {
|
||||
const phones = constructeurPhones(constructeur);
|
||||
return phones.length ? phones[0]!.numero : null;
|
||||
};
|
||||
|
||||
export interface ConstructeurLinkEntry {
|
||||
linkId?: string;
|
||||
constructeurId: string;
|
||||
@@ -133,8 +170,8 @@ export const formatConstructeurContact = (
|
||||
return '';
|
||||
}
|
||||
|
||||
const formattedPhone = formatPhone(constructeur.phone);
|
||||
const phone = formattedPhone || constructeur.phone || null;
|
||||
const primary = constructeurPrimaryPhone(constructeur);
|
||||
const phone = formatPhone(primary) || primary || null;
|
||||
|
||||
return [constructeur.email, phone].filter(Boolean).join(' • ');
|
||||
};
|
||||
|
||||
@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { mockLinkSKF, mockLinkFAG } from '../fixtures/mockData'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import under test (AFTER all vi.mock calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useComponentCreate } from '~/composables/useComponentCreate'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — API layer
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -206,12 +212,6 @@ vi.mock('~/shared/constructeurUtils', () => ({
|
||||
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import under test (AFTER all vi.mock calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useComponentCreate } from '~/composables/useComponentCreate'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
wrapCollection,
|
||||
} from '../fixtures/mockData'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import under test (AFTER all vi.mock calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useComponentEdit } from '~/composables/useComponentEdit'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — API layer
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -222,12 +228,6 @@ vi.mock('~/utils/documentPreview', () => ({
|
||||
canPreviewDocument: () => false,
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import under test (AFTER all vi.mock calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useComponentEdit } from '~/composables/useComponentEdit'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test data — component with structure containing slots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { wrapCollection } from '../fixtures/mockData'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import under test (AFTER all vi.mock calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — API layer
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -40,12 +46,6 @@ vi.mock('~/composables/useToast', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import under test (AFTER all vi.mock calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import under test (after mocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useMachineDetailData } from '~/composables/useMachineDetailData'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data — realistic /machines/{id}/structure response
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -345,12 +351,6 @@ vi.mock('~/shared/utils/documentDisplayUtils', () => ({
|
||||
downloadDocument: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import under test (after mocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useMachineDetailData } from '~/composables/useMachineDetailData'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
wrapCollection,
|
||||
} from '../fixtures/mockData'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import under test (AFTER all vi.mock calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { usePieceEdit } from '~/composables/usePieceEdit'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — API layer
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -183,12 +189,6 @@ vi.mock('~/shared/apiRelations', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import under test (AFTER all vi.mock calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { usePieceEdit } from '~/composables/usePieceEdit'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user