8 Commits

Author SHA1 Message Date
165e0a6341 fix(ui) : prevent dropdown overflow clipping in DataTable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:34:52 +01:00
de7be1b9d0 refactor(frontend) : extract shared components and reduce file sizes
- Extract CustomFieldInputGrid.vue from 6 duplicated template blocks (~70 lines each)
- Extract EntityHistorySection.vue from 3 identical history sections in edit pages
- Extract useDragReorder composable from 4 identical drag-and-drop implementations in StructureNodeEditor (~330 lines → ~30)
- Extract catalogDisplayUtils.ts (resolvePrimaryDocument, resolveSupplierNames, buildSuppliersDisplay)
- Remove redundant computed wrappers (historyEntries, loadingTypes, selectedFiles)
- Remove unused imports (fieldKey, historyActionLabel, formatHistoryDate, *HistoryEntry types)
- Move Intl.DateTimeFormat to module-level in date.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 02:28:26 +01:00
7b3eb1c5fc refactor(catalog) : extract shared delete impact logic and cleanup dead code
Extract duplicated resolveDeleteImpact/buildDeleteMessage into shared utility,
remove redundant computed wrappers, fix indentation, and remove dead code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 01:35:21 +01:00
Matthieu
592beb0fa7 fix(ui) : move add buttons below last element in structure editors
Place "Ajouter" buttons after the items list instead of in the section
header, so they always appear below the last added element.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:28:07 +01:00
Matthieu
e732585e63 fix(catalog) : add delete impact confirmation to product catalog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:06:06 +01:00
Matthieu
f1cc21c31b docs(changelog) : add delete confirmation dialog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:59:43 +01:00
Matthieu
6c2f84dd3a fix(catalog) : replace blocking delete guard with confirmation dialog
Show cascade-delete impact (documents, machine links, custom fields)
in a confirmation modal instead of blocking deletion entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:58:41 +01:00
Matthieu
032b3b33c9 docs(changelog) : add v1.8.1 release notes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:39:01 +01:00
25 changed files with 690 additions and 1376 deletions

View File

