From de7be1b9d0f739c3c643a23b68b21db58b04c14c Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 8 Mar 2026 02:28:26 +0100 Subject: [PATCH] refactor(frontend) : extract shared components and reduce file sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/components/DocumentUpload.vue | 2 +- app/components/StructureNodeEditor.vue | 280 +++--------------- .../common/CustomFieldInputGrid.vue | 83 ++++++ .../common/EntityHistorySection.vue | 97 ++++++ app/composables/useDragReorder.ts | 109 +++++++ app/pages/component-catalog.vue | 27 +- app/pages/component/[id]/edit.vue | 158 +--------- app/pages/component/create.vue | 78 +---- app/pages/constructeurs.vue | 12 +- app/pages/documents.vue | 5 +- app/pages/pieces-catalog.vue | 80 +---- app/pages/pieces/[id]/edit.vue | 159 +--------- app/pages/pieces/create.vue | 77 +---- app/pages/product-catalog.vue | 74 +---- app/pages/product/[id]/edit.vue | 159 +--------- app/pages/product/create.vue | 79 +---- app/shared/utils/catalogDisplayUtils.ts | 87 ++++++ app/utils/date.ts | 12 +- 18 files changed, 464 insertions(+), 1114 deletions(-) create mode 100644 app/components/common/CustomFieldInputGrid.vue create mode 100644 app/components/common/EntityHistorySection.vue create mode 100644 app/composables/useDragReorder.ts create mode 100644 app/shared/utils/catalogDisplayUtils.ts 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 @@ + + + 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 @@

-
-
-
-

Historique

-

- Qui a changé quoi, et quand. -

-
- - {{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }} - -
- -
-
- -
- {{ 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 @@

-
-
-
-

Historique

-

- Qui a changé quoi, et quand. -

-
- - {{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }} - -
- -
-
- -
- {{ 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 @@

-
-
-
-

Historique

-

- Qui a changé quoi, et quand. -

-
- - {{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }} - -
- -
-
- -
- {{ 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) }