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()) +}