refactor(components): extract shared entity utilities and simplify item components (F1.3, F1.4)

Extract 3 entity composables (useEntityCustomFields, useEntityDocuments,
useEntityProductDisplay) and entityCustomFieldLogic utility shared across
ComponentItem (1336→585 LOC) and PieceItem (1588→740 LOC).
Improve type safety in edit/create pages with explicit casts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-02-09 11:19:40 +01:00
parent e1594cab76
commit 519fa3a8f4
15 changed files with 1047 additions and 1883 deletions

View File

@@ -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]}`
}
</script>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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<File[]>([])
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<any>(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,
}
}

View File

@@ -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<any>
}
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,
}
}

View File

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

View File

@@ -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<string, any>
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

View File

@@ -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<string, unknown>
const keys = ['value', 'label', 'name']
for (const key of keys) {
const candidate = record[key]

View File

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

View File

@@ -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<string, any>
await _saveCustomFieldValues(
'piece',
updatedPiece.id,

View File

@@ -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<string, any>
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 },
)

View File

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

View File

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

View File

@@ -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<string>()
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<string, any>()
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<string, any>()
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<string, any>()
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())
}