@@ -130,7 +130,7 @@ const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
}) })
} }
const selectedFiles = computed(() => internalFiles.value) const selectedFiles = internalFiles
watch( watch(
() => props.modelValue, () => props.modelValue,

View File

@@ -1,19 +1,13 @@
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<section class="space-y-3"> <section class="space-y-3">
<header class="flex items-center justify-between"> <header>
<div> <h3 class="text-sm font-semibold">
<h3 class="text-sm font-semibold"> Produits inclus par défaut
Produits inclus par défaut </h3>
</h3> <p class="text-xs text-base-content/70">
<p class="text-xs text-base-content/70"> Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie. </p>
</p>
</div>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</header> </header>
<p v-if="!products.length" class="text-xs text-gray-500"> <p v-if="!products.length" class="text-xs text-gray-500">
@@ -71,18 +65,16 @@
</div> </div>
</li> </li>
</ul> </ul>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section> </section>
<section class="space-y-3"> <section class="space-y-3">
<header class="flex items-center justify-between"> <h3 class="text-sm font-semibold">
<h3 class="text-sm font-semibold"> Champs personnalisés
Champs personnalisés </h3>
</h3>
<button type="button" class="btn btn-outline btn-xs" @click="addField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</header>
<p v-if="!fields.length" class="text-xs text-gray-500"> <p v-if="!fields.length" class="text-xs text-gray-500">
Aucun champ personnalisé n'a encore été défini. Aucun champ personnalisé n'a encore été défini.
@@ -101,77 +93,81 @@
@drop.prevent="onDrop(index)" @drop.prevent="onDrop(index)"
@dragend="onDragEnd" @dragend="onDragEnd"
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
title="Réordonner"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
<option value="text">
Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
Obligatoire
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
:disabled="isFieldLocked(field)"
/>
</div>
<button
v-if="!isFieldLocked(field)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button <button
type="button" type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
disabled title="Réordonner"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
<option value="text">
Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
Obligatoire
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
:disabled="isFieldLocked(field)"
/>
</div>
<button
v-if="!isFieldLocked(field)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeField(index)"
> >
<IconLucideTrash class="w-4 h-4" aria-hidden="true" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button> </button>
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div> </div>
</div> </li>
</li> </ul>
</ul> <button type="button" class="btn btn-outline btn-xs" @click="addField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section> </section>
</div> </div>
</template> </template>

View File

@@ -70,15 +70,9 @@
<div class="px-4 py-4 space-y-5"> <div class="px-4 py-4 space-y-5">
<section v-if="isRoot" class="space-y-3"> <section v-if="isRoot" class="space-y-3">
<div class="flex items-center justify-between gap-2"> <h4 :class="headingClass">
<h4 :class="headingClass"> {{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }} </h4>
</h4>
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!(node.customFields?.length)" class="text-xs text-gray-500"> <p v-if="!(node.customFields?.length)" class="text-xs text-gray-500">
Aucun champ n'a encore été défini. Aucun champ n'a encore été défini.
</p> </p>
@@ -155,18 +149,16 @@
</div> </div>
</div> </div>
</div> </div>
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section> </section>
<section v-if="isRoot" class="space-y-3"> <section v-if="isRoot" class="space-y-3">
<div class="flex items-center justify-between gap-2"> <h4 :class="headingClass">
<h4 :class="headingClass"> {{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }} </h4>
</h4>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!(node.products?.length)" class="text-xs text-gray-500"> <p v-if="!(node.products?.length)" class="text-xs text-gray-500">
Aucun produit défini. Aucun produit défini.
</p> </p>
@@ -228,18 +220,16 @@
</div> </div>
</div> </div>
</div> </div>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section> </section>
<section v-if="isRoot" class="space-y-3"> <section v-if="isRoot" class="space-y-3">
<div class="flex items-center justify-between gap-2"> <h4 :class="headingClass">
<h4 :class="headingClass"> {{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }} </h4>
</h4>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!(node.pieces?.length)" class="text-xs text-gray-500"> <p v-if="!(node.pieces?.length)" class="text-xs text-gray-500">
Aucune pièce définie. Aucune pièce définie.
</p> </p>
@@ -302,21 +292,14 @@
</div> </div>
</div> </div>
</div> </div>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section> </section>
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3"> <section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
<div class="flex items-center justify-between gap-2"> <h4 :class="headingClass">Sous-composants</h4>
<h4 :class="headingClass">Sous-composants</h4>
<button
v-if="canManageSubcomponents && !restrictedMode"
type="button"
class="btn btn-outline btn-xs"
@click="addSubComponent"
>
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-gray-500"> <p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-gray-500">
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle. Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
</p> </p>
@@ -357,6 +340,15 @@
/> />
</div> </div>
</div> </div>
<button
v-if="canManageSubcomponents && !restrictedMode"
type="button"
class="btn btn-outline btn-xs"
@click="addSubComponent"
>
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section> </section>
</div> </div>
</div> </div>
@@ -540,11 +532,6 @@ const getPieceTypeLabel = (id?: string) => {
return formatModelTypeOption(pieceTypeMap.value.get(id)) return formatModelTypeOption(pieceTypeMap.value.get(id))
} }
const _getProductTypeLabel = (id?: string) => {
if (!id) return ''
return formatModelTypeOption(productTypeMap.value.get(id))
}
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) => const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type) formatModelTypeOption(type)
@@ -579,19 +566,6 @@ const syncComponentType = (component: EditableStructureNode) => {
} }
return return
} }
if (props.lockType && props.isRoot) {
if (props.lockedTypeLabel) {
component.typeComposantLabel = props.lockedTypeLabel
if (!component.alias || component.alias === component.typeComposantLabel) {
component.alias = props.lockedTypeLabel
}
}
if (component.typeComposantId) {
const option = componentTypeMap.value.get(component.typeComposantId)
component.familyCode = option?.code ?? component.familyCode
}
return
}
const id = typeof component.typeComposantId === 'string' const id = typeof component.typeComposantId === 'string'
? component.typeComposantId ? component.typeComposantId
: '' : ''
@@ -751,11 +725,6 @@ const handleProductTypeSelect = (product: ComponentModelProduct & Record<string,
product.familyCode = option.code ?? product.familyCode ?? '' product.familyCode = option.code ?? product.familyCode ?? ''
} }
const customFieldDragState = ref({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const reindexCustomFields = () => { const reindexCustomFields = () => {
if (!Array.isArray(props.node.customFields)) { if (!Array.isArray(props.node.customFields)) {
return return
@@ -768,59 +737,15 @@ const reindexCustomFields = () => {
}) })
} }
const resetCustomFieldDragState = () => { const customFieldDrag = useDragReorder(
customFieldDragState.value.draggingIndex = null () => props.node.customFields,
customFieldDragState.value.dropTargetIndex = null { onReorder: reindexCustomFields },
} )
const onCustomFieldDragStart = customFieldDrag.onDragStart
const onCustomFieldDragStart = (index: number, event: DragEvent) => { const onCustomFieldDragEnter = customFieldDrag.onDragEnter
customFieldDragState.value.draggingIndex = index const onCustomFieldDrop = customFieldDrag.onDrop
customFieldDragState.value.dropTargetIndex = index const onCustomFieldDragEnd = customFieldDrag.onDragEnd
if (event.dataTransfer) { const customFieldReorderClass = customFieldDrag.reorderClass
event.dataTransfer.effectAllowed = 'move'
}
}
const onCustomFieldDragEnter = (index: number) => {
if (customFieldDragState.value.draggingIndex === null) {
return
}
customFieldDragState.value.dropTargetIndex = index
}
const onCustomFieldDrop = (index: number) => {
if (!Array.isArray(props.node.customFields)) {
resetCustomFieldDragState()
return
}
const from = customFieldDragState.value.draggingIndex
const to = index
if (from === null || to === null) {
resetCustomFieldDragState()
return
}
moveItemInPlace(props.node.customFields, from, to)
reindexCustomFields()
resetCustomFieldDragState()
}
const onCustomFieldDragEnd = () => {
resetCustomFieldDragState()
}
const customFieldReorderClass = (index: number) => {
if (customFieldDragState.value.draggingIndex === index) {
return 'border-dashed border-primary'
}
if (
customFieldDragState.value.draggingIndex !== null &&
customFieldDragState.value.dropTargetIndex === index &&
customFieldDragState.value.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const addCustomField = () => { const addCustomField = () => {
ensureArray('customFields') ensureArray('customFields')
@@ -893,197 +818,32 @@ const removeSubComponent = (index: number) => {
props.node.subcomponents.splice(index, 1) props.node.subcomponents.splice(index, 1)
} }
const draggingPieceIndex = ref<number | null>(null) const pieceDrag = useDragReorder(() => props.node.pieces)
const pieceDropTargetIndex = ref<number | null>(null) const onPieceDragStart = pieceDrag.onDragStart
const draggingProductIndex = ref<number | null>(null) const onPieceDragEnter = pieceDrag.onDragEnter
const productDropTargetIndex = ref<number | null>(null) const onPieceDragOver = pieceDrag.onDragOver
const draggingSubcomponentIndex = ref<number | null>(null) const onPieceDrop = pieceDrag.onDrop
const subcomponentDropTargetIndex = ref<number | null>(null) const onPieceDragEnd = pieceDrag.onDragEnd
const pieceReorderClass = pieceDrag.reorderClass
const moveItemInPlace = <T,>(list: T[], from: number, to: number) => { const productDrag = useDragReorder(() => props.node.products)
if (from === to) { const onProductDragStart = productDrag.onDragStart
return const onProductDragEnter = productDrag.onDragEnter
} const onProductDragOver = productDrag.onDragOver
if (from < 0 || to < 0 || from >= list.length || to >= list.length) { const onProductDrop = productDrag.onDrop
return const onProductDragEnd = productDrag.onDragEnd
} const productReorderClass = productDrag.reorderClass
const updated = list.slice()
const [item] = updated.splice(from, 1)
if (item === undefined) return
updated.splice(to, 0, item)
list.splice(0, list.length, ...updated)
}
const resetPieceDragState = () => { const subcomponentDrag = useDragReorder(
draggingPieceIndex.value = null () => props.node.subcomponents,
pieceDropTargetIndex.value = null { draggingClass: 'ring-2 ring-primary', dropTargetClass: 'ring-2 ring-primary/70' },
} )
const onSubcomponentDragStart = subcomponentDrag.onDragStart
const resetProductDragState = () => { const onSubcomponentDragEnter = subcomponentDrag.onDragEnter
draggingProductIndex.value = null const onSubcomponentDragOver = subcomponentDrag.onDragOver
productDropTargetIndex.value = null const onSubcomponentDrop = subcomponentDrag.onDrop
} const onSubcomponentDragEnd = subcomponentDrag.onDragEnd
const subcomponentReorderClass = subcomponentDrag.reorderClass
const onPieceDragStart = (index: number, event: DragEvent) => {
draggingPieceIndex.value = index
pieceDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onPieceDragEnter = (index: number) => {
if (draggingPieceIndex.value === null) {
return
}
pieceDropTargetIndex.value = index
}
const onPieceDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onPieceDrop = (index: number) => {
if (!Array.isArray(props.node.pieces)) {
resetPieceDragState()
return
}
const from = draggingPieceIndex.value
const to = index
if (from === null || to === null) {
resetPieceDragState()
return
}
moveItemInPlace(props.node.pieces, from, to)
resetPieceDragState()
}
const onPieceDragEnd = () => {
resetPieceDragState()
}
const pieceReorderClass = (index: number) => {
if (draggingPieceIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingPieceIndex.value !== null &&
pieceDropTargetIndex.value === index &&
draggingPieceIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const onProductDragStart = (index: number, event: DragEvent) => {
draggingProductIndex.value = index
productDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onProductDragEnter = (index: number) => {
if (draggingProductIndex.value === null) {
return
}
productDropTargetIndex.value = index
}
const onProductDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onProductDrop = (index: number) => {
if (!Array.isArray(props.node.products)) {
resetProductDragState()
return
}
const from = draggingProductIndex.value
const to = index
if (from === null || to === null) {
resetProductDragState()
return
}
moveItemInPlace(props.node.products, from, to)
resetProductDragState()
}
const onProductDragEnd = () => {
resetProductDragState()
}
const productReorderClass = (index: number) => {
if (draggingProductIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingProductIndex.value !== null &&
productDropTargetIndex.value === index &&
draggingProductIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const resetSubcomponentDragState = () => {
draggingSubcomponentIndex.value = null
subcomponentDropTargetIndex.value = null
}
const onSubcomponentDragStart = (index: number, event: DragEvent) => {
draggingSubcomponentIndex.value = index
subcomponentDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onSubcomponentDragEnter = (index: number) => {
if (draggingSubcomponentIndex.value === null) {
return
}
subcomponentDropTargetIndex.value = index
}
const onSubcomponentDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onSubcomponentDrop = (index: number) => {
if (!Array.isArray(props.node.subcomponents)) {
resetSubcomponentDragState()
return
}
const from = draggingSubcomponentIndex.value
const to = index
if (from === null || to === null) {
resetSubcomponentDragState()
return
}
moveItemInPlace(props.node.subcomponents, from, to)
resetSubcomponentDragState()
}
const onSubcomponentDragEnd = () => {
resetSubcomponentDragState()
}
const subcomponentReorderClass = (index: number) => {
if (draggingSubcomponentIndex.value === index) {
return 'ring-2 ring-primary'
}
if (
draggingSubcomponentIndex.value !== null &&
subcomponentDropTargetIndex.value === index &&
draggingSubcomponentIndex.value !== index
) {
return 'ring-2 ring-primary/70'
}
return ''
}
watch( watch(
canManageSubcomponents, canManageSubcomponents,

View File

@@ -0,0 +1,83 @@
<template>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(field, index) in fields"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="disabled"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="disabled"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
</div>
</div>
</template>
<script setup lang="ts">
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
defineProps<{
fields: CustomFieldInput[]
disabled?: boolean
}>()
</script>

View File

@@ -63,7 +63,7 @@
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) --> <!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
<template v-else> <template v-else>
<div class="overflow-x-auto relative"> <div class="overflow-x-auto overflow-y-clip relative">
<!-- Loading overlay (keeps table & filter inputs visible) --> <!-- Loading overlay (keeps table & filter inputs visible) -->
<div <div
v-if="loading && hasFilterableColumns" v-if="loading && hasFilterableColumns"

View File

@@ -0,0 +1,97 @@
<template>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-base-content">Historique</h2>
<p class="text-xs text-base-content/70">
Qui a changé quoi, et quand.
</p>
</div>
<span v-if="entries.length" class="badge badge-outline">
{{ entries.length }} entrée{{ entries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de l'historique…
</div>
<div v-else-if="error" class="alert alert-warning">
<span>{{ error }}</span>
</div>
<p v-else-if="entries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in entries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="diffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in diffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
</template>
<script setup lang="ts">
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries,
type HistoryDiffEntry,
} from '~/shared/utils/historyDisplayUtils'
interface HistoryEntry {
id: string
action: string
createdAt: string
actor?: { label?: string } | null
diff?: Record<string, { from?: unknown; to?: unknown }> | null
snapshot?: { name?: string } | null
}
const props = defineProps<{
entries: HistoryEntry[]
loading?: boolean
error?: string | null
fieldLabels: Record<string, string>
}>()
const diffEntries = (entry: HistoryEntry): HistoryDiffEntry[] =>
historyDiffEntries(entry, props.fieldLabels)
</script>

View File

@@ -3,31 +3,21 @@
<div class="card-body"> <div class="card-body">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title">Composants</h2> <h2 class="card-title">Composants</h2>
<div class="flex items-center gap-2"> <button
<button type="button"
v-if="isEditMode" class="btn btn-ghost btn-sm gap-2"
type="button" @click="$emit('toggle-collapse')"
class="btn btn-sm md:btn-md btn-primary" :title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
@click="$emit('add-component')" >
> <IconLucideChevronRight
Ajouter un composant class="w-5 h-5 transition-transform"
</button> :class="collapsed ? 'rotate-0' : 'rotate-90'"
<button aria-hidden="true"
type="button" />
class="btn btn-ghost btn-sm gap-2" <span class="text-sm">
@click="$emit('toggle-collapse')" {{ collapsed ? 'Tout déplier' : 'Tout replier' }}
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'" </span>
> </button>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
</div> </div>
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4"> <div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
@@ -54,6 +44,15 @@
/> />
</div> </div>
</div> </div>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-component')"
>
Ajouter un composant
</button>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -3,31 +3,21 @@
<div class="card-body"> <div class="card-body">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title">Pièces de la machine</h2> <h2 class="card-title">Pièces de la machine</h2>
<div class="flex items-center gap-2"> <button
<button type="button"
v-if="isEditMode" class="btn btn-ghost btn-sm gap-2"
type="button" @click="$emit('toggle-collapse')"
class="btn btn-sm md:btn-md btn-primary" :title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
@click="$emit('add-piece')" >
> <IconLucideChevronRight
Ajouter une pièce class="w-5 h-5 transition-transform"
</button> :class="collapsed ? 'rotate-0' : 'rotate-90'"
<button aria-hidden="true"
type="button" />
class="btn btn-ghost btn-sm gap-2" <span class="text-sm">
@click="$emit('toggle-collapse')" {{ collapsed ? 'Tout déplier' : 'Tout replier' }}
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'" </span>
> </button>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
</div> </div>
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4"> <div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
@@ -54,6 +44,15 @@
/> />
</div> </div>
</div> </div>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-piece')"
>
Ajouter une pièce
</button>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -15,19 +15,9 @@
Produits sélectionnés directement pour cette machine. Produits sélectionnés directement pour cette machine.
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <span class="badge badge-outline" v-if="products.length">
<button {{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
v-if="isEditMode" </span>
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-product')"
>
Ajouter un produit
</button>
<span class="badge badge-outline" v-if="products.length">
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
</span>
</div>
</div> </div>
<div v-if="products.length" class="space-y-3"> <div v-if="products.length" class="space-y-3">
@@ -117,6 +107,15 @@
<p v-else class="text-xs text-gray-500"> <p v-else class="text-xs text-gray-500">
Aucun produit n'a été associé directement à cette machine. Aucun produit n'a été associé directement à cette machine.
</p> </p>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-product')"
>
Ajouter un produit
</button>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,109 @@
import { ref } from 'vue'
interface DragReorderHandlers {
draggingIndex: Ref<number | null>
dropTargetIndex: Ref<number | null>
onDragStart: (index: number, event: DragEvent) => void
onDragEnter: (index: number) => void
onDragOver: (event: DragEvent) => void
onDrop: (index: number) => void
onDragEnd: () => void
reorderClass: (index: number) => string
reset: () => void
}
interface DragReorderOptions {
draggingClass?: string
dropTargetClass?: string
onReorder?: () => void
}
function moveItemInPlace<T>(list: T[], from: number, to: number): void {
if (from === to) return
if (from < 0 || to < 0 || from >= list.length || to >= list.length) return
const updated = list.slice()
const [item] = updated.splice(from, 1)
if (item === undefined) return
updated.splice(to, 0, item)
list.splice(0, list.length, ...updated)
}
export function useDragReorder(
getList: () => unknown[] | undefined,
options: DragReorderOptions = {},
): DragReorderHandlers {
const {
draggingClass = 'border-dashed border-primary',
dropTargetClass = 'border-primary border-dashed bg-primary/5',
onReorder,
} = options
const draggingIndex = ref<number | null>(null)
const dropTargetIndex = ref<number | null>(null)
const reset = () => {
draggingIndex.value = null
dropTargetIndex.value = null
}
const onDragStart = (index: number, event: DragEvent) => {
draggingIndex.value = index
dropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (draggingIndex.value === null) return
dropTargetIndex.value = index
}
const onDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onDrop = (index: number) => {
const list = getList()
if (!Array.isArray(list)) {
reset()
return
}
const from = draggingIndex.value
if (from === null) {
reset()
return
}
moveItemInPlace(list, from, index)
onReorder?.()
reset()
}
const onDragEnd = () => {
reset()
}
const reorderClass = (index: number): string => {
if (draggingIndex.value === index) return draggingClass
if (
draggingIndex.value !== null
&& dropTargetIndex.value === index
&& draggingIndex.value !== index
) {
return dropTargetClass
}
return ''
}
return {
draggingIndex,
dropTargetIndex,
onDragStart,
onDragEnter,
onDragOver,
onDrop,
onDragEnd,
reorderClass,
reset,
}
}

View File

@@ -69,6 +69,22 @@ const badgeClass = (type: ChangeType) => {
} }
const releases: Release[] = [ const releases: Release[] = [
{
version: 'v1.8.1',
date: '2026-03-05',
changes: [
{ type: 'feat', text: 'Composant DataTable générique avec tri, recherche, pagination et filtres server-side toutes les pages catalogue migrées vers ce composant partagé' },
{ type: 'feat', text: 'Messages d\'erreur humanisés : les erreurs backend sont traduites en messages compréhensibles pour l\'utilisateur final' },
{ type: 'feat', text: 'Modal d\'ajout d\'entités aux machines : ajout direct de composants, pièces et produits depuis la fiche machine' },
{ type: 'feat', text: 'Filtres SearchFilter ipartial sur les noms de types de modèles et commentaires côté API' },
{ type: 'feat', text: 'Suppression du système TypeMachine (squelettes machines) : les champs personnalisés sont désormais liés directement à chaque machine' },
{ type: 'feat', text: 'Simplification de la création de machines : plus besoin de sélectionner un squelette, ajout direct des entités' },
{ type: 'fix', text: 'Suppression catalogue pièces/composants : confirmation avec liste des éléments supprimés en cascade (documents, liaisons machine, champs personnalisés) au lieu de bloquer la suppression' },
{ type: 'fix', text: 'Affichage des catégories sur les pages d\'édition (produit, composant, pièce) : correction de « Catégorie inconnue » causée par un import obsolète dans ModelType' },
{ type: 'fix', text: 'Recherche insensible à la casse sur les commentaires et documents (partial → ipartial)' },
{ type: 'chore', text: 'Suppression des pages squelettes machines (/machine-skeleton, /type) et composants associés' },
],
},
{ {
version: 'v1.8.0', version: 'v1.8.0',
date: '2026-03-03', date: '2026-03-03',

View File

@@ -124,16 +124,15 @@ import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable' import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const { showError } = useToast() const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const loadingComposants = computed(() => loadingComposantsRef.value)
const table = useDataTable( const table = useDataTable(
{ fetchData: fetchComposants }, { fetchData: fetchComposants },
@@ -180,63 +179,24 @@ async function fetchComposants() {
}) })
} }
const resolvePrimaryDocument = (component: Record<string, any>) => {
const documents = Array.isArray(component?.documents) ? component.documents : []
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
const image = withPath.find((doc: any) => isImageDocument(doc))
if (image) return image
return withPath[0] ?? normalized[0] ?? null
}
const resolvePreviewAlt = (component: Record<string, any>) => {
const parts = [component?.name, component?.reference].filter(Boolean)
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
const resolveComponentType = (component: Record<string, any>) => { const resolveComponentType = (component: Record<string, any>) => {
if (component?.typeComposant?.name) return component.typeComposant.name if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel if (component?.typeComposantLabel) return component.typeComposantLabel
return '—' return '—'
} }
const resolveDeleteGuard = (component: Record<string, any>) => { const { confirm } = useConfirm()
const blockingReasons: string[] = []
const machineLinks = Array.isArray(component?.machineLinks) ? component.machineLinks.length : component?.machineLinksCount ?? 0
const documents = Array.isArray(component?.documents) ? component.documents.length : component?.documentsCount ?? 0
const customFields = Array.isArray(component?.customFieldValues) ? component.customFieldValues.length : component?.customFieldValuesCount ?? 0
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
return { blockingReasons, hasCustomFields: customFields > 0 }
}
const handleDeleteComponent = async (component: Record<string, any>) => { const handleDeleteComponent = async (component: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
if (blockingReasons.length) {
showError(`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
return
}
const componentName = component?.name || 'ce composant' const componentName = component?.name || 'ce composant'
const confirmLines = [`Voulez-vous vraiment supprimer ${componentName} ?`] const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
if (hasCustomFields) { const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
}
const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
if (!confirmed) return if (!confirmed) return
await deleteComposant(component.id) await deleteComposant(component.id)
fetchComposants() fetchComposants()
} }
const formatDate = (dateStr: string) => { const formatDate = formatFrenchDate
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)
}
onMounted(async () => { onMounted(async () => {
await Promise.all([fetchComposants(), loadComponentTypes()]) await Promise.all([fetchComposants(), loadComponentTypes()])

View File

@@ -275,78 +275,7 @@
Mettez à jour les valeurs propres à ce composant. Mettez à jour les valeurs propres à ce composant.
</p> </p>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="!canEdit || saving"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
</div>
</div>
</div> </div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"> <div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -449,73 +378,12 @@
</p> </p>
</div> </div>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"> <EntityHistorySection
<header class="flex items-center justify-between gap-3"> :entries="history"
<div> :loading="historyLoading"
<h2 class="font-semibold text-base-content">Historique</h2> :error="historyError"
<p class="text-xs text-base-content/70"> :field-labels="historyFieldLabels"
Qui a changé quoi, et quand. />
</p>
</div>
<span v-if="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in historyEntries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
<div class="flex flex-col gap-3 md:flex-row md:justify-end"> <div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }"> <NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
@@ -559,7 +427,7 @@ import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory' import { useComponentHistory } from '~/composables/useComponentHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory' import type { ComponentModelStructure } from '~/shared/types/inventory'
@@ -567,7 +435,6 @@ import type { ModelType } from '~/services/modelTypes'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
import { import {
type CustomFieldInput, type CustomFieldInput,
fieldKey,
buildCustomFieldInputs, buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled, requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues, saveCustomFieldValues as _saveCustomFieldValues,
@@ -580,11 +447,6 @@ import {
documentThumbnailClass, documentThumbnailClass,
downloadDocument, downloadDocument,
} from '~/shared/utils/documentDisplayUtils' } from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
interface ComponentCatalogType extends ModelType { interface ComponentCatalogType extends ModelType {
structure: ComponentModelStructure | null structure: ComponentModelStructure | null
@@ -622,8 +484,6 @@ const componentDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null) const previewDocument = ref<any | null>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = { const historyFieldLabels: Record<string, string> = {
name: 'Nom', name: 'Nom',
reference: 'Référence', reference: 'Référence',
@@ -634,8 +494,6 @@ const historyFieldLabels: Record<string, string> = {
constructeurIds: 'Fournisseurs', constructeurIds: 'Fournisseurs',
} }
const historyDiffEntries = (entry: ComponentHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const selectedTypeId = ref<string>('') const selectedTypeId = ref<string>('')
const editionForm = reactive({ const editionForm = reactive({
name: '' as string, name: '' as string,

View File

@@ -241,78 +241,7 @@
Renseignez les valeurs propres à ce composant selon le squelette choisi. Renseignez les valeurs propres à ce composant selon le squelette choisi.
</p> </p>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="!canEdit || submitting"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
</div>
</div>
</div> </div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"> <div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -396,7 +325,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { get } = useApi() const { get } = useApi()
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes, loadingComponentTypes: loadingTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes() const { productTypes, loadProductTypes } = useProductTypes()
const { const {
@@ -487,7 +416,6 @@ watch(selectedTypeId, (id) => {
router.replace({ path: route.path, query: nextQuery }).catch(() => {}) router.replace({ path: route.path, query: nextQuery }).catch(() => {})
}) })
const loadingTypes = computed(() => loadingComponentTypes.value)
const componentTypeList = computed<ComponentCatalogType[]>(() => const componentTypeList = computed<ComponentCatalogType[]>(() =>
(componentTypes.value || []) (componentTypes.value || [])
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[], .filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
@@ -1026,8 +954,6 @@ interface CustomFieldInput {
orderIndex: number orderIndex: number
} }
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldValueId || field.id || `${field.name}-${index}`
const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null): CustomFieldInput[] => { const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null): CustomFieldInput[] => {
if (!structure || typeof structure !== 'object') { if (!structure || typeof structure !== 'object') {

View File

@@ -100,6 +100,7 @@ import { useConstructeurs } from '~/composables/useConstructeurs'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedValue } from '~/composables/usePersistedValue' import { usePersistedValue } from '~/composables/usePersistedValue'
import { formatPhone } from '~/utils/formatters/phone' import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
@@ -153,16 +154,7 @@ const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value) await searchConstructeurs(searchTerm.value)
}, 300) }, 300)
const formatDate = (dateStr) => { const formatDate = formatFrenchDate
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 formatPhoneDisplay = (value) => {
const formatted = formatPhone(value) const formatted = formatPhone(value)

View File

@@ -3,7 +3,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="documentRows" :documents="documents"
@close="closePreview" @close="closePreview"
/> />
@@ -11,7 +11,7 @@
<div class="card-body space-y-6"> <div class="card-body space-y-6">
<DataTable <DataTable
:columns="columns" :columns="columns"
:rows="documentRows" :rows="documents"
:loading="loading" :loading="loading"
:sort="table.sort.value" :sort="table.sort.value"
:pagination="paginationState" :pagination="paginationState"
@@ -148,7 +148,6 @@ const attachmentFilter = table.filters.filter as Ref<string>
const previewDocument = ref<any>(null) const previewDocument = ref<any>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const documentRows = computed(() => documents.value)
const documentsOnPage = computed(() => documents.value.length) const documentsOnPage = computed(() => documents.value.length)
const paginationState = table.pagination(total, documentsOnPage) const paginationState = table.pagination(total, documentsOnPage)

View File

@@ -147,16 +147,15 @@ import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable' import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const { showError } = useToast() const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const loadingPieces = computed(() => loadingPiecesRef.value)
const table = useDataTable( const table = useDataTable(
{ fetchData: fetchPieces }, { fetchData: fetchPieces },
@@ -205,115 +204,27 @@ async function fetchPieces() {
}) })
} }
const resolvePrimaryDocument = (piece: Record<string, any>) => {
const documents = Array.isArray(piece?.documents) ? piece.documents : []
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
const image = withPath.find((doc: any) => isImageDocument(doc))
if (image) return image
return withPath[0] ?? normalized[0] ?? null
}
const resolvePreviewAlt = (piece: Record<string, any>) => {
const parts = [piece?.name, piece?.reference].filter(Boolean)
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
const resolvePieceType = (piece: Record<string, any>) => { const resolvePieceType = (piece: Record<string, any>) => {
if (piece?.typePiece?.name) return piece.typePiece.name if (piece?.typePiece?.name) return piece.typePiece.name
if (piece?.typePieceLabel) return piece.typePieceLabel if (piece?.typePieceLabel) return piece.typePieceLabel
return '—' return '—'
} }
const MAX_VISIBLE_SUPPLIERS = 3 const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const resolvePieceSuppliers = (piece: Record<string, any>) => { const { confirm } = useConfirm()
const names: string[] = []
const seen = new Set<string>()
const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') return
const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) return
const key = normalized.toLowerCase()
if (seen.has(key)) return
seen.add(key)
names.push(normalized)
}
const collectConstructeurs = (value: unknown): void => {
if (!value) return
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
if (typeof value === 'string') { pushName(value); return }
if (typeof value === 'object') {
const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) collectConstructeurs(record.constructeur)
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
}
}
const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') return
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
}
collectConstructeurs(piece?.constructeurs)
collectConstructeurs(piece?.constructeur)
collectConstructeurs(piece?.product?.constructeurs)
collectConstructeurs(piece?.product?.constructeur)
collectFromLabel(piece?.constructeursLabel)
collectFromLabel(piece?.supplierLabel)
collectFromLabel(piece?.product?.constructeursLabel)
collectFromLabel(piece?.product?.supplierLabel)
return names
}
const buildPieceSuppliersDisplay = (piece: Record<string, any>) => {
const suppliers = resolvePieceSuppliers(piece)
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0)
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
}
const resolveDeleteGuard = (piece: Record<string, any>) => {
const blockingReasons: string[] = []
const machineLinks = Array.isArray(piece?.machineLinks) ? piece.machineLinks.length : piece?.machineLinksCount ?? 0
const documents = Array.isArray(piece?.documents) ? piece.documents.length : piece?.documentsCount ?? 0
const customFields = Array.isArray(piece?.customFieldValues) ? piece.customFieldValues.length : piece?.customFieldValuesCount ?? 0
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
return { blockingReasons, hasCustomFields: customFields > 0 }
}
const handleDeletePiece = async (piece: Record<string, any>) => { const handleDeletePiece = async (piece: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece)
if (blockingReasons.length) {
showError(`Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
return
}
const pieceName = piece?.name || 'cette pièce' const pieceName = piece?.name || 'cette pièce'
const confirmLines = [`Voulez-vous vraiment supprimer ${pieceName} ?`] const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
if (hasCustomFields) { const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
}
const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
if (!confirmed) return if (!confirmed) return
await deletePiece(piece.id) await deletePiece(piece.id)
fetchPieces() fetchPieces()
} }
const formatDate = (dateStr: string) => { const formatDate = formatFrenchDate
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)
}
onMounted(async () => { onMounted(async () => {
await Promise.all([fetchPieces(), loadPieceTypes()]) await Promise.all([fetchPieces(), loadPieceTypes()])

View File

@@ -222,78 +222,7 @@
Mettez à jour les valeurs propres à cette pièce. Mettez à jour les valeurs propres à cette pièce.
</p> </p>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="!canEdit || saving"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
</div>
</div>
</div> </div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"> <div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -396,73 +325,12 @@
</p> </p>
</div> </div>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"> <EntityHistorySection
<header class="flex items-center justify-between gap-3"> :entries="history"
<div> :loading="historyLoading"
<h2 class="font-semibold text-base-content">Historique</h2> :error="historyError"
<p class="text-xs text-base-content/70"> :field-labels="historyFieldLabels"
Qui a changé quoi, et quand. />
</p>
</div>
<span v-if="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in historyEntries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
<div class="flex flex-col gap-3 md:flex-row md:justify-end"> <div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }"> <NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
@@ -502,7 +370,7 @@ import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory' import { usePieceHistory } from '~/composables/usePieceHistory'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils' import { formatPieceStructurePreview } from '~/shared/modelUtils'
@@ -512,7 +380,6 @@ import type { ModelType } from '~/services/modelTypes'
import { getModelType } from '~/services/modelTypes' import { getModelType } from '~/services/modelTypes'
import { import {
type CustomFieldInput, type CustomFieldInput,
fieldKey,
buildCustomFieldInputs, buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled, requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues, saveCustomFieldValues as _saveCustomFieldValues,
@@ -525,11 +392,6 @@ import {
documentThumbnailClass, documentThumbnailClass,
downloadDocument, downloadDocument,
} from '~/shared/utils/documentDisplayUtils' } from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
interface PieceCatalogType extends ModelType { interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null structure: PieceModelStructure | null
@@ -563,8 +425,6 @@ const pieceDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null) const previewDocument = ref<any | null>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = { const historyFieldLabels: Record<string, string> = {
name: 'Nom', name: 'Nom',
reference: 'Référence', reference: 'Référence',
@@ -575,9 +435,6 @@ const historyFieldLabels: Record<string, string> = {
constructeurIds: 'Fournisseurs', constructeurIds: 'Fournisseurs',
} }
const historyDiffEntries = (entry: PieceHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const selectedTypeId = ref<string>('') const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null) const pieceTypeDetails = ref<any | null>(null)
const editionForm = reactive({ const editionForm = reactive({

View File

@@ -193,78 +193,7 @@
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné. Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
</p> </p>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="!canEdit || submitting"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
</div>
</div>
</div> </div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"> <div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -324,7 +253,6 @@ import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inve
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
import { import {
type CustomFieldInput, type CustomFieldInput,
fieldKey,
normalizeCustomFieldInputs, normalizeCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled, requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues, saveCustomFieldValues as _saveCustomFieldValues,
@@ -338,7 +266,7 @@ interface PieceCatalogType extends ModelType {
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
const { createPiece } = usePieces() const { createPiece } = usePieces()
const toast = useToast() const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
@@ -385,7 +313,6 @@ watch(selectedTypeId, (id) => {
router.replace({ path: route.path, query: nextQuery }).catch(() => {}) router.replace({ path: route.path, query: nextQuery }).catch(() => {})
}) })
const loadingTypes = computed(() => loadingPieceTypes.value)
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[]) const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
const typeOptionLabel = (type?: PieceCatalogType) => const typeOptionLabel = (type?: PieceCatalogType) =>

View File

@@ -36,7 +36,7 @@
v-else v-else
:columns="columns" :columns="columns"
:rows="productRows" :rows="productRows"
:loading="loadingProducts" :loading="loading"
:sort="table.sort.value" :sort="table.sort.value"
:pagination="paginationState" :pagination="paginationState"
:column-filters="table.columnFilters.value" :column-filters="table.columnFilters.value"
@@ -63,7 +63,7 @@
<template #cell-preview="{ row }"> <template #cell-preview="{ row }">
<DocumentThumbnail <DocumentThumbnail
:document="resolvePrimaryDocument(row.product)" :document="resolvePrimaryDocument(row.product, true)"
:alt="resolvePreviewAlt(row.product)" :alt="resolvePreviewAlt(row.product)"
/> />
</template> </template>
@@ -147,7 +147,8 @@ import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable' import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
@@ -169,7 +170,6 @@ const table = useDataTable(
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true }, { defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
) )
const loadingProducts = computed(() => loading.value)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null)) const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const columns = [ const columns = [
@@ -197,7 +197,7 @@ const productRows = computed(() =>
normalizedProducts.value.map(product => ({ normalizedProducts.value.map(product => ({
id: product.id, id: product.id,
product, product,
suppliers: buildSuppliersDisplay(product), suppliers: buildProductSuppliersDisplay(product),
})), })),
) )
@@ -225,85 +225,21 @@ const formatPrice = (value: any) => {
return Number.isNaN(number) ? '—' : priceFormatter.format(number) return Number.isNaN(number) ? '—' : priceFormatter.format(number)
} }
const MAX_VISIBLE_SUPPLIERS = 3 const buildProductSuppliersDisplay = (product: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(product))
const resolveProductSuppliers = (product: Record<string, any>) => {
const names: string[] = []
const seen = new Set<string>()
const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') return
const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) return
const key = normalized.toLowerCase()
if (seen.has(key)) return
seen.add(key)
names.push(normalized)
}
const collectConstructeurs = (value: unknown): void => {
if (!value) return
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
if (typeof value === 'string') { pushName(value); return }
if (typeof value === 'object') {
const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) collectConstructeurs(record.constructeur)
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
}
}
const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') return
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
}
collectConstructeurs(product?.constructeurs)
collectConstructeurs(product?.constructeur)
collectFromLabel(product?.constructeursLabel)
collectFromLabel(product?.supplierLabel)
collectFromLabel(product?.suppliers)
return names
}
const buildSuppliersDisplay = (product: Record<string, any>) => {
const suppliers = resolveProductSuppliers(product)
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0)
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
}
const resolvePrimaryDocument = (product: Record<string, any>) => {
const documents = Array.isArray(product?.documents) ? product.documents : []
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
if (!withPath.length) return normalized[0] ?? null
const images = withPath.filter((doc: any) => isImageDocument(doc))
if (images.length) return images[0]
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
return withPath[0]
}
const resolvePreviewAlt = (product: Record<string, any>) => {
const parts = [product?.name, product?.reference].filter(Boolean)
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
const reload = () => fetchProducts() const reload = () => fetchProducts()
const { confirm } = useConfirm() const { confirm } = useConfirm()
const confirmDelete = async (product: Record<string, any>) => { const confirmDelete = async (product: Record<string, any>) => {
const confirmed = await confirm({ const productName = product?.name || 'ce produit'
message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`, const message = buildDeleteMessage(productName, resolveDeleteImpact(product))
}) const confirmed = await confirm({ title: 'Supprimer le produit', message, dangerous: true })
if (!confirmed) return if (!confirmed) return
const result = await deleteProduct(product.id) const result = await deleteProduct(product.id)
if (result.success) { if (result.success) {
toast.showSuccess(`Produit "${product.name}" supprimé`) toast.showSuccess(`Produit "${productName}" supprimé`)
} }
} }

View File

@@ -133,78 +133,7 @@
Mettez à jour les valeurs propres à ce produit. Mettez à jour les valeurs propres à ce produit.
</p> </p>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="!canEdit || saving"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
</div>
</div>
</div> </div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"> <div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -303,73 +232,12 @@
</p> </p>
</div> </div>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"> <EntityHistorySection
<header class="flex items-center justify-between gap-3"> :entries="history"
<div> :loading="historyLoading"
<h2 class="font-semibold text-base-content">Historique</h2> :error="historyError"
<p class="text-xs text-base-content/70"> :field-labels="historyFieldLabels"
Qui a changé quoi, et quand. />
</p>
</div>
<span v-if="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in historyEntries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
<div class="flex flex-col gap-3 md:flex-row md:justify-end"> <div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }"> <NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
@@ -410,7 +278,7 @@ import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory' import { useProductHistory } from '~/composables/useProductHistory'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils' import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes' import { getModelType } from '~/services/modelTypes'
@@ -418,7 +286,6 @@ import type { ProductModelStructure } from '~/shared/types/inventory'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
import { import {
type CustomFieldInput, type CustomFieldInput,
fieldKey,
buildCustomFieldInputs, buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled, requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues, saveCustomFieldValues as _saveCustomFieldValues,
@@ -431,11 +298,6 @@ import {
documentThumbnailClass, documentThumbnailClass,
downloadDocument, downloadDocument,
} from '~/shared/utils/documentDisplayUtils' } from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
@@ -469,8 +331,6 @@ const productDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null) const previewDocument = ref<any | null>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = { const historyFieldLabels: Record<string, string> = {
name: 'Nom', name: 'Nom',
reference: 'Référence', reference: 'Référence',
@@ -479,9 +339,6 @@ const historyFieldLabels: Record<string, string> = {
constructeurIds: 'Fournisseurs', constructeurIds: 'Fournisseurs',
} }
const historyDiffEntries = (entry: ProductHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const refreshCustomFieldInputs = ( const refreshCustomFieldInputs = (
structureOverride?: ProductModelStructure | null, structureOverride?: ProductModelStructure | null,
valuesOverride?: any[] | null, valuesOverride?: any[] | null,

View File

@@ -119,78 +119,7 @@
Renseignez les valeurs propres à ce produit catalogue. Renseignez les valeurs propres à ce produit catalogue.
</p> </p>
</header> </header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="!canEdit || submitting"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
</div>
</div>
</div> </div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"> <div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -262,7 +191,7 @@ interface ProductCatalogType extends ModelType {
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes() const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
const { createProduct } = useProducts() const { createProduct } = useProducts()
const toast = useToast() const toast = useToast()
const { upsertCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue } = useCustomFields()
@@ -283,7 +212,6 @@ const uploadingDocuments = ref(false)
const customFieldInputs = ref<CustomFieldInput[]>([]) const customFieldInputs = ref<CustomFieldInput[]>([])
const loadingTypes = computed(() => loadingProductTypes.value)
const productTypeList = computed<ProductCatalogType[]>(() => const productTypeList = computed<ProductCatalogType[]>(() =>
(productTypes.value || []) as ProductCatalogType[], (productTypes.value || []) as ProductCatalogType[],
) )
@@ -354,9 +282,6 @@ const canSubmit = computed(() => Boolean(
!submitting.value, !submitting.value,
)) ))
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldId || field.id || `${field.name}-${index}`
const clearForm = () => { const clearForm = () => {
creationForm.name = '' creationForm.name = ''
creationForm.reference = '' creationForm.reference = ''

View File

@@ -0,0 +1,87 @@
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
/**
* Selects the best document for thumbnail preview from an entity's documents array.
* Default priority: PDF first, then images. Use `preferImages` to reverse.
*/
export const resolvePrimaryDocument = (entity: Record<string, any>, preferImages = false): any | null => {
const documents = Array.isArray(entity?.documents) ? entity.documents : []
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
if (!withPath.length) return normalized[0] ?? null
const first = preferImages ? isImageDocument : isPdfDocument
const second = preferImages ? isPdfDocument : isImageDocument
const a = withPath.find((doc: any) => first(doc))
if (a) return a
const b = withPath.find((doc: any) => second(doc))
if (b) return b
return withPath[0]
}
/**
* Builds alt text for a document preview thumbnail.
*/
export const resolvePreviewAlt = (entity: Record<string, any>): string => {
const parts = [entity?.name, entity?.reference].filter(Boolean)
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
/**
* Supplier name resolution: extracts unique supplier names from entity relations.
*/
export const resolveSupplierNames = (entity: Record<string, any>, nestedKey?: string): string[] => {
const names: string[] = []
const seen = new Set<string>()
const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') return
const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) return
const key = normalized.toLowerCase()
if (seen.has(key)) return
seen.add(key)
names.push(normalized)
}
const collectConstructeurs = (value: unknown): void => {
if (!value) return
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
if (typeof value === 'string') { pushName(value); return }
if (typeof value === 'object') {
const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) collectConstructeurs(record.constructeur)
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
}
}
const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') return
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
}
collectConstructeurs(entity?.constructeurs)
collectConstructeurs(entity?.constructeur)
collectFromLabel(entity?.constructeursLabel)
collectFromLabel(entity?.supplierLabel)
collectFromLabel(entity?.suppliers)
if (nestedKey && entity?.[nestedKey]) {
const nested = entity[nestedKey]
collectConstructeurs(nested?.constructeurs)
collectConstructeurs(nested?.constructeur)
collectFromLabel(nested?.constructeursLabel)
collectFromLabel(nested?.supplierLabel)
}
return names
}
const MAX_VISIBLE_SUPPLIERS = 3
export const buildSuppliersDisplay = (suppliers: string[]) => {
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0)
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
}

View File

@@ -0,0 +1,19 @@
export const resolveDeleteImpact = (entity: Record<string, any>): string[] => {
const impacts: string[] = []
const machineLinks = Array.isArray(entity?.machineLinks) ? entity.machineLinks.length : entity?.machineLinksCount ?? 0
const documents = Array.isArray(entity?.documents) ? entity.documents.length : entity?.documentsCount ?? 0
const customFields = Array.isArray(entity?.customFieldValues) ? entity.customFieldValues.length : entity?.customFieldValuesCount ?? 0
if (machineLinks > 0) impacts.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
if (documents > 0) impacts.push(`${documents} document${documents > 1 ? 's' : ''}`)
if (customFields > 0) impacts.push(`${customFields} valeur${customFields > 1 ? 's' : ''} de champs personnalisés`)
return impacts
}
export const buildDeleteMessage = (entityName: string, impacts: string[]): string => {
const lines = [`Voulez-vous vraiment supprimer « ${entityName} » ?`]
if (impacts.length) {
lines.push(`Cela supprimera également :\n• ${impacts.join('\n• ')}`)
}
lines.push('Cette action est irréversible.')
return lines.join('\n\n')
}

View File

@@ -2,6 +2,12 @@
* Formatte une date en respectant les conventions françaises (jj/mm/aaaa). * Formatte une date en respectant les conventions françaises (jj/mm/aaaa).
* Retourne "—" si la valeur est invalide ou absente. * Retourne "—" si la valeur est invalide ou absente.
*/ */
const frenchDateFormatter = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
export const formatFrenchDate = (value: Date | string | number | null | undefined): string => { export const formatFrenchDate = (value: Date | string | number | null | undefined): string => {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return '—' return '—'
@@ -12,9 +18,5 @@ export const formatFrenchDate = (value: Date | string | number | null | undefine
return '—' return '—'
} }
return new Intl.DateTimeFormat('fr-FR', { return frenchDateFormatter.format(date)
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date)
} }