From 42c788103ac31fd6ac7459703ce7a8a3ae8379df Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 16 Oct 2025 16:48:36 +0200 Subject: [PATCH] add sub componet in catego ske --- .../ComponentModelStructureEditor.vue | 92 ++++- app/components/StructureNodeEditor.vue | 64 ++- app/components/model-types/ModelTypeForm.vue | 15 +- app/pages/component-category/[id]/edit.vue | 1 - app/pages/component-category/new.vue | 1 - app/pages/component/[id]/edit.vue | 198 ++++++++-- app/pages/component/create.vue | 148 ++++++- app/pages/pieces/[id]/edit.vue | 39 +- app/pages/pieces/create.vue | 39 +- app/shared/modelUtils.ts | 367 ++++++++++++++++-- app/shared/types/inventory.ts | 4 + 11 files changed, 859 insertions(+), 109 deletions(-) diff --git a/app/components/ComponentModelStructureEditor.vue b/app/components/ComponentModelStructureEditor.vue index ced3aca..1f22c22 100644 --- a/app/components/ComponentModelStructureEditor.vue +++ b/app/components/ComponentModelStructureEditor.vue @@ -8,6 +8,7 @@ :lock-type="lockRootType" :locked-type-label="displayedRootTypeLabel" :allow-subcomponents="allowSubcomponents" + :max-subcomponent-depth="maxSubcomponentDepth" is-root /> @@ -48,6 +49,10 @@ const props = defineProps({ type: Boolean, default: true, }, + maxSubcomponentDepth: { + type: Number, + default: Infinity, + }, }) const emit = defineEmits(['update:modelValue']) @@ -61,6 +66,9 @@ const { componentTypes, loadComponentTypes } = useComponentTypes() const availablePieceTypes = computed(() => pieceTypes.value ?? []) const availableComponentTypes = computed(() => componentTypes.value ?? []) const allowSubcomponents = computed(() => props.allowSubcomponents !== false) +const maxSubcomponentDepth = computed(() => + typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity, +) const fallbackRootTypeLabel = computed(() => { if (!props.rootTypeId) { @@ -72,6 +80,79 @@ const fallbackRootTypeLabel = computed(() => { const displayedRootTypeLabel = computed(() => props.rootTypeLabel || fallbackRootTypeLabel.value) +const formatOptionsText = (field: Record) => { + if (typeof field?.optionsText === 'string') { + return field.optionsText + } + if (Array.isArray(field?.options)) { + return field.options.join('\n') + } + return '' +} + +const normalizeLineEndings = (text: string) => + text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + +const parseOptionsFromText = (text: string) => { + return text + .split('\n') + .map((option) => option.trim()) + .filter((option) => option.length > 0) +} + +const applyCustomFieldOptions = (node: Record | null | undefined) => { + if (!node || typeof node !== 'object') { + return + } + + if (Array.isArray(node.customFields)) { + node.customFields = node.customFields.map((field: any) => { + if (!field || typeof field !== 'object') { + return field + } + + const next = { ...field } + if (next.type === 'select') { + const baseText = normalizeLineEndings(formatOptionsText(next)) + if (next.optionsText !== baseText) { + next.optionsText = baseText + } + const parsedOptions = parseOptionsFromText(next.optionsText || '') + if (parsedOptions.length > 0) { + next.options = parsedOptions + } else { + delete next.options + } + } else { + if (next.options !== undefined) { + delete next.options + } + if (next.optionsText !== undefined && next.optionsText !== '') { + next.optionsText = '' + } + } + return next + }) + } + + if (Array.isArray(node.subcomponents)) { + node.subcomponents = node.subcomponents.map((sub: any) => { + if (!sub || typeof sub !== 'object') { + return sub + } + const copy = { ...sub } + applyCustomFieldOptions(copy) + return copy + }) + } +} + +const prepareStructureForEmit = (structure: any) => { + const clone = cloneStructure(structure) + applyCustomFieldOptions(clone as Record) + return clone +} + const syncRootType = () => { if (!props.lockRootType) { previousLockedLabel.value = props.rootTypeLabel || '' @@ -97,9 +178,14 @@ const syncRootType = () => { previousLockedLabel.value = newLabel } -let lastEmitted = JSON.stringify(cloneStructure(props.modelValue)) +let lastEmitted = JSON.stringify(prepareStructureForEmit(props.modelValue)) const syncFromProps = (value: any) => { + const normalizedIncoming = prepareStructureForEmit(value) + const incomingSerialized = JSON.stringify(normalizedIncoming) + if (incomingSerialized === lastEmitted) { + return + } const hydrated = hydrateStructureForEditor(value) localStructure.customFields = hydrated.customFields localStructure.pieces = hydrated.pieces @@ -109,7 +195,7 @@ const syncFromProps = (value: any) => { localStructure.modelId = hydrated.modelId localStructure.familyCode = hydrated.familyCode localStructure.alias = hydrated.alias - lastEmitted = JSON.stringify(cloneStructure(value)) + lastEmitted = incomingSerialized syncRootType() } @@ -139,7 +225,7 @@ watch( watch( localStructure, (value) => { - const payload = cloneStructure(value) + const payload = prepareStructureForEmit(value) const serialized = JSON.stringify(payload) if (serialized !== lastEmitted) { lastEmitted = serialized diff --git a/app/components/StructureNodeEditor.vue b/app/components/StructureNodeEditor.vue index b3929f1..05433f7 100644 --- a/app/components/StructureNodeEditor.vue +++ b/app/components/StructureNodeEditor.vue @@ -175,18 +175,23 @@ -
+

Sous-composants

-
-

+

Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.

-

+

Aucun sous-composant défini.

@@ -197,7 +202,8 @@ :depth="depth + 1" :component-types="componentTypes" :piece-types="pieceTypes" - :allow-subcomponents="allowSubcomponents" + :allow-subcomponents="childAllowSubcomponents" + :max-subcomponent-depth="maxSubcomponentDepth" @remove="removeSubComponent(index)" />
@@ -235,6 +241,7 @@ const props = withDefaults(defineProps<{ lockType?: boolean lockedTypeLabel?: string allowSubcomponents?: boolean + maxSubcomponentDepth?: number }>(), { depth: 0, componentTypes: () => [], @@ -243,6 +250,7 @@ const props = withDefaults(defineProps<{ lockType: false, lockedTypeLabel: '', allowSubcomponents: true, + maxSubcomponentDepth: Infinity, }) const emit = defineEmits(['remove']) @@ -250,10 +258,23 @@ const emit = defineEmits(['remove']) const componentTypes = computed(() => props.componentTypes ?? []) const pieceTypes = computed(() => props.pieceTypes ?? []) const allowSubcomponents = computed(() => props.allowSubcomponents !== false) +const maxSubcomponentDepth = computed(() => + typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity, +) +const currentDepth = computed(() => Math.max(0, props.depth ?? 0)) +const canManageSubcomponents = computed( + () => allowSubcomponents.value && currentDepth.value < maxSubcomponentDepth.value, +) +const childAllowSubcomponents = computed( + () => allowSubcomponents.value && currentDepth.value + 1 < maxSubcomponentDepth.value, +) +const hasSubcomponents = computed( + () => Array.isArray(props.node?.subcomponents) && props.node.subcomponents.length > 0, +) const depthClasses = ['', 'ml-4', 'ml-8', 'ml-12', 'ml-16', 'ml-20'] const containerClass = computed(() => { - const level = Math.max(0, props.depth ?? 0) + const level = currentDepth.value const index = Math.min(level, depthClasses.length - 1) return level === 0 ? 'space-y-4' : `${depthClasses[index]} space-y-4` }) @@ -279,6 +300,17 @@ const componentTypeMap = computed(() => { return map }) +const componentTypeCodeMap = computed(() => { + const map = new Map() + componentTypes.value.forEach((type) => { + const code = typeof type?.code === 'string' ? type.code.trim() : '' + if (code) { + map.set(code, type) + } + }) + return map +}) + const pieceTypeMap = computed(() => { const map = new Map() pieceTypes.value.forEach((type) => { @@ -346,6 +378,22 @@ const syncComponentType = (component: EditableStructureNode) => { : '' if (!id) { + const code = + typeof component.familyCode === 'string' && component.familyCode + ? component.familyCode + : '' + if (code) { + const codeMatch = componentTypeCodeMap.value.get(code) + if (codeMatch?.id) { + component.typeComposantId = codeMatch.id + component.typeComposantLabel = formatModelTypeOption(codeMatch) + component.familyCode = codeMatch.code ?? component.familyCode + if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) { + component.alias = codeMatch.name || component.typeComposantLabel + } + return + } + } component.typeComposantLabel = '' component.familyCode = '' return @@ -456,7 +504,7 @@ const removePiece = (index: number) => { } const addSubComponent = () => { - if (!allowSubcomponents.value) { + if (!canManageSubcomponents.value) { return } ensureArray('subcomponents') @@ -476,7 +524,7 @@ const removeSubComponent = (index: number) => { } watch( - allowSubcomponents, + canManageSubcomponents, (allowed) => { if (!allowed && Array.isArray(props.node.subcomponents) && props.node.subcomponents.length) { props.node.subcomponents.splice(0, props.node.subcomponents.length) diff --git a/app/components/model-types/ModelTypeForm.vue b/app/components/model-types/ModelTypeForm.vue index a0f887f..ca372b4 100644 --- a/app/components/model-types/ModelTypeForm.vue +++ b/app/components/model-types/ModelTypeForm.vue @@ -79,6 +79,7 @@ @@ -119,6 +120,7 @@ import { formatPieceStructurePreview, formatStructurePreview, normalizePieceStructureForSave, + normalizeStructureForEditor, normalizeStructureForSave, } from '~/shared/modelUtils' import type { ModelCategory, ModelTypePayload } from '~/services/modelTypes' @@ -131,12 +133,14 @@ const props = withDefaults(defineProps<{ lockCategory?: boolean structureLoading?: boolean allowComponentSubcomponents?: boolean + componentSubcomponentMaxDepth?: number }>(), { initialData: null, saving: false, lockCategory: false, structureLoading: false, allowComponentSubcomponents: true, + componentSubcomponentMaxDepth: 1, }) const emit = defineEmits<{ @@ -148,6 +152,11 @@ const lockCategory = computed(() => props.lockCategory ?? false) const structureLoading = computed(() => props.structureLoading ?? false) const saving = computed(() => props.saving ?? false) const allowComponentSubcomponents = computed(() => props.allowComponentSubcomponents !== false) +const componentSubcomponentMaxDepth = computed(() => + typeof props.componentSubcomponentMaxDepth === 'number' + ? props.componentSubcomponentMaxDepth + : 1, +) const form = reactive({ name: '', @@ -160,7 +169,7 @@ const form = reactive({ const errors = reactive<{ name?: string }>({}) const nameInput = ref(null) -const componentStructure = ref(normalizeStructureForSave(defaultStructure())) +const componentStructure = ref(normalizeStructureForEditor(defaultStructure())) const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure())) const generateCodeFromName = (name: string) => { @@ -179,7 +188,7 @@ const generateCodeFromName = (name: string) => { const resetStructures = (incomingStructure: ModelTypePayload['structure'], category: ModelCategory) => { if (category === 'COMPONENT') { - componentStructure.value = normalizeStructureForSave( + componentStructure.value = normalizeStructureForEditor( incomingStructure && props.initialData?.category === 'COMPONENT' ? incomingStructure : defaultStructure(), @@ -296,7 +305,7 @@ watch( } if (category === 'COMPONENT') { - componentStructure.value = normalizeStructureForSave(defaultStructure()) + componentStructure.value = normalizeStructureForEditor(defaultStructure()) } if (category === 'PIECE') { diff --git a/app/pages/component-category/[id]/edit.vue b/app/pages/component-category/[id]/edit.vue index faf67b5..619571c 100644 --- a/app/pages/component-category/[id]/edit.vue +++ b/app/pages/component-category/[id]/edit.vue @@ -25,7 +25,6 @@ initial-category="COMPONENT" :initial-data="initialData" :lock-category="true" - :allow-component-subcomponents="false" :saving="saving" @submit="handleSubmit" @cancel="handleCancel" diff --git a/app/pages/component-category/new.vue b/app/pages/component-category/new.vue index 90b9076..e65056c 100644 --- a/app/pages/component-category/new.vue +++ b/app/pages/component-category/new.vue @@ -19,7 +19,6 @@ mode="create" initial-category="COMPONENT" :lock-category="true" - :allow-component-subcomponents="false" :saving="saving" @submit="handleSubmit" @cancel="handleCancel" diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue index 37fae08..06d1408 100644 --- a/app/pages/component/[id]/edit.vue +++ b/app/pages/component/[id]/edit.vue @@ -131,32 +131,43 @@ {{ selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.' }}

- {{ formatStructurePreview(selectedType.structure) }} + {{ formatStructurePreview(selectedTypeStructure) }} -
+
Consulter le détail du squelette
-
+

Champs personnalisés

-
    +
    • - {{ field.key || field.name }} - : {{ field.value }} +

      + {{ field.name || field.key }} +

      +

      + Type : {{ field.type || 'text' }} • Obligatoire + + • Options : {{ field.options.join(', ') }} + + + • Défaut : {{ field.defaultValue }} + +

-
+

Pièces imposées

  • {{ resolvePieceLabel(piece) }} @@ -164,11 +175,11 @@
-
+

Sous-composants

  • {{ resolveSubcomponentLabel(subcomponent) }} @@ -177,7 +188,7 @@

Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut. @@ -383,7 +394,7 @@ import { useCustomFields } from '~/composables/useCustomFields' import { useApi } from '~/composables/useApi' import { useToast } from '~/composables/useToast' import { useDocuments } from '~/composables/useDocuments' -import { formatStructurePreview } from '~/shared/modelUtils' +import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' import type { ComponentModelStructure } from '~/shared/types/inventory' import type { ModelType } from '~/services/modelTypes' import { getFileIcon } from '~/utils/fileIcons' @@ -530,6 +541,11 @@ const selectedType = computed(() => { return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null }) +const selectedTypeStructure = computed(() => { + const structure = selectedType.value?.structure ?? null + return structure ? normalizeStructureForEditor(structure) : null +}) + const requiredCustomFieldsFilled = computed(() => customFieldInputs.value.every((field) => { if (!field.required) { @@ -582,8 +598,8 @@ const fetchComponent = async () => { let initialized = false watch( - [component, selectedType], - ([currentComponent, currentType]) => { + [component, selectedTypeStructure], + ([currentComponent, currentStructure]) => { if (!currentComponent || initialized) { return } @@ -596,7 +612,7 @@ watch( editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : '' customFieldInputs.value = buildCustomFieldInputs( - currentType?.structure ?? null, + currentStructure, currentComponent.customFieldValues, ) @@ -605,12 +621,12 @@ watch( { immediate: true }, ) -watch(selectedType, (currentType) => { - if (!component.value || !currentType) { +watch(selectedTypeStructure, (currentStructure) => { + if (!component.value) { return } customFieldInputs.value = buildCustomFieldInputs( - currentType.structure, + currentStructure, component.value.customFieldValues, ) }) @@ -662,7 +678,8 @@ const buildCustomFieldInputs = ( structure: ComponentModelStructure | null, values: any[] | null, ): CustomFieldInput[] => { - const definitions = normalizeCustomFieldInputs(structure) + const normalizedStructure = structure ? normalizeStructureForEditor(structure) : null + const definitions = normalizeCustomFieldInputs(normalizedStructure) const valueList = Array.isArray(values) ? values : [] const mapById = new Map() @@ -694,7 +711,7 @@ const buildCustomFieldInputs = ( } } - const resolvedValue = matched.value ?? '' + const resolvedValue = extractStoredCustomFieldValue(matched) return { ...definition, customFieldId: matched.customField?.id || definition.customFieldId || definition.id, @@ -728,11 +745,10 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => { return null } const type = resolveFieldType(rawField) - const required = !!rawField.required - const options = Array.isArray(rawField.options) - ? rawField.options.filter((option: unknown): option is string => typeof option === 'string') - : [] - const value = formatDefaultValue(type, rawField.value) + const required = resolveRequiredFlag(rawField) + const options = resolveOptions(rawField) + const defaultSource = resolveDefaultValue(rawField) + const value = formatDefaultValue(type, defaultSource) const id = typeof rawField.id === 'string' ? rawField.id : null const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id const customFieldValueId = typeof rawField.customFieldValueId === 'string' @@ -756,14 +772,56 @@ const resolveFieldName = (field: any): string => { const resolveFieldType = (field: any): string => { const allowed = ['text', 'number', 'select', 'boolean', 'date'] - const value = typeof field?.type === 'string' ? field.type.toLowerCase() : '' + const rawType = + typeof field?.type === 'string' + ? field.type + : typeof field?.value?.type === 'string' + ? field.value.type + : '' + const value = rawType.toLowerCase() return allowed.includes(value) ? value : 'text' } +const resolveDefaultValue = (field: any): any => { + if (!field || typeof field !== 'object') { + return null + } + if (field.defaultValue !== undefined && field.defaultValue !== null) { + return field.defaultValue + } + if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') { + return field.value + } + if (field.default !== undefined && field.default !== null) { + return field.default + } + if (field.value && typeof field.value === 'object') { + if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) { + return (field.value as any).defaultValue + } + if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') { + return (field.value as any).value + } + } + return null +} + const formatDefaultValue = (type: string, defaultValue: any): string => { if (defaultValue === null || defaultValue === undefined) { return '' } + if (typeof defaultValue === 'object') { + if (defaultValue === null) { + return '' + } + if ('defaultValue' in (defaultValue as Record)) { + return formatDefaultValue(type, (defaultValue as Record).defaultValue) + } + if ('value' in (defaultValue as Record)) { + return formatDefaultValue(type, (defaultValue as Record).value) + } + return '' + } if (type === 'boolean') { const normalized = String(defaultValue).toLowerCase() if (normalized === 'true' || normalized === '1') { @@ -777,6 +835,90 @@ const formatDefaultValue = (type: string, defaultValue: any): string => { return String(defaultValue) } +const resolveRequiredFlag = (field: any): boolean => { + if (typeof field?.required === 'boolean') { + return field.required + } + const nestedRequired = field?.value?.required + if (typeof nestedRequired === 'boolean') { + return nestedRequired + } + if (typeof nestedRequired === 'string') { + const normalized = nestedRequired.toLowerCase() + return normalized === 'true' || normalized === '1' + } + return false +} + +const resolveOptions = (field: any): string[] => { + const sources = [field?.options, field?.value?.options, field?.value?.choices] + for (const source of sources) { + if (Array.isArray(source)) { + const mapped = source + .map((option: unknown) => { + if (option === null || option === undefined) { + return '' + } + if (typeof option === 'string') { + return option.trim() + } + if (typeof option === 'object') { + const record = option as Record + const keys = ['value', 'label', 'name'] + for (const key of keys) { + const candidate = record[key] + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim() + } + } + } + const fallback = String(option).trim() + return fallback === '[object Object]' ? '' : fallback + }) + .filter((option) => option.length > 0) + if (mapped.length) { + return mapped + } + } + } + return [] +} + +const extractStoredCustomFieldValue = (entry: any): any => { + if (entry === null || entry === undefined) { + return '' + } + if (typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean') { + return entry + } + if (typeof entry !== 'object') { + return String(entry) + } + const direct = entry.value + if (direct !== undefined && direct !== null) { + if (typeof direct === 'object') { + if (direct === null) { + return '' + } + if ('value' in direct && direct.value !== undefined && direct.value !== null) { + return direct.value + } + if ('defaultValue' in direct && direct.defaultValue !== undefined && direct.defaultValue !== null) { + return direct.defaultValue + } + return '' + } + return direct + } + if (entry.defaultValue !== undefined && entry.defaultValue !== null) { + return entry.defaultValue + } + if (entry.customFieldValue?.value !== undefined && entry.customFieldValue?.value !== null) { + return entry.customFieldValue.value + } + return '' +} + const getStructureCustomFields = (structure: ComponentModelStructure | null) => { return Array.isArray(structure?.customFields) ? structure.customFields : [] } diff --git a/app/pages/component/create.vue b/app/pages/component/create.vue index 74ec133..d761929 100644 --- a/app/pages/component/create.vue +++ b/app/pages/component/create.vue @@ -104,32 +104,43 @@ {{ selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.' }}

- {{ formatStructurePreview(selectedType.structure) }} + {{ formatStructurePreview(selectedTypeStructure) }}
-
+
Consulter le détail du squelette
-
+

Champs personnalisés

-
    +
    • - {{ field.key || field.name }} - : {{ field.value }} +

      + {{ field.name || field.key }} +

      +

      + Type : {{ field.type || 'text' }} • Obligatoire + + • Options : {{ field.options.join(', ') }} + + + • Défaut : {{ field.defaultValue }} + +

-
+

Pièces imposées

  • {{ resolvePieceLabel(piece) }} @@ -137,11 +148,11 @@
-
+

Sous-composants

  • {{ resolveSubcomponentLabel(subcomponent) }} @@ -327,7 +338,7 @@ import { usePieces } from '~/composables/usePieces' import { useToast } from '~/composables/useToast' import { useCustomFields } from '~/composables/useCustomFields' import { useDocuments } from '~/composables/useDocuments' -import { formatStructurePreview } from '~/shared/modelUtils' +import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' import type { ComponentModelPiece, ComponentModelStructure, @@ -422,6 +433,11 @@ const selectedType = computed(() => { return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null }) +const selectedTypeStructure = computed(() => { + const structure = selectedType.value?.structure ?? null + return structure ? normalizeStructureForEditor(structure) : null +}) + watch(selectedType, (type) => { if (!type) { clearCreationForm() @@ -433,8 +449,8 @@ watch(selectedType, (type) => { creationForm.name = type.name } lastSuggestedName.value = creationForm.name - customFieldInputs.value = normalizeCustomFieldInputs(type.structure) - structureAssignments.value = initializeStructureAssignments(type.structure) + customFieldInputs.value = normalizeCustomFieldInputs(selectedTypeStructure.value) + structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value) }) const extractSubcomponents = ( @@ -845,11 +861,10 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => { return null } const type = resolveFieldType(rawField) - const required = !!rawField.required - const options = Array.isArray(rawField.options) - ? rawField.options.filter((option: unknown): option is string => typeof option === 'string') - : [] - const value = formatDefaultValue(type, rawField.value) + const required = resolveRequiredFlag(rawField) + const options = resolveOptions(rawField) + const defaultSource = resolveDefaultValue(rawField) + const value = formatDefaultValue(type, defaultSource) const id = typeof rawField.id === 'string' ? rawField.id : null const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id const customFieldValueId = typeof rawField.customFieldValueId === 'string' @@ -873,14 +888,56 @@ const resolveFieldName = (field: any): string => { const resolveFieldType = (field: any): string => { const allowed = ['text', 'number', 'select', 'boolean', 'date'] - const value = typeof field?.type === 'string' ? field.type.toLowerCase() : '' + const rawType = + typeof field?.type === 'string' + ? field.type + : typeof field?.value?.type === 'string' + ? field.value.type + : '' + const value = rawType.toLowerCase() return allowed.includes(value) ? value : 'text' } +const resolveDefaultValue = (field: any): any => { + if (!field || typeof field !== 'object') { + return null + } + if (field.defaultValue !== undefined && field.defaultValue !== null) { + return field.defaultValue + } + if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') { + return field.value + } + if (field.default !== undefined && field.default !== null) { + return field.default + } + if (field.value && typeof field.value === 'object') { + if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) { + return (field.value as any).defaultValue + } + if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') { + return (field.value as any).value + } + } + return null +} + const formatDefaultValue = (type: string, defaultValue: any): string => { if (defaultValue === null || defaultValue === undefined) { return '' } + if (typeof defaultValue === 'object') { + if (defaultValue === null) { + return '' + } + if ('defaultValue' in (defaultValue as Record)) { + return formatDefaultValue(type, (defaultValue as Record).defaultValue) + } + if ('value' in (defaultValue as Record)) { + return formatDefaultValue(type, (defaultValue as Record).value) + } + return '' + } if (type === 'boolean') { const normalized = String(defaultValue).toLowerCase() if (normalized === 'true' || normalized === '1') { @@ -894,6 +951,55 @@ const formatDefaultValue = (type: string, defaultValue: any): string => { return String(defaultValue) } +const resolveRequiredFlag = (field: any): boolean => { + if (typeof field?.required === 'boolean') { + return field.required + } + const nestedRequired = field?.value?.required + if (typeof nestedRequired === 'boolean') { + return nestedRequired + } + if (typeof nestedRequired === 'string') { + const normalized = nestedRequired.toLowerCase() + return normalized === 'true' || normalized === '1' + } + return false +} + +const resolveOptions = (field: any): string[] => { + const sources = [field?.options, field?.value?.options, field?.value?.choices] + for (const source of sources) { + if (Array.isArray(source)) { + const mapped = source + .map((option: unknown) => { + if (option === null || option === undefined) { + return '' + } + if (typeof option === 'string') { + return option.trim() + } + if (typeof option === 'object') { + const record = option as Record + const keys = ['value', 'label', 'name'] + for (const key of keys) { + const candidate = record[key] + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim() + } + } + } + const fallback = String(option).trim() + return fallback === '[object Object]' ? '' : fallback + }) + .filter((option) => option.length > 0) + if (mapped.length) { + return mapped + } + } + } + return [] +} + const buildCustomFieldMetadata = (field: CustomFieldInput) => ({ customFieldName: field.name, customFieldType: field.type, diff --git a/app/pages/pieces/[id]/edit.vue b/app/pages/pieces/[id]/edit.vue index 6228ef3..4cf5b9e 100644 --- a/app/pages/pieces/[id]/edit.vue +++ b/app/pages/pieces/[id]/edit.vue @@ -699,7 +699,8 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => { const options = Array.isArray(rawField.options) ? rawField.options.filter((option: unknown): option is string => typeof option === 'string') : [] - const value = formatDefaultValue(type, rawField.value) + const defaultSource = resolveDefaultValue(rawField) + const value = formatDefaultValue(type, defaultSource) const id = typeof rawField.id === 'string' ? rawField.id : null const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id const customFieldValueId = typeof rawField.customFieldValueId === 'string' @@ -728,10 +729,46 @@ const resolveFieldType = (field: any): string => { return allowed.includes(value) ? value : 'text' } +const resolveDefaultValue = (field: any): any => { + if (!field || typeof field !== 'object') { + return null + } + if (field.defaultValue !== undefined && field.defaultValue !== null) { + return field.defaultValue + } + if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') { + return field.value + } + if (field.default !== undefined && field.default !== null) { + return field.default + } + if (field.value && typeof field.value === 'object') { + if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) { + return (field.value as any).defaultValue + } + if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') { + return (field.value as any).value + } + } + return null +} + const formatDefaultValue = (type: string, defaultValue: any): string => { if (defaultValue === null || defaultValue === undefined) { return '' } + if (typeof defaultValue === 'object') { + if (defaultValue === null) { + return '' + } + if ('defaultValue' in (defaultValue as Record)) { + return formatDefaultValue(type, (defaultValue as Record).defaultValue) + } + if ('value' in (defaultValue as Record)) { + return formatDefaultValue(type, (defaultValue as Record).value) + } + return '' + } if (type === 'boolean') { const normalized = String(defaultValue).toLowerCase() if (normalized === 'true' || normalized === '1') { diff --git a/app/pages/pieces/create.vue b/app/pages/pieces/create.vue index 76b7b82..1181448 100644 --- a/app/pages/pieces/create.vue +++ b/app/pages/pieces/create.vue @@ -494,7 +494,8 @@ const normalizeCustomField = (rawField: any): CustomFieldInput | null => { const options = Array.isArray(rawField.options) ? rawField.options.filter((option: unknown): option is string => typeof option === 'string') : [] - const value = formatDefaultValue(type, rawField.value) + const defaultSource = resolveDefaultValue(rawField) + const value = formatDefaultValue(type, defaultSource) const id = typeof rawField.id === 'string' ? rawField.id : null const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id const customFieldValueId = typeof rawField.customFieldValueId === 'string' @@ -523,10 +524,46 @@ const resolveFieldType = (field: any): string => { return allowed.includes(value) ? value : 'text' } +const resolveDefaultValue = (field: any): any => { + if (!field || typeof field !== 'object') { + return null + } + if (field.defaultValue !== undefined && field.defaultValue !== null) { + return field.defaultValue + } + if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') { + return field.value + } + if (field.default !== undefined && field.default !== null) { + return field.default + } + if (field.value && typeof field.value === 'object') { + if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) { + return (field.value as any).defaultValue + } + if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') { + return (field.value as any).value + } + } + return null +} + const formatDefaultValue = (type: string, defaultValue: any): string => { if (defaultValue === null || defaultValue === undefined) { return '' } + if (typeof defaultValue === 'object') { + if (defaultValue === null) { + return '' + } + if ('defaultValue' in (defaultValue as Record)) { + return formatDefaultValue(type, (defaultValue as Record).defaultValue) + } + if ('value' in (defaultValue as Record)) { + return formatDefaultValue(type, (defaultValue as Record).value) + } + return '' + } if (type === 'boolean') { const normalized = String(defaultValue).toLowerCase() if (normalized === 'true' || normalized === '1') { diff --git a/app/shared/modelUtils.ts b/app/shared/modelUtils.ts index 2d4691a..beedec8 100644 --- a/app/shared/modelUtils.ts +++ b/app/shared/modelUtils.ts @@ -1,5 +1,6 @@ import { createEmptyComponentModelStructure, + type ComponentModelCustomFieldType, type ComponentModelCustomField, type ComponentModelPiece, type ComponentModelStructure, @@ -61,6 +62,31 @@ export const cloneStructure = (input: any): ComponentModelStructure => { } } +const toStringArray = (input: unknown): string[] | undefined => { + if (!Array.isArray(input)) { + return undefined + } + const parsed = input + .map((value) => { + if (typeof value === 'string') { + return value.trim() + } + if (value === null || value === undefined) { + return '' + } + return String(value).trim() + }) + .filter((value) => value.length > 0) + return parsed.length ? parsed : undefined +} + +const extractFieldValueObject = (field: any): Record => { + if (isPlainObject(field?.value)) { + return field.value as Record + } + return {} +} + const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => { if (!Array.isArray(fields)) { return [] @@ -68,32 +94,84 @@ const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => { return fields .map((field) => { - const name = typeof field?.name === 'string' ? field.name.trim() : '' + const rawName = + typeof field?.name === 'string' + ? field.name + : typeof field?.key === 'string' + ? field.key + : '' + const name = rawName.trim() if (!name) { return null } - const type = typeof field?.type === 'string' && field.type ? field.type : 'text' - const required = !!field?.required + const valueObject = extractFieldValueObject(field) + + const candidateType = + typeof field?.type === 'string' && field.type + ? field.type + : typeof valueObject?.type === 'string' + ? valueObject.type + : '' + const allowedTypes: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date'] + const type = allowedTypes.includes(candidateType as ComponentModelCustomFieldType) + ? (candidateType as ComponentModelCustomFieldType) + : 'text' + + const required = + typeof valueObject?.required === 'boolean' ? valueObject.required : !!field?.required let options: string[] | undefined if (type === 'select') { - const rawOptions = typeof field?.optionsText === 'string' - ? field.optionsText - : Array.isArray(field?.options) - ? field.options.join('\n') - : '' - const parsed = rawOptions - .split(/\r?\n/) - .map((option) => option.trim()) - .filter((option) => option.length > 0) - options = parsed.length > 0 ? parsed : undefined + options = + toStringArray(valueObject?.options) || + toStringArray((valueObject as any)?.choices) || + toStringArray(field?.options) + + if (!options && typeof field?.optionsText === 'string') { + const parsedFromText = field.optionsText + .split(/\r?\n/) + .map((option) => option.trim()) + .filter((option) => option.length > 0) + options = parsedFromText.length ? parsedFromText : undefined + } } const result: ComponentModelCustomField = { name, type, required } if (options) { result.options = options } + const defaultCandidate = + field?.defaultValue ?? valueObject?.defaultValue ?? field?.value ?? field?.default ?? null + const resolvedDefault = (() => { + if (defaultCandidate === undefined || defaultCandidate === null) { + return undefined + } + if (typeof defaultCandidate === 'object') { + if (defaultCandidate === null) { + return undefined + } + if ('defaultValue' in (defaultCandidate as Record)) { + return (defaultCandidate as Record).defaultValue + } + if ('value' in (defaultCandidate as Record)) { + return (defaultCandidate as Record).value + } + return undefined + } + return defaultCandidate + })() + if (resolvedDefault !== undefined && resolvedDefault !== null && resolvedDefault !== '') { + result.defaultValue = String(resolvedDefault) + } + const id = typeof field?.id === 'string' ? field.id : undefined + if (id) { + result.id = id + } + const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined + if (customFieldId) { + result.customFieldId = customFieldId + } return result }) .filter((field): field is ComponentModelCustomField => !!field) @@ -207,13 +285,142 @@ const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] .filter((component): component is ComponentModelStructureNode => !!component) } -export const normalizeStructureForSave = (input: any): ComponentModelStructure => { +export const normalizeStructureForEditor = (input: any): ComponentModelStructure => { const source = cloneStructure(input) + const sanitizedCustomFields = sanitizeCustomFields(source.customFields) + const customFields = sanitizedCustomFields.map((field) => { + const options = Array.isArray(field.options) ? [...field.options] : [] + const optionsText = options.length ? options.join('\n') : '' + const defaultValue = + field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== '' + ? String(field.defaultValue) + : null + const copy: ComponentModelCustomField = { + name: field.name, + type: field.type, + required: field.required, + options, + defaultValue, + optionsText, + id: field.id, + customFieldId: field.customFieldId, + } + return copy + }) + const result: ComponentModelStructure = { - customFields: sanitizeCustomFields(source.customFields), + customFields: customFields as ComponentModelCustomField[], pieces: sanitizePieces(source.pieces), - subcomponents: sanitizeSubcomponents(source.subcomponents), + subcomponents: hydrateSubcomponents(source.subcomponents), + } + + if (typeof source.typeComposantId === 'string' && source.typeComposantId.length > 0) { + result.typeComposantId = source.typeComposantId + } + if (typeof source.typeComposantLabel === 'string' && source.typeComposantLabel.length > 0) { + result.typeComposantLabel = source.typeComposantLabel + } + if (typeof source.modelId === 'string' && source.modelId.length > 0) { + result.modelId = source.modelId + } + if (typeof source.familyCode === 'string' && source.familyCode.length > 0) { + result.familyCode = source.familyCode + } + if (typeof source.alias === 'string' && source.alias.length > 0) { + result.alias = source.alias + } + + return result +} + +export const normalizeStructureForSave = (input: any): any => { + const source = cloneStructure(input) + + const sanitizedCustomFields = sanitizeCustomFields(source.customFields) + const backendCustomFields = sanitizedCustomFields.map((field) => { + const value: Record = { + type: field.type, + required: !!field.required, + } + if (field.options && field.options.length) { + value.options = field.options + } + if (field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== '') { + value.defaultValue = field.defaultValue + } + const payload: Record = { + key: field.name, + value, + } + if (field.id) { + payload.id = field.id + } + if (field.customFieldId) { + payload.customFieldId = field.customFieldId + } + return payload + }) as any + + const backendPieces = sanitizePieces(source.pieces).map((piece) => { + const payload: Record = {} + if ((piece as any).familyCode) { + payload.familyCode = (piece as any).familyCode + } + if (piece.typePieceId) { + payload.typePieceId = piece.typePieceId + } + if (piece.typePieceLabel) { + payload.typePieceLabel = piece.typePieceLabel + } + if (piece.reference) { + payload.reference = piece.reference + } + return payload + }) as any + + const mapSubcomponentForSave = (subcomponent: ComponentModelStructureNode): any => { + const payload: Record = {} + if (subcomponent.typeComposantId) { + payload.typeComposantId = subcomponent.typeComposantId + } + if (subcomponent.modelId) { + payload.modelId = subcomponent.modelId + } + if (subcomponent.familyCode) { + payload.familyCode = subcomponent.familyCode + } + if (subcomponent.alias) { + payload.alias = subcomponent.alias + } + if (Array.isArray(subcomponent.subcomponents) && subcomponent.subcomponents.length) { + payload.subcomponents = subcomponent.subcomponents.map(mapSubcomponentForSave) + } + return payload + } + + const backendSubcomponents = sanitizeSubcomponents(source.subcomponents).map(mapSubcomponentForSave) as any + + const result: ComponentModelStructure = { + customFields: backendCustomFields, + pieces: backendPieces, + subcomponents: backendSubcomponents, + } + + if (typeof source.typeComposantId === 'string' && source.typeComposantId.length > 0) { + (result as any).typeComposantId = source.typeComposantId + } + if (typeof source.typeComposantLabel === 'string' && source.typeComposantLabel.length > 0) { + (result as any).typeComposantLabel = source.typeComposantLabel + } + if (typeof source.modelId === 'string' && source.modelId.length > 0) { + (result as any).modelId = source.modelId + } + if (typeof source.familyCode === 'string' && source.familyCode.length > 0) { + (result as any).familyCode = source.familyCode + } + if (typeof source.alias === 'string' && source.alias.length > 0) { + (result as any).alias = source.alias } return result @@ -224,13 +431,83 @@ const hydrateCustomFields = (fields: any[]): any[] => { return [] } - return fields.map((field) => ({ - name: field?.name ?? '', - type: field?.type ?? 'text', - required: !!field?.required, - options: Array.isArray(field?.options) ? field.options : [], - optionsText: Array.isArray(field?.options) ? field.options.join('\n') : (field?.optionsText ?? ''), - })) + return fields.map((field) => { + const valueObject = extractFieldValueObject(field) + const name = typeof field?.name === 'string' + ? field.name + : typeof field?.key === 'string' + ? field.key + : '' + + const candidateType = + typeof field?.type === 'string' && field.type + ? field.type + : typeof valueObject?.type === 'string' + ? valueObject.type + : '' + const allowedTypes: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date'] + const type = allowedTypes.includes(candidateType as ComponentModelCustomFieldType) + ? (candidateType as ComponentModelCustomFieldType) + : 'text' + + const required = + typeof field?.required === 'boolean' + ? field.required + : typeof valueObject?.required === 'boolean' + ? valueObject.required + : false + + const options = + toStringArray(field?.options) || + toStringArray(valueObject?.options) || + toStringArray((valueObject as any)?.choices) || + [] + + const optionsText = typeof field?.optionsText === 'string' + ? field.optionsText + : options.length + ? options.join('\n') + : '' + + const defaultCandidate = + field?.defaultValue ?? valueObject?.defaultValue ?? field?.value ?? field?.default ?? null + const resolvedDefault = (() => { + if (defaultCandidate === undefined || defaultCandidate === null) { + return undefined + } + if (typeof defaultCandidate === 'object') { + if (defaultCandidate === null) { + return undefined + } + if ('defaultValue' in (defaultCandidate as Record)) { + return (defaultCandidate as Record).defaultValue + } + if ('value' in (defaultCandidate as Record)) { + return (defaultCandidate as Record).value + } + return undefined + } + return defaultCandidate + })() + const defaultValue = + resolvedDefault !== undefined && resolvedDefault !== null && resolvedDefault !== '' + ? String(resolvedDefault) + : '' + + const id = typeof field?.id === 'string' ? field.id : undefined + const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined + + return { + name, + type, + required, + options, + optionsText, + defaultValue, + id, + customFieldId, + } + }) } const hydratePieces = (pieces: any[]): ComponentModelPiece[] => { @@ -280,27 +557,29 @@ export const hydrateStructureForEditor = (input: any): ComponentModelStructure = } } -const toOptionsText = (field: any) => { - if (typeof field?.optionsText === 'string') { - return field.optionsText - } - if (Array.isArray(field?.options)) { - return field.options.join('\n') - } - return '' -} - const mapComponentCustomFields = (fields: any[]) => { if (!Array.isArray(fields)) { return [] } - return fields.map((field) => ({ - name: field?.name ?? '', - type: field?.type ?? 'text', - required: !!field?.required, - options: Array.isArray(field?.options) ? field.options : [], - optionsText: toOptionsText(field), - })) + return hydrateCustomFields(fields).map((field) => { + const defaultValue = + field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== '' + ? field.defaultValue + : null + return { + name: typeof field?.name === 'string' ? field.name : '', + type: field?.type ?? 'text', + required: !!field?.required, + options: Array.isArray(field?.options) ? field.options : [], + optionsText: typeof field?.optionsText === 'string' ? field.optionsText : '', + defaultValue, + id: typeof (field as any)?.id === 'string' ? (field as any).id : undefined, + customFieldId: + typeof (field as any)?.customFieldId === 'string' + ? (field as any).customFieldId + : undefined, + } + }) } const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => { @@ -352,7 +631,7 @@ export const extractStructureFromComponent = (component: any) => { alias: component?.alias ?? component?.name ?? '', } - return normalizeStructureForSave(raw) + return normalizeStructureForEditor(raw) } export const computeStructureStats = (structure: any): ModelStructurePreview => { @@ -518,7 +797,11 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField type: field?.type ?? 'text', required: !!field?.required, options: Array.isArray(field?.options) ? field.options : undefined, - optionsText: Array.isArray(field?.options) ? field.options.join('\n') : (field?.optionsText ?? ''), + optionsText: typeof field?.optionsText === 'string' + ? field.optionsText + : Array.isArray(field?.options) + ? field.options.join('\n') + : '', })) } diff --git a/app/shared/types/inventory.ts b/app/shared/types/inventory.ts index f08352a..2257c41 100644 --- a/app/shared/types/inventory.ts +++ b/app/shared/types/inventory.ts @@ -5,6 +5,10 @@ export interface ComponentModelCustomField { type: ComponentModelCustomFieldType required: boolean options?: string[] + defaultValue?: string | null + optionsText?: string + id?: string + customFieldId?: string } export interface ComponentModelPiece {