diff --git a/app/components/ComponentItem.vue b/app/components/ComponentItem.vue
index fd0701e..7434bf2 100644
--- a/app/components/ComponentItem.vue
+++ b/app/components/ComponentItem.vue
@@ -401,7 +401,7 @@
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode && !piece.skeletonOnly"
-
+
@update="updatePiece"
@edit="editPiece"
@custom-field-update="updatePieceCustomField"
@@ -437,200 +437,98 @@ import { ref, watch, computed } from 'vue'
import PieceItem from './PieceItem.vue'
import DocumentUpload from './DocumentUpload.vue'
import ConstructeurSelect from './ConstructeurSelect.vue'
-import { useDocuments } from '~/composables/useDocuments'
-import { getFileIcon } from '~/utils/fileIcons'
-import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
-import { useCustomFields } from '~/composables/useCustomFields'
-import { useToast } from '~/composables/useToast'
+import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { useConstructeurs } from '~/composables/useConstructeurs'
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
+import {
+ formatSize,
+ shouldInlinePdf,
+ documentPreviewSrc,
+ documentThumbnailClass,
+ documentIcon,
+ downloadDocument,
+} from '~/shared/utils/documentDisplayUtils'
+import {
+ resolveFieldKey,
+ resolveFieldName,
+ resolveFieldType,
+ resolveFieldOptions,
+ resolveFieldRequired,
+ resolveFieldReadOnly,
+ formatFieldDisplayValue,
+} from '~/shared/utils/entityCustomFieldLogic'
+import { useEntityDocuments } from '~/composables/useEntityDocuments'
+import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
+import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({
- component: {
- type: Object,
- required: true
- },
- isEditMode: {
- type: Boolean,
- default: false
- },
- collapseAll: {
- type: Boolean,
- default: true
- },
- toggleToken: {
- type: Number,
- default: 0
- }
+ component: { type: Object, required: true },
+ isEditMode: { type: Boolean, default: false },
+ collapseAll: { type: Boolean, default: true },
+ toggleToken: { type: Number, default: 0 },
})
-const emit = defineEmits([
- 'update',
- 'edit-piece',
- 'custom-field-update'
-])
+const emit = defineEmits(['update', 'edit-piece', 'custom-field-update'])
+// --- Shared composables ---
+const {
+ documents: componentDocuments,
+ selectedFiles,
+ uploadingDocuments,
+ loadingDocuments,
+ previewDocument,
+ previewVisible,
+ openPreview,
+ closePreview,
+ ensureDocumentsLoaded,
+ handleFilesAdded,
+ removeDocument,
+} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
+
+const {
+ displayProduct,
+ displayProductName,
+ productInfoRows,
+ productDocuments,
+} = useEntityProductDisplay({ entity: () => props.component })
+
+const {
+ displayedCustomFields,
+ updateCustomField: updateComponentCustomField,
+} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
+
+// --- Collapse state ---
const isCollapsed = ref(true)
-const selectedFiles = ref([])
-const uploadingDocuments = ref(false)
-const loadingDocuments = ref(false)
-const documentsLoaded = ref(!!(props.component.documents && props.component.documents.length))
-const componentDocuments = computed(() => props.component.documents || [])
-const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
-const previewDocument = ref(null)
-const previewVisible = ref(false)
-const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
-const shouldInlinePdf = (document) => {
- if (!document || !isPdfDocument(document) || !document.path) {
- return false
- }
- if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
- return false
- }
- return true
-}
-const appendPdfViewerParams = (src) => {
- if (!src || src.startsWith('data:')) {
- return src || ''
- }
- if (src.includes('#')) {
- return `${src}&toolbar=0&navpanes=0`
- }
- return `${src}#toolbar=0&navpanes=0`
-}
-const documentPreviewSrc = (document) => {
- if (!document?.path) {
- return ''
- }
- if (isPdfDocument(document)) {
- return appendPdfViewerParams(document.path)
- }
- return document.path
-}
-const documentThumbnailClass = (document) => {
- if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
- return 'h-24 w-20'
- }
- return 'h-16 w-16'
+
+watch(
+ () => props.toggleToken,
+ () => {
+ isCollapsed.value = props.collapseAll
+ if (!isCollapsed.value) ensureDocumentsLoaded()
+ },
+ { immediate: true },
+)
+
+const toggleCollapse = () => {
+ isCollapsed.value = !isCollapsed.value
+ if (!isCollapsed.value) ensureDocumentsLoaded()
}
+// --- Child components ---
const childComponents = computed(() => {
const list = props.component.subcomponents || props.component.subComponents || []
return Array.isArray(list) ? list : []
})
+// --- Constructeurs ---
const { constructeurs } = useConstructeurs()
-const buildProductDisplay = (product) => {
- if (!product || typeof product !== 'object') {
- return null
- }
-
- const suppliers = Array.isArray(product.constructeurs)
- ? product.constructeurs
- .map((constructeur) => constructeur?.name)
- .filter((name) => typeof name === 'string' && name.trim().length > 0)
- .join(', ')
- : product.supplierLabel || null
-
- const priceValue =
- product.supplierPrice ??
- product.price ??
- product.priceLabel ??
- product.priceDisplay ??
- null
-
- let price = null
- if (priceValue !== null && priceValue !== undefined) {
- const parsed = Number(priceValue)
- if (!Number.isNaN(parsed)) {
- price = currencyFormatter.format(parsed)
- } else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
- price = priceValue
- }
- }
-
- return {
- name:
- product.name ||
- product.label ||
- product.reference ||
- product.productName ||
- null,
- reference: product.reference || null,
- category: product.typeProduct?.name || product.category || null,
- suppliers,
- price,
- }
-}
-
-const displayProduct = computed(() => {
- const explicit = props.component.product || null
- const normalized = buildProductDisplay(explicit)
- if (normalized) {
- return normalized
- }
- const fallback = props.component.__productDisplay
- if (fallback) {
- return {
- name: fallback.name || null,
- reference: fallback.reference || null,
- category: fallback.category || null,
- suppliers: fallback.suppliers || null,
- price: fallback.price || null,
- }
- }
- return null
-})
-
-const displayProductName = computed(() => {
- if (displayProduct.value?.name) {
- return displayProduct.value.name
- }
- return (
- props.component.product?.name ||
- props.component.productName ||
- props.component.productLabel ||
- null
- )
-})
-
-const displayProductCategory = computed(() => displayProduct.value?.category || null)
-const displayProductReference = computed(() => displayProduct.value?.reference || null)
-const displayProductSuppliers = computed(() => displayProduct.value?.suppliers || null)
-const displayProductPrice = computed(() => displayProduct.value?.price || null)
-
-const productInfoRows = computed(() => {
- if (!displayProduct.value) {
- return []
- }
- const rows = []
- if (displayProductReference.value) {
- rows.push({ label: 'Référence', value: displayProductReference.value })
- }
- if (displayProductPrice.value) {
- rows.push({ label: 'Prix indicatif', value: displayProductPrice.value })
- }
- if (displayProductSuppliers.value) {
- rows.push({ label: 'Fournisseur(s)', value: displayProductSuppliers.value })
- }
- if (displayProductCategory.value) {
- rows.push({ label: 'Catégorie', value: displayProductCategory.value })
- }
- return rows
-})
-
-const productDocuments = computed(() => {
- const product = props.component.product
- return Array.isArray(product?.documents) ? product.documents : []
-})
-
const componentConstructeurIds = computed(() =>
uniqueConstructeurIds(
props.component,
@@ -651,332 +549,8 @@ const componentConstructeursDisplay = computed(() =>
const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur)
-const extractStructureCustomFields = (structure) => {
- if (!structure || typeof structure !== 'object') {
- return []
- }
- const customFields = structure.customFields
- return Array.isArray(customFields) ? customFields : []
-}
-
-function fieldKeyFromNameAndType(name, type) {
- const normalizedName =
- typeof name === 'string' ? name.trim().toLowerCase() : ''
- const normalizedType =
- typeof type === 'string' ? type.trim().toLowerCase() : ''
- return normalizedName ? `${normalizedName}::${normalizedType}` : null
-}
-
-function resolveOrderIndex(field) {
- if (!field || typeof field !== 'object') {
- return 0
- }
- if (typeof field.orderIndex === 'number') {
- return field.orderIndex
- }
- if (
- field.customField &&
- typeof field.customField.orderIndex === 'number'
- ) {
- return field.customField.orderIndex
- }
- return 0
-}
-
-function deduplicateFieldDefinitions(definitions) {
- const result = []
- const seen = new Set()
-
- const orderedDefinitions = (Array.isArray(definitions)
- ? definitions.slice()
- : []
- ).sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
-
- orderedDefinitions.forEach((field) => {
- if (!field || typeof field !== 'object') {
- return
- }
- const id =
- field.id ??
- field.customFieldId ??
- field.customField?.id ??
- null
- const nameKey = fieldKeyFromNameAndType(field.name, field.type)
- if (!id && !nameKey) {
- return
- }
- const key = id || nameKey
- if (key && seen.has(key)) {
- return
- }
- if (key) {
- seen.add(key)
- }
- field.orderIndex = resolveOrderIndex(field)
- result.push(field)
- })
-
- return result
-}
-
-function mergeFieldDefinitionsWithValues(definitions, values) {
- const definitionList = Array.isArray(definitions) ? definitions : []
- const valueList = Array.isArray(values) ? values : []
-
- const valueMap = new Map()
- valueList.forEach((entry) => {
- if (!entry || typeof entry !== 'object') {
- return
- }
- const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
- if (fieldId) {
- valueMap.set(fieldId, entry)
- }
- const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
- if (nameKey) {
- valueMap.set(nameKey, entry)
- }
- })
-
- const merged = definitionList.map((field) => {
- if (!field || typeof field !== 'object') {
- return field
- }
-
- const fieldId = ensureCustomFieldId(field)
- const nameKey = fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field))
-
- const matchedValue =
- (fieldId ? valueMap.get(fieldId) : undefined) ??
- (nameKey ? valueMap.get(nameKey) : undefined)
-
- if (!matchedValue) {
- return {
- ...field,
- value: field?.value ?? '',
- orderIndex: resolveOrderIndex(field),
- }
- }
-
- const resolvedOrder = Math.min(
- resolveOrderIndex(field),
- resolveOrderIndex(matchedValue.customField),
- )
-
- return {
- ...field,
- customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
- customFieldId:
- matchedValue.customField?.id ??
- matchedValue.customFieldId ??
- fieldId ??
- null,
- customField: matchedValue.customField ?? field.customField ?? null,
- value: matchedValue.value ?? field.value ?? '',
- orderIndex: resolvedOrder,
- }
- })
-
- valueList.forEach((entry) => {
- if (!entry || typeof entry !== 'object') {
- return
- }
-
- const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
- const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
-
- const exists = merged.some((field) => {
- if (!field || typeof field !== 'object') {
- return false
- }
- if (field.customFieldValueId && field.customFieldValueId === entry.id) {
- return true
- }
- const existingId = ensureCustomFieldId(field)
- if (fieldId && existingId && existingId === fieldId) {
- return true
- }
- if (!fieldId && nameKey) {
- return (
- fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field)) === nameKey
- )
- }
- return false
- })
-
- if (!exists) {
- merged.push({
- customFieldValueId: entry.id ?? null,
- customFieldId: fieldId,
- name: entry.customField?.name ?? '',
- type: entry.customField?.type ?? 'text',
- required: entry.customField?.required ?? false,
- options: entry.customField?.options ?? [],
- value: entry.value ?? '',
- customField: entry.customField ?? null,
- orderIndex: resolveOrderIndex(entry.customField),
- })
- }
- })
-
- return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
-}
-
-function dedupeMergedFields(fields) {
- if (!Array.isArray(fields) || fields.length <= 1) {
- return Array.isArray(fields)
- ? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
- : []
- }
-
- const seen = new Map()
- const result = []
-
- const orderedFields = fields
- .slice()
- .sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
-
- orderedFields.forEach((field) => {
- if (!field || typeof field !== 'object') {
- return
- }
-
- const rawName = resolveFieldName(field)
- const normalizedName = typeof rawName === 'string' ? rawName.trim() : ''
- if (!normalizedName) {
- return
- }
- field.name = normalizedName
- field.type = resolveFieldType(field)
-
- const fieldId = ensureCustomFieldId(field)
- const nameKey = fieldKeyFromNameAndType(normalizedName, field.type)
- const key = fieldId || nameKey
-
- if (!key) {
- field.orderIndex = resolveOrderIndex(field)
- result.push(field)
- return
- }
-
- const existing = seen.get(key)
- if (!existing) {
- field.orderIndex = resolveOrderIndex(field)
- seen.set(key, field)
- result.push(field)
- return
- }
-
- const existingHasValue =
- existing.value !== undefined &&
- existing.value !== null &&
- String(existing.value).trim().length > 0
-
- const incomingHasValue =
- field.value !== undefined &&
- field.value !== null &&
- String(field.value).trim().length > 0
-
- if (!existingHasValue && incomingHasValue) {
- Object.assign(existing, field)
- existing.orderIndex = Math.min(
- resolveOrderIndex(existing),
- resolveOrderIndex(field),
- )
- seen.set(key, existing)
- }
- })
-
- return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
-}
-
-const componentDefinitionSources = computed(() => {
- const requirement = props.component.typeMachineComponentRequirement || {}
- const type = requirement.typeComposant || props.component.typeComposant || {}
-
- const definitions = []
- const pushFields = (collection) => {
- if (Array.isArray(collection)) {
- definitions.push(...collection)
- }
- }
-
- pushFields(props.component.customFields)
- pushFields(props.component.definition?.customFields)
- pushFields(type.customFields)
- pushFields(requirement.customFields)
- pushFields(requirement.definition?.customFields)
-
- ;[
- props.component.definition?.structure,
- type.structure,
- type.componentSkeleton,
- requirement.structure,
- requirement.componentSkeleton,
- ].forEach((structure) => {
- const fields = extractStructureCustomFields(structure)
- if (fields.length) {
- definitions.push(...fields)
- }
- })
-
- return deduplicateFieldDefinitions(definitions)
-})
-
-const displayedCustomFields = computed(() =>
- dedupeMergedFields(
- mergeFieldDefinitionsWithValues(
- componentDefinitionSources.value,
- props.component.customFieldValues,
- ),
- ),
-)
-
-const candidateCustomFields = computed(() => {
- const map = new Map()
- const register = (collection) => {
- if (!Array.isArray(collection)) {
- return
- }
- collection.forEach((item) => {
- if (!item || typeof item !== 'object') {
- return
- }
- const id = item.id || item.customFieldId
- const name = typeof item.name === 'string' ? item.name : null
- const key = id || (name ? `${name}::${item.type ?? ''}` : null)
- if (!key || map.has(key)) {
- return
- }
- map.set(key, item)
- })
- }
-
- register(props.component.customFieldValues?.map((value) => value?.customField))
- register(componentDefinitionSources.value)
-
- return Array.from(map.values())
-})
-
-watch(
- candidateCustomFields,
- () => {
- displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
- },
- { immediate: true, deep: true }
-)
-
-watch(
- displayedCustomFields,
- (fields) => {
- (fields || []).forEach((field) => ensureCustomFieldId(field))
- },
- { immediate: true, deep: true }
-)
-
const handleConstructeurChange = async (value) => {
const ids = uniqueConstructeurIds(value)
-
props.component.constructeurIds = [...ids]
props.component.constructeurId = null
props.component.constructeur = null
@@ -985,42 +559,10 @@ const handleConstructeurChange = async (value) => {
constructeurs.value,
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
)
-
await updateComponent()
}
-const { uploadDocuments, deleteDocument, loadDocumentsByComponent } = useDocuments()
-const {
- updateCustomFieldValue: updateComponentCustomFieldValueApi,
- upsertCustomFieldValue: upsertComponentCustomFieldValue,
-} = useCustomFields()
-const { showSuccess, showError } = useToast()
-
-watch(
- () => props.toggleToken,
- () => {
- isCollapsed.value = props.collapseAll
- if (!isCollapsed.value) {
- ensureDocumentsLoaded()
- }
- },
- { immediate: true }
-)
-
-watch(
- () => props.component.documents,
- (docs) => {
- documentsLoaded.value = !!(docs && docs.length)
- }
-)
-
-const toggleCollapse = () => {
- isCollapsed.value = !isCollapsed.value
- if (!isCollapsed.value) {
- ensureDocumentsLoaded()
- }
-}
-
+// --- Update / Event forwarding ---
const updateComponent = () => {
emit('update', {
...props.component,
@@ -1028,216 +570,6 @@ const updateComponent = () => {
})
}
-function resolveFieldKey(field, index) {
- return field?.id
- ?? field?.customFieldValueId
- ?? field?.customFieldId
- ?? field?.name
- ?? `field-${index}`
-}
-
-function resolveFieldId(field) {
- return field?.customFieldValueId ?? null
-}
-
-function resolveFieldName(field) {
- return field?.name ?? 'Champ'
-}
-
-function resolveFieldType(field) {
- return field?.type ?? 'text'
-}
-
-function resolveFieldOptions(field) {
- return field?.options ?? []
-}
-
-function resolveFieldRequired(field) {
- return !!field?.required
-}
-
-function resolveFieldReadOnly(field) {
- return !!field?.readOnly
-}
-
-function buildCustomFieldMetadata(field) {
- return {
- customFieldName: resolveFieldName(field),
- customFieldType: resolveFieldType(field),
- customFieldRequired: resolveFieldRequired(field),
- customFieldOptions: resolveFieldOptions(field)
- }
-}
-
-function resolveCustomFieldId(field) {
- return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
-}
-
-function ensureCustomFieldId(field) {
- const existingId = resolveCustomFieldId(field)
- if (existingId) {
- return existingId
- }
-
- const name = resolveFieldName(field)
- if (!name || name === 'Champ') {
- return null
- }
-
- const matches = candidateCustomFields.value.filter((candidate) => {
- if (!candidate || typeof candidate !== 'object') {
- return false
- }
- const candidateId = candidate.id || candidate.customFieldId
- if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) {
- return true
- }
- return typeof candidate.name === 'string' && candidate.name === name
- })
-
- if (matches.length) {
- const withId = matches.find((candidate) => candidate?.id || candidate?.customFieldId) || matches[0]
- const id = withId?.id || withId?.customFieldId || null
- if (id) {
- field.customFieldId = id
- }
- if (!field.customField && typeof withId === 'object') {
- field.customField = withId
- }
- return id
- }
-
- return null
-}
-
-watch(
- candidateCustomFields,
- () => {
- displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
- },
- { immediate: true, deep: true }
-)
-
-watch(
- displayedCustomFields,
- (fields) => {
- (fields || []).forEach((field) => ensureCustomFieldId(field))
- },
- { immediate: true, deep: true }
-)
-const formatFieldDisplayValue = (field) => {
- const type = resolveFieldType(field)
- const rawValue = field?.value ?? ''
- if (type === 'boolean') {
- const normalized = String(rawValue).toLowerCase()
- if (normalized === 'true') return 'Oui'
- if (normalized === 'false') return 'Non'
- }
- return rawValue || 'Non défini'
-}
-
-const updateComponentCustomField = async (field) => {
- if (!field || resolveFieldReadOnly(field)) {
- return
- }
-
- const fieldValueId = resolveFieldId(field)
- if (fieldValueId) {
- const result = await updateComponentCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
- if (result.success) {
- const existingValue = props.component.customFieldValues?.find((value) => value.id === fieldValueId)
- if (existingValue?.customField?.id) {
- field.customFieldId = existingValue.customField.id
- field.customField = existingValue.customField
- }
- showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
- } else {
- showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
- }
- return
- }
-
- const customFieldId = ensureCustomFieldId(field)
- const fieldName = resolveFieldName(field)
- if (!props.component?.id) {
- showError('Impossible de créer la valeur pour ce champ de composant')
- return
- }
-
- if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
- showError('Impossible de créer la valeur pour ce champ de composant')
- return
- }
-
- const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
-
- const result = await upsertComponentCustomFieldValue(
- customFieldId,
- 'composant',
- props.component.id,
- field.value ?? '',
- metadata,
- )
-
- if (result.success) {
- const newValue = result.data
- if (newValue?.id) {
- field.customFieldValueId = newValue.id
- field.value = newValue.value ?? field.value ?? ''
- if (newValue.customField?.id) {
- field.customFieldId = newValue.customField.id
- field.customField = newValue.customField
- }
-
- if (Array.isArray(props.component.customFieldValues)) {
- const index = props.component.customFieldValues.findIndex((value) => value.id === newValue.id)
- if (index !== -1) {
- props.component.customFieldValues.splice(index, 1, newValue)
- } else {
- props.component.customFieldValues.push(newValue)
- }
- } else {
- props.component.customFieldValues = [newValue]
- }
- }
- showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
-
- const definitions = Array.isArray(props.component.customFields)
- ? [...props.component.customFields]
- : []
- const fieldIdentifier = ensureCustomFieldId(field)
- const existingIndex = definitions.findIndex((definition) => {
- const definitionId = ensureCustomFieldId(definition)
- if (fieldIdentifier && definitionId) {
- return definitionId === fieldIdentifier
- }
- return definition?.name === resolveFieldName(field)
- })
-
- const updatedDefinition = {
- ...(existingIndex !== -1 ? definitions[existingIndex] : {}),
- customFieldValueId: field.customFieldValueId,
- customFieldId: fieldIdentifier,
- name: resolveFieldName(field),
- type: resolveFieldType(field),
- required: resolveFieldRequired(field),
- options: resolveFieldOptions(field),
- value: field.value ?? '',
- customField: field.customField ?? null,
- }
-
- if (existingIndex !== -1) {
- definitions.splice(existingIndex, 1, updatedDefinition)
- } else {
- definitions.push(updatedDefinition)
- }
-
- props.component.customFields = definitions
- } else {
- showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
- }
-}
-
const updatePiece = (updatedPiece) => {
emit('edit-piece', updatedPiece)
}
@@ -1250,87 +582,4 @@ const updatePieceCustomField = (fieldUpdate) => {
emit('custom-field-update', fieldUpdate)
emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' })
}
-
-const ensureDocumentsLoaded = async () => {
- if (documentsLoaded.value || !props.component?.id) { return }
- await refreshDocuments()
-}
-
-const refreshDocuments = async () => {
- loadingDocuments.value = true
- try {
- const result = await loadDocumentsByComponent(props.component.id, { updateStore: false })
- if (result.success) {
- props.component.documents = result.data || []
- documentsLoaded.value = true
- }
- } finally {
- loadingDocuments.value = false
- }
-}
-
-const handleFilesAdded = async (files) => {
- if (!files.length || !props.component?.id) { return }
- uploadingDocuments.value = true
- try {
- const result = await uploadDocuments(
- {
- files,
- context: { composantId: props.component.id }
- },
- { updateStore: false }
- )
-
- if (result.success) {
- const newDocs = result.data || []
- props.component.documents = [...newDocs, ...(props.component.documents || [])]
- documentsLoaded.value = true
- selectedFiles.value = []
- }
- } finally {
- uploadingDocuments.value = false
- }
-}
-
-const removeDocument = async (documentId) => {
- if (!documentId) { return }
- const result = await deleteDocument(documentId, { updateStore: false })
- if (result.success) {
- props.component.documents = (props.component.documents || []).filter(doc => doc.id !== documentId)
- }
-}
-
-const downloadDocument = (doc) => {
- if (!doc?.path) { return }
-
- if (doc.path.startsWith('data:')) {
- const link = document.createElement('a')
- link.href = doc.path
- link.download = doc.filename || doc.name || 'document'
- link.click()
- return
- }
-
- window.open(doc.path, '_blank')
-}
-
-const openPreview = (doc) => {
- if (!canPreviewDocument(doc)) { return }
- previewDocument.value = doc
- previewVisible.value = true
-}
-
-const closePreview = () => {
- previewVisible.value = false
- previewDocument.value = null
-}
-
-const formatSize = (size) => {
- if (size === undefined || size === null) { return '—' }
- if (size === 0) { return '0 B' }
- const units = ['B', 'KB', 'MB', 'GB']
- const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
- const formatted = size / Math.pow(1024, index)
- return `${formatted.toFixed(1)} ${units[index]}`
-}
diff --git a/app/components/PieceItem.vue b/app/components/PieceItem.vue
index 9454961..29e8c57 100644
--- a/app/components/PieceItem.vue
+++ b/app/components/PieceItem.vue
@@ -483,427 +483,100 @@
diff --git a/app/components/PieceModelStructureEditor.vue b/app/components/PieceModelStructureEditor.vue
index 27e2a82..6a31625 100644
--- a/app/components/PieceModelStructureEditor.vue
+++ b/app/components/PieceModelStructureEditor.vue
@@ -511,6 +511,10 @@ const reorderFields = (from: number, to: number) => {
}
const [moved] = list.splice(from, 1)
+ if (!moved) {
+ resetDragState()
+ return
+ }
list.splice(to, 0, moved)
fields.value = applyOrderIndex(list)
resetDragState()
diff --git a/app/composables/useEntityCustomFields.ts b/app/composables/useEntityCustomFields.ts
new file mode 100644
index 0000000..4f57280
--- /dev/null
+++ b/app/composables/useEntityCustomFields.ts
@@ -0,0 +1,181 @@
+/**
+ * Reactive custom field management for entity items (ComponentItem, PieceItem).
+ *
+ * Wraps the pure logic from entityCustomFieldLogic.ts with Vue reactivity,
+ * watchers, and API calls for updating/upserting custom field values.
+ */
+
+import { computed, watch } from 'vue'
+import { useCustomFields } from '~/composables/useCustomFields'
+import { useToast } from '~/composables/useToast'
+import {
+ buildDefinitionSources,
+ buildCandidateCustomFields,
+ mergeFieldDefinitionsWithValues,
+ dedupeMergedFields,
+ ensureCustomFieldId,
+ resolveFieldId,
+ resolveFieldName,
+ resolveFieldType,
+ resolveFieldReadOnly,
+ resolveCustomFieldId,
+ buildCustomFieldMetadata,
+} from '~/shared/utils/entityCustomFieldLogic'
+
+export interface EntityCustomFieldsDeps {
+ entity: () => any
+ entityType: 'composant' | 'piece'
+}
+
+export function useEntityCustomFields(deps: EntityCustomFieldsDeps) {
+ const { entity, entityType } = deps
+ const {
+ updateCustomFieldValue: updateCustomFieldValueApi,
+ upsertCustomFieldValue,
+ } = useCustomFields()
+ const { showSuccess, showError } = useToast()
+
+ const definitionSources = computed(() =>
+ buildDefinitionSources(entity(), entityType),
+ )
+
+ const displayedCustomFields = computed(() =>
+ dedupeMergedFields(
+ mergeFieldDefinitionsWithValues(
+ definitionSources.value,
+ entity().customFieldValues,
+ ),
+ ),
+ )
+
+ const candidateCustomFields = computed(() =>
+ buildCandidateCustomFields(entity(), definitionSources.value),
+ )
+
+ // Watchers to ensure field IDs are resolved
+ watch(
+ candidateCustomFields,
+ () => {
+ const candidates = candidateCustomFields.value
+ ;(displayedCustomFields.value || []).forEach((field: any) => {
+ if (field) ensureCustomFieldId(field, candidates)
+ })
+ },
+ { immediate: true, deep: true },
+ )
+
+ watch(
+ displayedCustomFields,
+ (fields) => {
+ const candidates = candidateCustomFields.value
+ ;(fields || []).forEach((field: any) => {
+ if (field) ensureCustomFieldId(field, candidates)
+ })
+ },
+ { immediate: true, deep: true },
+ )
+
+ const updateCustomField = async (field: any) => {
+ if (!field || resolveFieldReadOnly(field)) return
+
+ const e = entity()
+ const fieldValueId = resolveFieldId(field)
+
+ // Update existing field value
+ if (fieldValueId) {
+ const result: any = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
+ if (result.success) {
+ const existingValue = e.customFieldValues?.find((v: any) => v.id === fieldValueId)
+ if (existingValue?.customField?.id) {
+ field.customFieldId = existingValue.customField.id
+ field.customField = existingValue.customField
+ }
+ showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
+ } else {
+ showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
+ }
+ return
+ }
+
+ // Create new field value
+ const customFieldId = ensureCustomFieldId(field, candidateCustomFields.value)
+ const fieldName = resolveFieldName(field)
+ if (!e?.id) {
+ showError(`Impossible de créer la valeur pour ce champ`)
+ return
+ }
+ if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
+ showError(`Impossible de créer la valeur pour ce champ`)
+ return
+ }
+
+ const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
+ const result: any = await upsertCustomFieldValue(
+ customFieldId,
+ entityType,
+ e.id,
+ field.value ?? '',
+ metadata,
+ )
+
+ if (result.success) {
+ const newValue = result.data
+ if (newValue?.id) {
+ field.customFieldValueId = newValue.id
+ field.value = newValue.value ?? field.value ?? ''
+ if (newValue.customField?.id) {
+ field.customFieldId = newValue.customField.id
+ field.customField = newValue.customField
+ }
+
+ if (Array.isArray(e.customFieldValues)) {
+ const index = e.customFieldValues.findIndex((v: any) => v.id === newValue.id)
+ if (index !== -1) {
+ e.customFieldValues.splice(index, 1, newValue)
+ } else {
+ e.customFieldValues.push(newValue)
+ }
+ } else {
+ e.customFieldValues = [newValue]
+ }
+ }
+ showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
+
+ // Update definitions list
+ const definitions = Array.isArray(e.customFields) ? [...e.customFields] : []
+ const fieldIdentifier = ensureCustomFieldId(field, candidateCustomFields.value)
+ const existingIndex = definitions.findIndex((definition: any) => {
+ const definitionId = resolveCustomFieldId(definition)
+ if (fieldIdentifier && definitionId) return definitionId === fieldIdentifier
+ return definition?.name === resolveFieldName(field)
+ })
+
+ const updatedDefinition = {
+ ...(existingIndex !== -1 ? definitions[existingIndex] : {}),
+ customFieldValueId: field.customFieldValueId,
+ customFieldId: fieldIdentifier,
+ name: resolveFieldName(field),
+ type: resolveFieldType(field),
+ required: field.required ?? false,
+ options: field.options ?? [],
+ value: field.value ?? '',
+ customField: field.customField ?? null,
+ }
+
+ if (existingIndex !== -1) {
+ definitions.splice(existingIndex, 1, updatedDefinition)
+ } else {
+ definitions.push(updatedDefinition)
+ }
+ e.customFields = definitions
+ } else {
+ showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
+ }
+ }
+
+ return {
+ displayedCustomFields,
+ candidateCustomFields,
+ updateCustomField,
+ }
+}
diff --git a/app/composables/useEntityDocuments.ts b/app/composables/useEntityDocuments.ts
new file mode 100644
index 0000000..00d8064
--- /dev/null
+++ b/app/composables/useEntityDocuments.ts
@@ -0,0 +1,122 @@
+/**
+ * Reactive document management for entity items (ComponentItem, PieceItem).
+ *
+ * Handles document CRUD operations, preview modal state, and lazy loading.
+ * Display helpers (formatSize, shouldInlinePdf, etc.) are imported from
+ * shared/utils/documentDisplayUtils.ts.
+ */
+
+import { ref, computed, watch } from 'vue'
+import { useDocuments } from '~/composables/useDocuments'
+import { canPreviewDocument } from '~/utils/documentPreview'
+
+export interface EntityDocumentsDeps {
+ entity: () => any
+ entityType: 'composant' | 'piece'
+}
+
+export function useEntityDocuments(deps: EntityDocumentsDeps) {
+ const { entity, entityType } = deps
+ const { uploadDocuments, deleteDocument } = useDocuments()
+
+ const loadDocumentsFn = entityType === 'composant'
+ ? useDocuments().loadDocumentsByComponent
+ : useDocuments().loadDocumentsByPiece
+
+ const selectedFiles = ref([])
+ const uploadingDocuments = ref(false)
+ const loadingDocuments = ref(false)
+ const documentsLoaded = ref(!!(entity().documents && entity().documents.length))
+
+ const documents = computed(() => entity().documents || [])
+
+ // Preview modal state
+ const previewDocument = ref(null)
+ const previewVisible = ref(false)
+
+ const openPreview = (doc: any) => {
+ if (!canPreviewDocument(doc)) return
+ previewDocument.value = doc
+ previewVisible.value = true
+ }
+
+ const closePreview = () => {
+ previewVisible.value = false
+ previewDocument.value = null
+ }
+
+ // Document watchers
+ watch(
+ () => entity().documents,
+ (docs: any) => {
+ documentsLoaded.value = !!(docs && docs.length)
+ },
+ )
+
+ // CRUD operations
+ const refreshDocuments = async () => {
+ const e = entity()
+ if (!e?.id) return
+ loadingDocuments.value = true
+ try {
+ const result: any = await loadDocumentsFn(e.id, { updateStore: false })
+ if (result.success) {
+ e.documents = result.data || []
+ documentsLoaded.value = true
+ }
+ } finally {
+ loadingDocuments.value = false
+ }
+ }
+
+ const ensureDocumentsLoaded = async () => {
+ if (documentsLoaded.value || !entity()?.id) return
+ await refreshDocuments()
+ }
+
+ const handleFilesAdded = async (files: File[]) => {
+ const e = entity()
+ if (!files.length || !e?.id) return
+ uploadingDocuments.value = true
+ try {
+ const contextKey = entityType === 'composant' ? 'composantId' : 'pieceId'
+ const result: any = await uploadDocuments(
+ { files, context: { [contextKey]: e.id } } as any,
+ { updateStore: false } as any,
+ )
+ if (result.success) {
+ const newDocs = result.data || []
+ e.documents = [...newDocs, ...(e.documents || [])]
+ documentsLoaded.value = true
+ selectedFiles.value = []
+ }
+ } finally {
+ uploadingDocuments.value = false
+ }
+ }
+
+ const removeDocument = async (documentId: string) => {
+ if (!documentId) return
+ const result: any = await deleteDocument(documentId, { updateStore: false } as any)
+ if (result.success) {
+ const e = entity()
+ e.documents = (e.documents || []).filter((doc: any) => doc.id !== documentId)
+ }
+ }
+
+ return {
+ documents,
+ selectedFiles,
+ uploadingDocuments,
+ loadingDocuments,
+ documentsLoaded,
+ previewDocument,
+ previewVisible,
+ openPreview,
+ closePreview,
+ refreshDocuments,
+ ensureDocumentsLoaded,
+ handleFilesAdded,
+ removeDocument,
+ }
+}
diff --git a/app/composables/useEntityProductDisplay.ts b/app/composables/useEntityProductDisplay.ts
new file mode 100644
index 0000000..e0de3fa
--- /dev/null
+++ b/app/composables/useEntityProductDisplay.ts
@@ -0,0 +1,103 @@
+/**
+ * Reactive product display for entity items (ComponentItem, PieceItem).
+ *
+ * Resolves product information from entity.product, entity.__productDisplay,
+ * or a selectedProduct ref, and produces display-ready computed properties.
+ */
+
+import { computed, type Ref } from 'vue'
+
+const currencyFormatter = new Intl.NumberFormat('fr-FR', {
+ style: 'currency',
+ currency: 'EUR',
+ currencyDisplay: 'narrowSymbol',
+})
+
+function buildProductDisplay(product: any) {
+ if (!product || typeof product !== 'object') return null
+
+ const suppliers = Array.isArray(product.constructeurs)
+ ? product.constructeurs
+ .map((c: any) => c?.name)
+ .filter((name: any) => typeof name === 'string' && name.trim().length > 0)
+ .join(', ')
+ : product.supplierLabel || null
+
+ const priceValue = product.supplierPrice ?? product.price ?? product.priceLabel ?? product.priceDisplay ?? null
+ let price: string | null = null
+ if (priceValue !== null && priceValue !== undefined) {
+ const parsed = Number(priceValue)
+ if (!Number.isNaN(parsed)) {
+ price = currencyFormatter.format(parsed)
+ } else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
+ price = priceValue
+ }
+ }
+
+ return {
+ name: product.name || product.label || product.reference || product.productName || null,
+ reference: product.reference || null,
+ category: product.typeProduct?.name || product.category || null,
+ suppliers,
+ price,
+ }
+}
+
+export interface EntityProductDisplayDeps {
+ entity: () => any
+ selectedProduct?: Ref
+}
+
+export function useEntityProductDisplay(deps: EntityProductDisplayDeps) {
+ const { entity, selectedProduct } = deps
+
+ const displayProduct = computed(() => {
+ // Priority: selectedProduct (for PieceItem) → entity.product → entity.__productDisplay
+ if (selectedProduct?.value) {
+ const normalized = buildProductDisplay(selectedProduct.value)
+ if (normalized) return normalized
+ }
+ const explicit = entity().product || null
+ const normalized = buildProductDisplay(explicit)
+ if (normalized) return normalized
+ const fallback = entity().__productDisplay
+ if (fallback) {
+ return {
+ name: fallback.name || null,
+ reference: fallback.reference || null,
+ category: fallback.category || null,
+ suppliers: fallback.suppliers || null,
+ price: fallback.price || null,
+ }
+ }
+ return null
+ })
+
+ const displayProductName = computed(() => {
+ if (displayProduct.value?.name) return displayProduct.value.name
+ const e = entity()
+ return e.product?.name || e.productName || e.productLabel || null
+ })
+
+ const productInfoRows = computed(() => {
+ if (!displayProduct.value) return []
+ const rows: { label: string; value: string }[] = []
+ if (displayProduct.value.reference) rows.push({ label: 'Référence', value: displayProduct.value.reference })
+ if (displayProduct.value.price) rows.push({ label: 'Prix indicatif', value: displayProduct.value.price })
+ if (displayProduct.value.suppliers) rows.push({ label: 'Fournisseur(s)', value: displayProduct.value.suppliers })
+ if (displayProduct.value.category) rows.push({ label: 'Catégorie', value: displayProduct.value.category })
+ return rows
+ })
+
+ const productDocuments = computed(() => {
+ const product = selectedProduct?.value || entity().product || null
+ return Array.isArray(product?.documents) ? product.documents : []
+ })
+
+ return {
+ displayProduct,
+ displayProductName,
+ productInfoRows,
+ productDocuments,
+ }
+}
diff --git a/app/pages/component-category/[id]/edit.vue b/app/pages/component-category/[id]/edit.vue
index 4e0f5af..c34e94e 100644
--- a/app/pages/component-category/[id]/edit.vue
+++ b/app/pages/component-category/[id]/edit.vue
@@ -42,6 +42,7 @@ import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
+import type { ComponentModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useToast } from '~/composables/useToast'
@@ -108,7 +109,7 @@ const loadCategory = async () => {
code: response.code,
category: response.category,
notes: response.notes ?? response.description ?? '',
- structure: response.structure ?? undefined,
+ structure: (response.structure as ComponentModelStructure | null) ?? undefined,
}
await loadLinkedCount(id)
diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue
index f81095d..d073099 100644
--- a/app/pages/component/[id]/edit.vue
+++ b/app/pages/component/[id]/edit.vue
@@ -558,7 +558,6 @@ import {
import {
historyActionLabel,
formatHistoryDate,
- formatHistoryValue,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
@@ -709,7 +708,7 @@ const refreshDocuments = async () => {
try {
const result = await loadDocumentsByComponent(component.value.id, { updateStore: false })
if (result.success) {
- componentDocuments.value = result.data || []
+ componentDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
}
} finally {
loadingDocuments.value = false
@@ -851,8 +850,8 @@ const submitEdition = async () => {
saving.value = true
try {
const result = await updateComposant(component.value.id, payload)
- if (result.success) {
- const updatedComponent = result.data
+ if (result.success && result.data) {
+ const updatedComponent = result.data as Record
await _saveCustomFieldValues(
'composant',
updatedComponent.id,
@@ -925,13 +924,14 @@ const fetchPieceTypeNames = async (ids: string[]) => {
)
const next = { ...fetchedPieceTypeMap.value }
results.forEach((result, index) => {
- if (result.status !== 'fulfilled') {
+ const key = missing[index]
+ if (!key || result.status !== 'fulfilled') {
return
}
const data = result.value?.data
const name = data?.name || data?.code
if (name) {
- next[missing[index]] = name
+ next[key] = name
}
})
fetchedPieceTypeMap.value = next
@@ -968,13 +968,14 @@ const fetchProductTypeNames = async (ids: string[]) => {
)
const next = { ...fetchedProductTypeMap.value }
results.forEach((result, index) => {
- if (result.status !== 'fulfilled') {
+ const key = missing[index]
+ if (!key || result.status !== 'fulfilled') {
return
}
const data = result.value?.data
const name = data?.name || data?.code
if (name) {
- next[missing[index]] = name
+ next[key] = name
}
})
fetchedProductTypeMap.value = next
diff --git a/app/pages/component/create.vue b/app/pages/component/create.vue
index f176843..c73b1cc 100644
--- a/app/pages/component/create.vue
+++ b/app/pages/component/create.vue
@@ -813,13 +813,14 @@ const fetchPieceTypeNames = async (ids: string[]) => {
)
const next = { ...fetchedPieceTypeMap.value }
results.forEach((result, index) => {
- if (result.status !== 'fulfilled') {
+ const key = missing[index]
+ if (!key || result.status !== 'fulfilled') {
return
}
const data = result.value?.data
const name = data?.name || data?.code
if (name) {
- next[missing[index]] = name
+ next[key] = name
}
})
fetchedPieceTypeMap.value = next
@@ -1144,7 +1145,7 @@ const resolveOptions = (field: any): string[] => {
return option.trim()
}
if (typeof option === 'object') {
- const record = option || {}
+ const record = option as Record
const keys = ['value', 'label', 'name']
for (const key of keys) {
const candidate = record[key]
diff --git a/app/pages/piece-category/[id]/edit.vue b/app/pages/piece-category/[id]/edit.vue
index 7b17bbc..bb16bd1 100644
--- a/app/pages/piece-category/[id]/edit.vue
+++ b/app/pages/piece-category/[id]/edit.vue
@@ -42,6 +42,7 @@ import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
+import type { PieceModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useToast } from '~/composables/useToast'
@@ -106,7 +107,7 @@ const loadCategory = async () => {
code: response.code,
category: response.category,
notes: response.notes ?? response.description ?? '',
- structure: response.structure ?? undefined,
+ structure: (response.structure as PieceModelStructure | null) ?? undefined,
}
await loadLinkedCount(id)
diff --git a/app/pages/pieces/[id]/edit.vue b/app/pages/pieces/[id]/edit.vue
index c535428..5480b2d 100644
--- a/app/pages/pieces/[id]/edit.vue
+++ b/app/pages/pieces/[id]/edit.vue
@@ -503,7 +503,6 @@ import {
import {
historyActionLabel,
formatHistoryDate,
- formatHistoryValue,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
@@ -626,7 +625,7 @@ const refreshDocuments = async () => {
try {
const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false })
if (result.success) {
- pieceDocuments.value = result.data || []
+ pieceDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
}
} finally {
loadingDocuments.value = false
@@ -776,9 +775,9 @@ const loadPieceTypeDetails = async (currentPiece: any) => {
const type = await getModelType(typeId)
if (type && typeof type === 'object') {
pieceTypeDetails.value = type
- refreshCustomFieldInputs(type.structure ?? null, currentPiece?.customFieldValues ?? null)
+ refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
}
- } catch (error) {
+ } catch (_error) {
pieceTypeDetails.value = null
}
}
@@ -898,8 +897,8 @@ const submitEdition = async () => {
saving.value = true
try {
const result = await updatePiece(piece.value.id, payload)
- if (result.success) {
- const updatedPiece = result.data
+ if (result.success && result.data) {
+ const updatedPiece = result.data as Record
await _saveCustomFieldValues(
'piece',
updatedPiece.id,
diff --git a/app/pages/pieces/create.vue b/app/pages/pieces/create.vue
index e84a4be..5429ca5 100644
--- a/app/pages/pieces/create.vue
+++ b/app/pages/pieces/create.vue
@@ -543,22 +543,23 @@ const submitCreation = async () => {
submitting.value = true
try {
const result = await createPiece(payload)
- if (result.success) {
+ if (result.success && result.data) {
+ const createdPiece = result.data as Record
await _saveCustomFieldValues(
'piece',
- result.data.id,
+ createdPiece.id,
[
- result.data?.typePiece?.pieceCustomFields,
- result.data?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
+ createdPiece?.typePiece?.pieceCustomFields,
+ createdPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
- if (selectedDocuments.value.length && result.data?.id) {
+ if (selectedDocuments.value.length && createdPiece.id) {
uploadingDocuments.value = true
const uploadResult = await uploadDocuments(
{
files: selectedDocuments.value,
- context: { pieceId: result.data.id },
+ context: { pieceId: createdPiece.id },
},
{ updateStore: false },
)
diff --git a/app/pages/product-category/[id]/edit.vue b/app/pages/product-category/[id]/edit.vue
index 687d16d..945560f 100644
--- a/app/pages/product-category/[id]/edit.vue
+++ b/app/pages/product-category/[id]/edit.vue
@@ -42,6 +42,7 @@ import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
+import type { ProductModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useToast } from '~/composables/useToast'
@@ -106,7 +107,7 @@ const loadCategory = async () => {
code: response.code,
category: response.category,
notes: response.notes ?? response.description ?? '',
- structure: response.structure ?? undefined,
+ structure: (response.structure as ProductModelStructure | null) ?? undefined,
}
await loadLinkedCount(id)
diff --git a/app/pages/product/[id]/edit.vue b/app/pages/product/[id]/edit.vue
index 900f1da..bb8d89a 100644
--- a/app/pages/product/[id]/edit.vue
+++ b/app/pages/product/[id]/edit.vue
@@ -421,7 +421,6 @@ import {
import {
historyActionLabel,
formatHistoryDate,
- formatHistoryValue,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
@@ -518,9 +517,9 @@ const loadProduct = async () => {
return
}
const result = await getProduct(id)
- if (result.success) {
+ if (result.success && result.data) {
product.value = result.data
- productDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
+ productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
await loadProductType()
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) {
diff --git a/app/shared/utils/entityCustomFieldLogic.ts b/app/shared/utils/entityCustomFieldLogic.ts
new file mode 100644
index 0000000..32951f1
--- /dev/null
+++ b/app/shared/utils/entityCustomFieldLogic.ts
@@ -0,0 +1,349 @@
+/**
+ * Pure functions for custom field resolution, merging, and deduplication.
+ *
+ * Extracted from ComponentItem.vue and PieceItem.vue which had ~350 LOC
+ * of identical custom field logic duplicated between them.
+ */
+
+// ---------------------------------------------------------------------------
+// Field key / identity helpers
+// ---------------------------------------------------------------------------
+
+export function fieldKeyFromNameAndType(name: unknown, type: unknown): string | null {
+ const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : ''
+ const normalizedType = typeof type === 'string' ? type.trim().toLowerCase() : ''
+ return normalizedName ? `${normalizedName}::${normalizedType}` : null
+}
+
+export function resolveOrderIndex(field: any): number {
+ if (!field || typeof field !== 'object') return 0
+ if (typeof field.orderIndex === 'number') return field.orderIndex
+ if (field.customField && typeof field.customField.orderIndex === 'number') return field.customField.orderIndex
+ return 0
+}
+
+// ---------------------------------------------------------------------------
+// Field accessors
+// ---------------------------------------------------------------------------
+
+export function resolveFieldKey(field: any, index: number): string {
+ return field?.id ?? field?.customFieldValueId ?? field?.customFieldId ?? field?.name ?? `field-${index}`
+}
+
+export function resolveFieldId(field: any): string | null {
+ return field?.customFieldValueId ?? null
+}
+
+export function resolveFieldName(field: any): string {
+ return field?.name ?? 'Champ'
+}
+
+export function resolveFieldType(field: any): string {
+ return field?.type ?? 'text'
+}
+
+export function resolveFieldOptions(field: any): string[] {
+ return field?.options ?? []
+}
+
+export function resolveFieldRequired(field: any): boolean {
+ return !!field?.required
+}
+
+export function resolveFieldReadOnly(field: any): boolean {
+ return !!field?.readOnly
+}
+
+export function resolveCustomFieldId(field: any): string | null {
+ return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
+}
+
+export function buildCustomFieldMetadata(field: any) {
+ return {
+ customFieldName: resolveFieldName(field),
+ customFieldType: resolveFieldType(field),
+ customFieldRequired: resolveFieldRequired(field),
+ customFieldOptions: resolveFieldOptions(field),
+ }
+}
+
+export function formatFieldDisplayValue(field: any): string {
+ const type = resolveFieldType(field)
+ const rawValue = field?.value ?? ''
+ if (type === 'boolean') {
+ const normalized = String(rawValue).toLowerCase()
+ if (normalized === 'true') return 'Oui'
+ if (normalized === 'false') return 'Non'
+ }
+ return rawValue || 'Non défini'
+}
+
+// ---------------------------------------------------------------------------
+// Custom field ID resolution against candidate pool
+// ---------------------------------------------------------------------------
+
+export function ensureCustomFieldId(field: any, candidateFields: any[]): string | null {
+ const existingId = resolveCustomFieldId(field)
+ if (existingId) return existingId
+
+ const name = resolveFieldName(field)
+ if (!name || name === 'Champ') return null
+
+ const matches = candidateFields.filter((candidate) => {
+ if (!candidate || typeof candidate !== 'object') return false
+ const candidateId = candidate.id || candidate.customFieldId
+ if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) return true
+ return typeof candidate.name === 'string' && candidate.name === name
+ })
+
+ if (matches.length) {
+ const withId = matches.find((c) => c?.id || c?.customFieldId) || matches[0]
+ const id = withId?.id || withId?.customFieldId || null
+ if (id) field.customFieldId = id
+ if (!field.customField && typeof withId === 'object') field.customField = withId
+ return id
+ }
+
+ return null
+}
+
+// ---------------------------------------------------------------------------
+// Structure extraction
+// ---------------------------------------------------------------------------
+
+export function extractStructureCustomFields(structure: any): any[] {
+ if (!structure || typeof structure !== 'object') return []
+ const customFields = structure.customFields
+ return Array.isArray(customFields) ? customFields : []
+}
+
+// ---------------------------------------------------------------------------
+// Deduplication & merge
+// ---------------------------------------------------------------------------
+
+export function deduplicateFieldDefinitions(definitions: any[]): any[] {
+ const result: any[] = []
+ const seen = new Set()
+
+ const orderedDefinitions = (Array.isArray(definitions) ? definitions.slice() : [])
+ .sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
+
+ orderedDefinitions.forEach((field) => {
+ if (!field || typeof field !== 'object') return
+ const id = field.id ?? field.customFieldId ?? field.customField?.id ?? null
+ const nameKey = fieldKeyFromNameAndType(field.name, field.type)
+ const key = id || nameKey
+ if (key && seen.has(key)) return
+ if (key) seen.add(key)
+ field.orderIndex = resolveOrderIndex(field)
+ result.push(field)
+ })
+
+ return result
+}
+
+export function mergeFieldDefinitionsWithValues(definitions: any[], values: any[]): any[] {
+ const definitionList = Array.isArray(definitions) ? definitions : []
+ const valueList = Array.isArray(values) ? values : []
+
+ const valueMap = new Map()
+ valueList.forEach((entry) => {
+ if (!entry || typeof entry !== 'object') return
+ const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
+ if (fieldId) valueMap.set(fieldId, entry)
+ const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
+ if (nameKey) valueMap.set(nameKey, entry)
+ })
+
+ const merged = definitionList.map((field) => {
+ if (!field || typeof field !== 'object') return field
+
+ const fieldId = resolveCustomFieldId(field)
+ const nameKey = fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field))
+ const matchedValue = (fieldId ? valueMap.get(fieldId) : undefined) ?? (nameKey ? valueMap.get(nameKey) : undefined)
+
+ if (!matchedValue) {
+ return { ...field, value: field?.value ?? '', orderIndex: resolveOrderIndex(field) }
+ }
+
+ const resolvedOrder = Math.min(resolveOrderIndex(field), resolveOrderIndex(matchedValue.customField))
+ return {
+ ...field,
+ customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
+ customFieldId: matchedValue.customField?.id ?? matchedValue.customFieldId ?? fieldId ?? null,
+ customField: matchedValue.customField ?? field.customField ?? null,
+ value: matchedValue.value ?? field.value ?? '',
+ orderIndex: resolvedOrder,
+ }
+ })
+
+ valueList.forEach((entry) => {
+ if (!entry || typeof entry !== 'object') return
+
+ const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
+ const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
+
+ const exists = merged.some((field) => {
+ if (!field || typeof field !== 'object') return false
+ if (field.customFieldValueId && field.customFieldValueId === entry.id) return true
+ const existingId = resolveCustomFieldId(field)
+ if (fieldId && existingId && existingId === fieldId) return true
+ if (!fieldId && nameKey) {
+ return fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field)) === nameKey
+ }
+ return false
+ })
+
+ if (!exists) {
+ merged.push({
+ customFieldValueId: entry.id ?? null,
+ customFieldId: fieldId,
+ name: entry.customField?.name ?? '',
+ type: entry.customField?.type ?? 'text',
+ required: entry.customField?.required ?? false,
+ options: entry.customField?.options ?? [],
+ value: entry.value ?? '',
+ customField: entry.customField ?? null,
+ orderIndex: resolveOrderIndex(entry.customField),
+ })
+ }
+ })
+
+ return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
+}
+
+export function dedupeMergedFields(fields: any[]): any[] {
+ if (!Array.isArray(fields) || fields.length <= 1) {
+ return Array.isArray(fields)
+ ? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
+ : []
+ }
+
+ const seen = new Map()
+ const result: any[] = []
+
+ const orderedFields = fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
+
+ orderedFields.forEach((field) => {
+ if (!field || typeof field !== 'object') return
+
+ const rawName = resolveFieldName(field)
+ const normalizedName = typeof rawName === 'string' ? rawName.trim() : ''
+ if (!normalizedName) return
+
+ field.name = normalizedName
+ field.type = field.type || resolveFieldType(field)
+
+ const fieldId = resolveCustomFieldId(field)
+ const nameKey = fieldKeyFromNameAndType(normalizedName, field.type)
+ const key = fieldId || nameKey
+
+ if (!key) {
+ field.orderIndex = resolveOrderIndex(field)
+ result.push(field)
+ return
+ }
+
+ const existing = seen.get(key)
+ if (!existing) {
+ field.orderIndex = resolveOrderIndex(field)
+ seen.set(key, field)
+ result.push(field)
+ return
+ }
+
+ const existingHasValue = existing.value !== undefined && existing.value !== null && String(existing.value).trim().length > 0
+ const incomingHasValue = field.value !== undefined && field.value !== null && String(field.value).trim().length > 0
+
+ if (!existingHasValue && incomingHasValue) {
+ Object.assign(existing, field)
+ existing.orderIndex = Math.min(resolveOrderIndex(existing), resolveOrderIndex(field))
+ seen.set(key, existing)
+ }
+ })
+
+ return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
+}
+
+// ---------------------------------------------------------------------------
+// Definition sources builder
+// ---------------------------------------------------------------------------
+
+export function buildDefinitionSources(entity: any, entityType: 'composant' | 'piece'): any[] {
+ const definitions: any[] = []
+ const pushFields = (collection: any) => {
+ if (Array.isArray(collection)) definitions.push(...collection)
+ }
+
+ if (entityType === 'composant') {
+ const requirement = entity.typeMachineComponentRequirement || {}
+ const type = requirement.typeComposant || entity.typeComposant || {}
+
+ pushFields(entity.customFields)
+ pushFields(entity.definition?.customFields)
+ pushFields(type.customFields)
+ pushFields(requirement.customFields)
+ pushFields(requirement.definition?.customFields)
+
+ ;[
+ entity.definition?.structure,
+ type.structure,
+ type.componentSkeleton,
+ requirement.structure,
+ requirement.componentSkeleton,
+ ].forEach((structure) => {
+ const fields = extractStructureCustomFields(structure)
+ if (fields.length) definitions.push(...fields)
+ })
+ } else {
+ const requirement = entity.typeMachinePieceRequirement || {}
+ const type = requirement.typePiece || entity.typePiece || {}
+
+ pushFields(entity.customFields)
+ pushFields(entity.definition?.customFields)
+ pushFields(entity.typePiece?.customFields)
+ pushFields(type.customFields)
+ pushFields(requirement.typePiece?.customFields)
+ pushFields(requirement.customFields)
+ pushFields(requirement.definition?.customFields)
+
+ ;[
+ entity.definition?.structure,
+ entity.typePiece?.structure,
+ type.structure,
+ type.pieceSkeleton,
+ entity.typePiece?.pieceSkeleton,
+ requirement.structure,
+ requirement.pieceSkeleton,
+ ].forEach((structure) => {
+ const fields = extractStructureCustomFields(structure)
+ if (fields.length) definitions.push(...fields)
+ })
+ }
+
+ return deduplicateFieldDefinitions(definitions)
+}
+
+// ---------------------------------------------------------------------------
+// Candidate fields builder
+// ---------------------------------------------------------------------------
+
+export function buildCandidateCustomFields(entity: any, definitionSources: any[]): any[] {
+ const map = new Map()
+ const register = (collection: any[]) => {
+ if (!Array.isArray(collection)) return
+ collection.forEach((item) => {
+ if (!item || typeof item !== 'object') return
+ const id = item.id || item.customFieldId
+ const name = typeof item.name === 'string' ? item.name : null
+ const key = id || (name ? `${name}::${item.type ?? ''}` : null)
+ if (!key || map.has(key)) return
+ map.set(key, item)
+ })
+ }
+
+ register((entity.customFieldValues || []).map((value: any) => value?.customField))
+ register(definitionSources)
+
+ return Array.from(map.values())
+}