diff --git a/app/components/DocumentUpload.vue b/app/components/DocumentUpload.vue
index 02920b5..ce9a188 100644
--- a/app/components/DocumentUpload.vue
+++ b/app/components/DocumentUpload.vue
@@ -130,7 +130,7 @@ const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
})
}
-const selectedFiles = computed(() => internalFiles.value)
+const selectedFiles = internalFiles
watch(
() => props.modelValue,
diff --git a/app/components/StructureNodeEditor.vue b/app/components/StructureNodeEditor.vue
index 57052ad..8ae67d7 100644
--- a/app/components/StructureNodeEditor.vue
+++ b/app/components/StructureNodeEditor.vue
@@ -725,11 +725,6 @@ const handleProductTypeSelect = (product: ComponentModelProduct & Record {
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(null)
-const pieceDropTargetIndex = ref(null)
-const draggingProductIndex = ref(null)
-const productDropTargetIndex = ref(null)
-const draggingSubcomponentIndex = ref(null)
-const subcomponentDropTargetIndex = ref(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 = (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,
diff --git a/app/components/common/CustomFieldInputGrid.vue b/app/components/common/CustomFieldInputGrid.vue
new file mode 100644
index 0000000..70c048f
--- /dev/null
+++ b/app/components/common/CustomFieldInputGrid.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
diff --git a/app/components/common/EntityHistorySection.vue b/app/components/common/EntityHistorySection.vue
new file mode 100644
index 0000000..d874353
--- /dev/null
+++ b/app/components/common/EntityHistorySection.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+ Chargement de l'historique…
+
+
+
+ {{ error }}
+
+
+
+ Aucun changement enregistré pour le moment.
+
+
+
+ -
+
+
+ {{ historyActionLabel(entry.action) }}
+
+ {{ formatHistoryDate(entry.createdAt) }}
+
+
+ Par {{ entry.actor?.label || 'Inconnu' }}
+
+
+
+ -
+ {{ diffEntry.label }}
+
+ {{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
+
+
+
+
+
+ {{ entry.snapshot.name }}
+
+
+
+
+
+
+
diff --git a/app/composables/useDragReorder.ts b/app/composables/useDragReorder.ts
new file mode 100644
index 0000000..4523e98
--- /dev/null
+++ b/app/composables/useDragReorder.ts
@@ -0,0 +1,109 @@
+import { ref } from 'vue'
+
+interface DragReorderHandlers {
+ draggingIndex: Ref
+ dropTargetIndex: Ref
+ 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(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(null)
+ const dropTargetIndex = ref(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,
+ }
+}
diff --git a/app/pages/component-catalog.vue b/app/pages/component-catalog.vue
index 5c96a92..711cd5c 100644
--- a/app/pages/component-catalog.vue
+++ b/app/pages/component-catalog.vue
@@ -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) => {
- 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) => {
- 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) => {
if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel
@@ -212,12 +196,7 @@ const handleDeleteComponent = async (component: Record) => {
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()])
diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue
index aea3a59..81a8850 100644
--- a/app/pages/component/[id]/edit.vue
+++ b/app/pages/component/[id]/edit.vue
@@ -275,78 +275,7 @@
Mettez à jour les valeurs propres à ce composant.
-
+
@@ -449,73 +378,12 @@
-
-
-
-
-
- Chargement de l’historique…
-
-
-
- {{ historyError }}
-
-
-
- Aucun changement enregistré pour le moment.
-
-
-
- -
-
-
- {{ historyActionLabel(entry.action) }}
-
- {{ formatHistoryDate(entry.createdAt) }}
-
-
- Par {{ entry.actor?.label || 'Inconnu' }}
-
-
-
- -
- {{ diffEntry.label }}
-
- {{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
-
-
-
-
-
- {{ entry.snapshot.name }}
-
-
-
-
+
@@ -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([])
const previewDocument = ref(null)
const previewVisible = ref(false)
-const historyEntries = computed(() => history.value)
-
const historyFieldLabels: Record = {
name: 'Nom',
reference: 'Référence',
@@ -634,8 +494,6 @@ const historyFieldLabels: Record = {
constructeurIds: 'Fournisseurs',
}
-const historyDiffEntries = (entry: ComponentHistoryEntry) =>
- _historyDiffEntries(entry, historyFieldLabels)
const selectedTypeId = ref('')
const editionForm = reactive({
name: '' as string,
diff --git a/app/pages/component/create.vue b/app/pages/component/create.vue
index 3797dd0..834f94c 100644
--- a/app/pages/component/create.vue
+++ b/app/pages/component/create.vue
@@ -241,78 +241,7 @@
Renseignez les valeurs propres à ce composant selon le squelette choisi.
-
+
@@ -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
(() =>
(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') {
diff --git a/app/pages/constructeurs.vue b/app/pages/constructeurs.vue
index 5c3d44b..e82c14e 100644
--- a/app/pages/constructeurs.vue
+++ b/app/pages/constructeurs.vue
@@ -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)
diff --git a/app/pages/documents.vue b/app/pages/documents.vue
index f57a16b..25f5dac 100644
--- a/app/pages/documents.vue
+++ b/app/pages/documents.vue
@@ -3,7 +3,7 @@
@@ -11,7 +11,7 @@
const previewDocument = ref(null)
const previewVisible = ref(false)
-const documentRows = computed(() => documents.value)
const documentsOnPage = computed(() => documents.value.length)
const paginationState = table.pagination(total, documentsOnPage)
diff --git a/app/pages/pieces-catalog.vue b/app/pages/pieces-catalog.vue
index 578de4c..91ae219 100644
--- a/app/pages/pieces-catalog.vue
+++ b/app/pages/pieces-catalog.vue
@@ -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) => {
- 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) => {
- 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) => {
if (piece?.typePiece?.name) return piece.typePiece.name
if (piece?.typePieceLabel) return piece.typePieceLabel
return '—'
}
-const MAX_VISIBLE_SUPPLIERS = 3
-
-const resolvePieceSuppliers = (piece: Record) => {
- const names: string[] = []
- const seen = new Set()
-
- 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
- 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) => {
- 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) =>
+ buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm()
@@ -289,12 +224,7 @@ const handleDeletePiece = async (piece: Record) => {
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()])
diff --git a/app/pages/pieces/[id]/edit.vue b/app/pages/pieces/[id]/edit.vue
index d5d06b2..e5d8143 100644
--- a/app/pages/pieces/[id]/edit.vue
+++ b/app/pages/pieces/[id]/edit.vue
@@ -222,78 +222,7 @@
Mettez à jour les valeurs propres à cette pièce.
-
+
@@ -396,73 +325,12 @@
-
-
-
-
-
- Chargement de l’historique…
-
-
-
- {{ historyError }}
-
-
-
- Aucun changement enregistré pour le moment.
-
-
-
- -
-
-
- {{ historyActionLabel(entry.action) }}
-
- {{ formatHistoryDate(entry.createdAt) }}
-
-
- Par {{ entry.actor?.label || 'Inconnu' }}
-
-
-
- -
- {{ diffEntry.label }}
-
- {{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
-
-
-
-
-
- {{ entry.snapshot.name }}
-
-
-
-
+
@@ -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([])
const previewDocument = ref(null)
const previewVisible = ref(false)
-const historyEntries = computed(() => history.value)
-
const historyFieldLabels: Record = {
name: 'Nom',
reference: 'Référence',
@@ -575,9 +435,6 @@ const historyFieldLabels: Record = {
constructeurIds: 'Fournisseurs',
}
-const historyDiffEntries = (entry: PieceHistoryEntry) =>
- _historyDiffEntries(entry, historyFieldLabels)
-
const selectedTypeId = ref('')
const pieceTypeDetails = ref(null)
const editionForm = reactive({
diff --git a/app/pages/pieces/create.vue b/app/pages/pieces/create.vue
index 6105f7c..9ccc3e4 100644
--- a/app/pages/pieces/create.vue
+++ b/app/pages/pieces/create.vue
@@ -193,78 +193,7 @@
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
-
+
@@ -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
(() => (pieceTypes.value || []) as PieceCatalogType[])
const typeOptionLabel = (type?: PieceCatalogType) =>
diff --git a/app/pages/product-catalog.vue b/app/pages/product-catalog.vue
index d7f0da7..d39650f 100644
--- a/app/pages/product-catalog.vue
+++ b/app/pages/product-catalog.vue
@@ -63,7 +63,7 @@
@@ -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) => {
- const names: string[] = []
- const seen = new Set()
-
- 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
- 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) => {
- 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) => {
- 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) => {
- 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) =>
+ buildSuppliersDisplay(resolveSupplierNames(product))
const reload = () => fetchProducts()
diff --git a/app/pages/product/[id]/edit.vue b/app/pages/product/[id]/edit.vue
index 2446f65..c5e8acd 100644
--- a/app/pages/product/[id]/edit.vue
+++ b/app/pages/product/[id]/edit.vue
@@ -133,78 +133,7 @@
Mettez à jour les valeurs propres à ce produit.
-
+
@@ -303,73 +232,12 @@
-
-
-
-
-
- Chargement de l’historique…
-
-
-
- {{ historyError }}
-
-
-
- Aucun changement enregistré pour le moment.
-
-
-
- -
-
-
- {{ historyActionLabel(entry.action) }}
-
- {{ formatHistoryDate(entry.createdAt) }}
-
-
- Par {{ entry.actor?.label || 'Inconnu' }}
-
-
-
- -
- {{ diffEntry.label }}
-
- {{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
-
-
-
-
-
- {{ entry.snapshot.name }}
-
-
-
-
+
@@ -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([])
const previewDocument = ref(null)
const previewVisible = ref(false)
-const historyEntries = computed(() => history.value)
-
const historyFieldLabels: Record = {
name: 'Nom',
reference: 'Référence',
@@ -479,9 +339,6 @@ const historyFieldLabels: Record = {
constructeurIds: 'Fournisseurs',
}
-const historyDiffEntries = (entry: ProductHistoryEntry) =>
- _historyDiffEntries(entry, historyFieldLabels)
-
const refreshCustomFieldInputs = (
structureOverride?: ProductModelStructure | null,
valuesOverride?: any[] | null,
diff --git a/app/pages/product/create.vue b/app/pages/product/create.vue
index 566bc2a..28144d1 100644
--- a/app/pages/product/create.vue
+++ b/app/pages/product/create.vue
@@ -119,78 +119,7 @@
Renseignez les valeurs propres à ce produit catalogue.
-
+
@@ -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
([])
-const loadingTypes = computed(() => loadingProductTypes.value)
const productTypeList = computed(() =>
(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 = ''
diff --git a/app/shared/utils/catalogDisplayUtils.ts b/app/shared/utils/catalogDisplayUtils.ts
new file mode 100644
index 0000000..5f710a1
--- /dev/null
+++ b/app/shared/utils/catalogDisplayUtils.ts
@@ -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, 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 => {
+ 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, nestedKey?: string): string[] => {
+ const names: string[] = []
+ const seen = new Set()
+
+ 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
+ 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(', ') : '' }
+}
diff --git a/app/utils/date.ts b/app/utils/date.ts
index 27c6d24..756dfee 100644
--- a/app/utils/date.ts
+++ b/app/utils/date.ts
@@ -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)
}