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>
This commit is contained in:
2026-03-08 02:28:26 +01:00
parent 7b3eb1c5fc
commit de7be1b9d0
18 changed files with 464 additions and 1114 deletions

View File

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

View File

@@ -725,11 +725,6 @@ const handleProductTypeSelect = (product: ComponentModelProduct & Record<string,
product.familyCode = option.code ?? product.familyCode ?? ''
}
const customFieldDragState = ref({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const reindexCustomFields = () => {
if (!Array.isArray(props.node.customFields)) {
return
@@ -742,59 +737,15 @@ const reindexCustomFields = () => {
})
}
const resetCustomFieldDragState = () => {
customFieldDragState.value.draggingIndex = null
customFieldDragState.value.dropTargetIndex = null
}
const onCustomFieldDragStart = (index: number, event: DragEvent) => {
customFieldDragState.value.draggingIndex = index
customFieldDragState.value.dropTargetIndex = index
if (event.dataTransfer) {
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 customFieldDrag = useDragReorder(
() => props.node.customFields,
{ onReorder: reindexCustomFields },
)
const onCustomFieldDragStart = customFieldDrag.onDragStart
const onCustomFieldDragEnter = customFieldDrag.onDragEnter
const onCustomFieldDrop = customFieldDrag.onDrop
const onCustomFieldDragEnd = customFieldDrag.onDragEnd
const customFieldReorderClass = customFieldDrag.reorderClass
const addCustomField = () => {
ensureArray('customFields')
@@ -867,197 +818,32 @@ const removeSubComponent = (index: number) => {
props.node.subcomponents.splice(index, 1)
}
const draggingPieceIndex = ref<number | null>(null)
const pieceDropTargetIndex = ref<number | null>(null)
const draggingProductIndex = ref<number | null>(null)
const productDropTargetIndex = ref<number | null>(null)
const draggingSubcomponentIndex = ref<number | null>(null)
const subcomponentDropTargetIndex = ref<number | null>(null)
const pieceDrag = useDragReorder(() => props.node.pieces)
const onPieceDragStart = pieceDrag.onDragStart
const onPieceDragEnter = pieceDrag.onDragEnter
const onPieceDragOver = pieceDrag.onDragOver
const onPieceDrop = pieceDrag.onDrop
const onPieceDragEnd = pieceDrag.onDragEnd
const pieceReorderClass = pieceDrag.reorderClass
const moveItemInPlace = <T,>(list: T[], from: number, to: number) => {
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)
}
const productDrag = useDragReorder(() => props.node.products)
const onProductDragStart = productDrag.onDragStart
const onProductDragEnter = productDrag.onDragEnter
const onProductDragOver = productDrag.onDragOver
const onProductDrop = productDrag.onDrop
const onProductDragEnd = productDrag.onDragEnd
const productReorderClass = productDrag.reorderClass
const resetPieceDragState = () => {
draggingPieceIndex.value = null
pieceDropTargetIndex.value = null
}
const resetProductDragState = () => {
draggingProductIndex.value = null
productDropTargetIndex.value = null
}
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 ''
}
const subcomponentDrag = useDragReorder(
() => props.node.subcomponents,
{ draggingClass: 'ring-2 ring-primary', dropTargetClass: 'ring-2 ring-primary/70' },
)
const onSubcomponentDragStart = subcomponentDrag.onDragStart
const onSubcomponentDragEnter = subcomponentDrag.onDragEnter
const onSubcomponentDragOver = subcomponentDrag.onDragOver
const onSubcomponentDrop = subcomponentDrag.onDrop
const onSubcomponentDragEnd = subcomponentDrag.onDragEnd
const subcomponentReorderClass = subcomponentDrag.reorderClass
watch(
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

@@ -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

@@ -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

@@ -126,8 +126,9 @@ import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useDataTable } from '~/composables/useDataTable'
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 { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
@@ -178,23 +179,6 @@ 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>) => {
if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel
@@ -212,12 +196,7 @@ const handleDeleteComponent = async (component: Record<string, any>) => {
fetchComposants()
}
const formatDate = (dateStr: string) => {
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 formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchComposants(), loadComponentTypes()])

View File

@@ -275,78 +275,7 @@
Mettez à jour les valeurs propres à ce composant.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<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>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -449,73 +378,12 @@
</p>
</div>
<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="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>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<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 }">
@@ -559,7 +427,7 @@ import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
import { useComponentHistory } from '~/composables/useComponentHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory'
@@ -567,7 +435,6 @@ import type { ModelType } from '~/services/modelTypes'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
fieldKey,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
@@ -580,11 +447,6 @@ import {
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
interface ComponentCatalogType extends ModelType {
structure: ComponentModelStructure | null
@@ -622,8 +484,6 @@ const componentDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
@@ -634,8 +494,6 @@ const historyFieldLabels: Record<string, string> = {
constructeurIds: 'Fournisseurs',
}
const historyDiffEntries = (entry: ComponentHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const selectedTypeId = ref<string>('')
const editionForm = reactive({
name: '' as string,

View File

@@ -241,78 +241,7 @@
Renseignez les valeurs propres à ce composant selon le squelette choisi.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<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>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<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 { get } = useApi()
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
const { componentTypes, loadComponentTypes, loadingComponentTypes: loadingTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const {
@@ -487,7 +416,6 @@ watch(selectedTypeId, (id) => {
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
})
const loadingTypes = computed(() => loadingComponentTypes.value)
const componentTypeList = computed<ComponentCatalogType[]>(() =>
(componentTypes.value || [])
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
@@ -1026,8 +954,6 @@ interface CustomFieldInput {
orderIndex: number
}
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldValueId || field.id || `${field.name}-${index}`
const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null): CustomFieldInput[] => {
if (!structure || typeof structure !== 'object') {

View File

@@ -100,6 +100,7 @@ import { useConstructeurs } from '~/composables/useConstructeurs'
import { useToast } from '~/composables/useToast'
import { usePersistedValue } from '~/composables/usePersistedValue'
import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
const { canEdit } = usePermissions()
@@ -153,16 +154,7 @@ const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value)
}, 300)
const formatDate = (dateStr) => {
if (!dateStr) return '—'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date)
}
const formatDate = formatFrenchDate
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)

View File

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

View File

@@ -149,8 +149,9 @@ import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useDataTable } from '~/composables/useDataTable'
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 { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
@@ -203,80 +204,14 @@ 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>) => {
if (piece?.typePiece?.name) return piece.typePiece.name
if (piece?.typePieceLabel) return piece.typePieceLabel
return '—'
}
const MAX_VISIBLE_SUPPLIERS = 3
const resolvePieceSuppliers = (piece: 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(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 buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm()
@@ -289,12 +224,7 @@ const handleDeletePiece = async (piece: Record<string, any>) => {
fetchPieces()
}
const formatDate = (dateStr: string) => {
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 formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchPieces(), loadPieceTypes()])

View File

@@ -222,78 +222,7 @@
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<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>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -396,73 +325,12 @@
</p>
</div>
<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="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>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<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 }">
@@ -502,7 +370,7 @@ import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
import { usePieceHistory } from '~/composables/usePieceHistory'
import { extractRelationId } from '~/shared/apiRelations'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
@@ -512,7 +380,6 @@ import type { ModelType } from '~/services/modelTypes'
import { getModelType } from '~/services/modelTypes'
import {
type CustomFieldInput,
fieldKey,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
@@ -525,11 +392,6 @@ import {
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null
@@ -563,8 +425,6 @@ const pieceDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
@@ -575,9 +435,6 @@ const historyFieldLabels: Record<string, string> = {
constructeurIds: 'Fournisseurs',
}
const historyDiffEntries = (entry: PieceHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null)
const editionForm = reactive({

View File

@@ -193,78 +193,7 @@
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<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>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<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 CustomFieldInput,
fieldKey,
normalizeCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
@@ -338,7 +266,7 @@ interface PieceCatalogType extends ModelType {
const route = useRoute()
const router = useRouter()
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
const { createPiece } = usePieces()
const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
@@ -385,7 +313,6 @@ watch(selectedTypeId, (id) => {
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
})
const loadingTypes = computed(() => loadingPieceTypes.value)
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
const typeOptionLabel = (type?: PieceCatalogType) =>

View File

@@ -63,7 +63,7 @@
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.product)"
:document="resolvePrimaryDocument(row.product, true)"
:alt="resolvePreviewAlt(row.product)"
/>
</template>
@@ -147,8 +147,8 @@ import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable'
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()
@@ -197,7 +197,7 @@ const productRows = computed(() =>
normalizedProducts.value.map(product => ({
id: product.id,
product,
suppliers: buildSuppliersDisplay(product),
suppliers: buildProductSuppliersDisplay(product),
})),
)
@@ -225,72 +225,8 @@ const formatPrice = (value: any) => {
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
}
const MAX_VISIBLE_SUPPLIERS = 3
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 buildProductSuppliersDisplay = (product: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(product))
const reload = () => fetchProducts()

View File

@@ -133,78 +133,7 @@
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<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>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -303,73 +232,12 @@
</p>
</div>
<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="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>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<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 }">
@@ -410,7 +278,7 @@ import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
import { useProductHistory } from '~/composables/useProductHistory'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
@@ -418,7 +286,6 @@ import type { ProductModelStructure } from '~/shared/types/inventory'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
fieldKey,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
@@ -431,11 +298,6 @@ import {
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
const { canEdit } = usePermissions()
const route = useRoute()
@@ -469,8 +331,6 @@ const productDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
@@ -479,9 +339,6 @@ const historyFieldLabels: Record<string, string> = {
constructeurIds: 'Fournisseurs',
}
const historyDiffEntries = (entry: ProductHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const refreshCustomFieldInputs = (
structureOverride?: ProductModelStructure | null,
valuesOverride?: any[] | null,

View File

@@ -119,78 +119,7 @@
Renseignez les valeurs propres à ce produit catalogue.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<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>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<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 router = useRouter()
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
const { createProduct } = useProducts()
const toast = useToast()
const { upsertCustomFieldValue } = useCustomFields()
@@ -283,7 +212,6 @@ const uploadingDocuments = ref(false)
const customFieldInputs = ref<CustomFieldInput[]>([])
const loadingTypes = computed(() => loadingProductTypes.value)
const productTypeList = computed<ProductCatalogType[]>(() =>
(productTypes.value || []) as ProductCatalogType[],
)
@@ -354,9 +282,6 @@ const canSubmit = computed(() => Boolean(
!submitting.value,
))
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldId || field.id || `${field.name}-${index}`
const clearForm = () => {
creationForm.name = ''
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

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