refactor: adopt canonical component model structure schema

This commit is contained in:
MatthieuTD
2025-10-01 14:26:31 +02:00
parent d3f8ac3649
commit 386a1c9d1b
9 changed files with 470 additions and 146 deletions

View File

@@ -320,13 +320,13 @@
</div> </div>
<!-- Sub Components --> <!-- Sub Components -->
<div v-if="component.subComponents && component.subComponents.length > 0" class="space-y-3"> <div v-if="childComponents.length > 0" class="space-y-3">
<h4 class="font-semibold text-gray-700"> <h4 class="font-semibold text-gray-700">
Sous-composants Sous-composants
</h4> </h4>
<div class="space-y-3 pl-4 border-l-2 border-gray-200"> <div class="space-y-3 pl-4 border-l-2 border-gray-200">
<ComponentItem <ComponentItem
v-for="subComponent in component.subComponents" v-for="subComponent in childComponents"
:key="subComponent.id" :key="subComponent.id"
:component="subComponent" :component="subComponent"
:is-edit-mode="isEditMode" :is-edit-mode="isEditMode"
@@ -415,6 +415,10 @@ const componentModelOptionsList = computed(() => {
return Array.isArray(provided) && provided.length ? provided : props.componentModelOptions return Array.isArray(provided) && provided.length ? provided : props.componentModelOptions
}) })
const pieceModelOptionsList = computed(() => props.pieceModelOptionsProvider(props.component) || []) const pieceModelOptionsList = computed(() => props.pieceModelOptionsProvider(props.component) || [])
const childComponents = computed(() => {
const list = props.component.subcomponents || props.component.subComponents || []
return Array.isArray(list) ? list : []
})
function fieldKeyFromNameAndType(name, type) { function fieldKeyFromNameAndType(name, type) {
const normalizedName = typeof name === 'string' ? name : '' const normalizedName = typeof name === 'string' ? name : ''
const normalizedType = typeof type === 'string' ? type : '' const normalizedType = typeof type === 'string' ? type : ''

View File

@@ -22,6 +22,7 @@ import {
} from '~/shared/modelUtils' } from '~/shared/modelUtils'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
defineOptions({ name: 'ComponentModelStructureEditor' }) defineOptions({ name: 'ComponentModelStructureEditor' })
@@ -46,7 +47,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const localStructure = reactive(hydrateStructureForEditor(props.modelValue)) const localStructure = reactive<ComponentModelStructure>(hydrateStructureForEditor(props.modelValue))
const previousLockedLabel = ref(props.rootTypeLabel || '') const previousLockedLabel = ref(props.rootTypeLabel || '')
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
@@ -77,9 +78,14 @@ const syncRootType = () => {
localStructure.typeComposantId = newTypeId localStructure.typeComposantId = newTypeId
localStructure.typeComposantLabel = newLabel localStructure.typeComposantLabel = newLabel
const match = availableComponentTypes.value.find((type) => type?.id === newTypeId)
if (match?.code) {
localStructure.familyCode = match.code
}
const previousLabel = previousLockedLabel.value const previousLabel = previousLockedLabel.value
if (!localStructure.name || localStructure.name === previousLabel || localStructure.name === '') { if (!localStructure.alias || localStructure.alias === previousLabel || localStructure.alias === '') {
localStructure.name = newLabel || localStructure.name localStructure.alias = newLabel || localStructure.alias
} }
previousLockedLabel.value = newLabel previousLockedLabel.value = newLabel
@@ -91,7 +97,12 @@ const syncFromProps = (value: any) => {
const hydrated = hydrateStructureForEditor(value) const hydrated = hydrateStructureForEditor(value)
localStructure.customFields = hydrated.customFields localStructure.customFields = hydrated.customFields
localStructure.pieces = hydrated.pieces localStructure.pieces = hydrated.pieces
localStructure.subComponents = hydrated.subComponents localStructure.subcomponents = hydrated.subcomponents
localStructure.typeComposantId = hydrated.typeComposantId
localStructure.typeComposantLabel = hydrated.typeComposantLabel
localStructure.modelId = hydrated.modelId
localStructure.familyCode = hydrated.familyCode
localStructure.alias = hydrated.alias
lastEmitted = JSON.stringify(cloneStructure(value)) lastEmitted = JSON.stringify(cloneStructure(value))
syncRootType() syncRootType()
} }

View File

@@ -58,6 +58,6 @@ const props = defineProps({
selection: { type: Object, required: true } selection: { type: Object, required: true }
}) })
const childComponents = computed(() => props.component.subComponents || []) const childComponents = computed(() => props.component.subcomponents || props.component.subComponents || [])
const childPieces = computed(() => props.component.pieces || []) const childPieces = computed(() => props.component.pieces || [])
</script> </script>

View File

@@ -3,8 +3,8 @@
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-600"> <div class="flex flex-wrap items-center gap-2 text-sm text-gray-600">
<span v-if="stats.customFields" class="badge badge-outline badge-sm">{{ stats.customFields }} champ(s)</span> <span v-if="stats.customFields" class="badge badge-outline badge-sm">{{ stats.customFields }} champ(s)</span>
<span v-if="stats.pieces" class="badge badge-outline badge-sm">{{ stats.pieces }} pièce(s)</span> <span v-if="stats.pieces" class="badge badge-outline badge-sm">{{ stats.pieces }} pièce(s)</span>
<span v-if="stats.subComponents" class="badge badge-outline badge-sm">{{ stats.subComponents }} sous-composant(s)</span> <span v-if="stats.subcomponents" class="badge badge-outline badge-sm">{{ stats.subcomponents }} sous-composant(s)</span>
<span v-if="!stats.customFields && !stats.pieces && !stats.subComponents" class="text-xs text-gray-500"> <span v-if="!stats.customFields && !stats.pieces && !stats.subcomponents" class="text-xs text-gray-500">
Structure vide Structure vide
</span> </span>
</div> </div>

View File

@@ -5,7 +5,7 @@
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="space-y-1"> <div class="space-y-1">
<h4 class="font-semibold text-sm"> <h4 class="font-semibold text-sm">
{{ node.name || node.typeComposantLabel || 'Composant' }} {{ node.alias || node.typeComposantLabel || 'Composant' }}
</h4> </h4>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
{{ {{
@@ -46,15 +46,15 @@
</div> </div>
</div> </div>
<div v-if="hasPieces" class="space-y-2"> <div v-if="hasPieces" class="space-y-2">
<h5 class="text-[11px] font-semibold uppercase text-gray-500">Pièces associées</h5> <h5 class="text-[11px] font-semibold uppercase text-gray-500">Pièces associées</h5>
<div <div
v-for="(piece, pieceIndex) in node.pieces" v-for="(piece, pieceIndex) in node.pieces"
:key="pieceIndex" :key="pieceIndex"
class="bg-base-200/60 border border-base-200 rounded-md p-3 space-y-2" class="bg-base-200/60 border border-base-200 rounded-md p-3 space-y-2"
> >
<div class="space-y-1"> <div class="space-y-1">
<span class="font-medium text-sm">{{ piece.name || piece.typePieceLabel || 'Pièce' }}</span> <span class="font-medium text-sm">{{ piece.typePieceLabel || 'Pièce' }}</span>
<p class="text-[11px] text-gray-500"> <p class="text-[11px] text-gray-500">
{{ {{
piece.typePieceLabel piece.typePieceLabel
@@ -95,7 +95,7 @@
<div v-if="hasSubComponents" class="space-y-3"> <div v-if="hasSubComponents" class="space-y-3">
<h5 class="text-[11px] font-semibold uppercase text-gray-500">Sous-composants</h5> <h5 class="text-[11px] font-semibold uppercase text-gray-500">Sous-composants</h5>
<SkeletonComponentNodeSelector <SkeletonComponentNodeSelector
v-for="(subComponent, index) in node.subComponents" v-for="(subComponent, index) in (node.subcomponents || node.subComponents || [])"
:key="index" :key="index"
:node="subComponent" :node="subComponent"
:depth="depth + 1" :depth="depth + 1"
@@ -168,7 +168,10 @@ const selectedComponentModelLabel = computed(() => {
}) })
const hasPieces = computed(() => Array.isArray(props.node?.pieces) && props.node.pieces.length > 0) const hasPieces = computed(() => Array.isArray(props.node?.pieces) && props.node.pieces.length > 0)
const hasSubComponents = computed(() => Array.isArray(props.node?.subComponents) && props.node.subComponents.length > 0) const hasSubComponents = computed(() => {
const list = props.node?.subcomponents || props.node?.subComponents || []
return Array.isArray(list) && list.length > 0
})
const getPieceModels = (typePieceId) => { const getPieceModels = (typePieceId) => {
if (!typePieceId) { if (!typePieceId) {

View File

@@ -28,6 +28,17 @@
<p class="text-[11px] text-gray-500"> <p class="text-[11px] text-gray-500">
{{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }} {{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p> </p>
<div v-if="!isRoot" class="form-control mt-2">
<label class="label py-1">
<span class="label-text text-[11px]">Alias (optionnel)</span>
</label>
<input
v-model="node.alias"
type="text"
class="input input-bordered input-xs"
placeholder="Alias du sous-composant"
/>
</div>
</template> </template>
<template v-else> <template v-else>
<div class="input input-bordered input-sm bg-base-200 flex items-center"> <div class="input input-bordered input-sm bg-base-200 flex items-center">
@@ -170,12 +181,12 @@
<p v-if="!isRoot" class="text-[11px] text-gray-500"> <p v-if="!isRoot" class="text-[11px] text-gray-500">
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle. Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
</p> </p>
<p v-if="!(node.subComponents?.length)" class="text-xs text-gray-500"> <p v-if="!(node.subcomponents?.length)" class="text-xs text-gray-500">
Aucun sous-composant défini. Aucun sous-composant défini.
</p> </p>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<StructureNodeEditor <StructureNodeEditor
v-for="(subComponent, index) in node.subComponents" v-for="(subComponent, index) in node.subcomponents"
:key="`sub-${index}`" :key="`sub-${index}`"
:node="subComponent" :node="subComponent"
:depth="depth + 1" :depth="depth + 1"
@@ -194,6 +205,7 @@
import { computed, watch } from 'vue' import { computed, watch } from 'vue'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash' import IconLucideTrash from '~icons/lucide/trash'
import type { ComponentModelPiece, ComponentModelStructureNode } from '~/shared/types/inventory'
defineOptions({ name: 'StructureNodeEditor' }) defineOptions({ name: 'StructureNodeEditor' })
@@ -203,8 +215,13 @@ type ModelTypeOption = {
code?: string | null code?: string | null
} }
type EditableStructureNode = ComponentModelStructureNode & {
customFields?: any[]
pieces?: ComponentModelPiece[]
}
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
node: Record<string, any> node: EditableStructureNode
depth?: number depth?: number
componentTypes?: ModelTypeOption[] componentTypes?: ModelTypeOption[]
pieceTypes?: ModelTypeOption[] pieceTypes?: ModelTypeOption[]
@@ -281,23 +298,31 @@ const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) => const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type) formatModelTypeOption(type)
const ensureArray = (key: 'customFields' | 'pieces' | 'subComponents') => { const ensureArray = (key: 'customFields' | 'pieces' | 'subcomponents') => {
if (!Array.isArray(props.node[key])) { if (!Array.isArray((props.node as any)[key])) {
props.node[key] = [] if (key === 'subcomponents') {
props.node.subcomponents = []
} else {
(props.node as any)[key] = []
}
} }
} }
const syncComponentType = (component: any) => { const syncComponentType = (component: EditableStructureNode) => {
if (!component) { if (!component) {
return return
} }
if (props.lockType && props.isRoot) { if (props.lockType && props.isRoot) {
if (props.lockedTypeLabel) { if (props.lockedTypeLabel) {
component.typeComposantLabel = props.lockedTypeLabel component.typeComposantLabel = props.lockedTypeLabel
if (!component.name || component.name === component.typeComposantLabel) { if (!component.alias || component.alias === component.typeComposantLabel) {
component.name = props.lockedTypeLabel component.alias = props.lockedTypeLabel
} }
} }
if (component.typeComposantId) {
const option = componentTypeMap.value.get(component.typeComposantId)
component.familyCode = option?.code ?? component.familyCode
}
return return
} }
const id = typeof component.typeComposantId === 'string' const id = typeof component.typeComposantId === 'string'
@@ -306,29 +331,31 @@ const syncComponentType = (component: any) => {
if (!id) { if (!id) {
component.typeComposantLabel = '' component.typeComposantLabel = ''
component.name = '' component.familyCode = ''
return return
} }
const option = componentTypeMap.value.get(id) const option = componentTypeMap.value.get(id)
if (!option) { if (!option) {
component.typeComposantLabel = '' component.typeComposantLabel = ''
component.name = '' component.familyCode = ''
return return
} }
component.typeComposantLabel = formatModelTypeOption(option) component.typeComposantLabel = formatModelTypeOption(option)
component.name = option.name || component.typeComposantLabel component.familyCode = option.code ?? component.familyCode
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
component.alias = option.name || component.typeComposantLabel
}
} }
const updatePieceTypeLabel = (piece: any) => { const updatePieceTypeLabel = (piece: ComponentModelPiece & Record<string, any>) => {
if (!piece) return if (!piece) return
if (piece.typePieceId) { if (piece.typePieceId) {
const option = pieceTypeMap.value.get(piece.typePieceId) const option = pieceTypeMap.value.get(piece.typePieceId)
if (option) { if (option) {
piece.typePieceLabel = formatPieceTypeOption(option) piece.typePieceLabel = formatPieceTypeOption(option)
piece.name = option.name || formatPieceTypeOption(option)
return return
} }
} }
@@ -345,15 +372,10 @@ const updatePieceTypeLabel = (piece: any) => {
if (match) { if (match) {
piece.typePieceId = match.id piece.typePieceId = match.id
piece.typePieceLabel = formatPieceTypeOption(match) piece.typePieceLabel = formatPieceTypeOption(match)
piece.name = match.name || formatPieceTypeOption(match)
return return
} }
} }
} }
if (!piece.name) {
piece.name = piece.typePieceLabel || ''
}
} }
const syncPieceLabels = (pieces?: any[]) => { const syncPieceLabels = (pieces?: any[]) => {
@@ -369,25 +391,22 @@ const handleComponentTypeSelect = (component: any) => {
syncComponentType(component) syncComponentType(component)
} }
const handlePieceTypeSelect = (piece: any) => { const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>) => {
if (!piece) { if (!piece) {
return return
} }
const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : '' const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : ''
if (!id) { if (!id) {
piece.typePieceLabel = '' piece.typePieceLabel = ''
piece.name = ''
return return
} }
const option = pieceTypeMap.value.get(id) const option = pieceTypeMap.value.get(id)
if (!option) { if (!option) {
piece.typePieceId = '' piece.typePieceId = ''
piece.typePieceLabel = '' piece.typePieceLabel = ''
piece.name = ''
return return
} }
piece.typePieceLabel = formatPieceTypeOption(option) piece.typePieceLabel = formatPieceTypeOption(option)
piece.name = option.name || piece.typePieceLabel
} }
const addCustomField = () => { const addCustomField = () => {
@@ -409,9 +428,9 @@ const removeCustomField = (index: number) => {
const addPiece = () => { const addPiece = () => {
ensureArray('pieces') ensureArray('pieces')
props.node.pieces.push({ props.node.pieces.push({
name: '',
typePieceId: '', typePieceId: '',
typePieceLabel: '', typePieceLabel: '',
reference: '',
}) })
} }
@@ -421,17 +440,20 @@ const removePiece = (index: number) => {
} }
const addSubComponent = () => { const addSubComponent = () => {
ensureArray('subComponents') ensureArray('subcomponents')
props.node.subComponents.push({ props.node.subcomponents.push({
name: '',
typeComposantId: '', typeComposantId: '',
typeComposantLabel: '', typeComposantLabel: '',
modelId: '',
familyCode: '',
alias: '',
subcomponents: [],
}) })
} }
const removeSubComponent = (index: number) => { const removeSubComponent = (index: number) => {
if (!Array.isArray(props.node.subComponents)) return if (!Array.isArray(props.node.subcomponents)) return
props.node.subComponents.splice(index, 1) props.node.subcomponents.splice(index, 1)
} }
watch(componentTypes, () => { watch(componentTypes, () => {
@@ -463,8 +485,12 @@ watch(
if (props.lockType && props.isRoot) { if (props.lockType && props.isRoot) {
const label = props.lockedTypeLabel || lockedTypeDisplay.value const label = props.lockedTypeLabel || lockedTypeDisplay.value
props.node.typeComposantLabel = label props.node.typeComposantLabel = label
if (label && (!props.node.name || props.node.name === lockedTypeDisplay.value)) { if (label && (!props.node.alias || props.node.alias === lockedTypeDisplay.value)) {
props.node.name = label props.node.alias = label
}
if (props.node.typeComposantId) {
const option = componentTypeMap.value.get(props.node.typeComposantId)
props.node.familyCode = option?.code ?? props.node.familyCode
} }
} }
}, },

View File

@@ -214,6 +214,7 @@ import {
cloneStructure, cloneStructure,
normalizeStructureForSave, normalizeStructureForSave,
} from '~/shared/modelUtils' } from '~/shared/modelUtils'
import { componentModelStructureValidator } from '~/shared/types/inventory'
import { formatFrenchDate } from '~/utils/date' import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideLayers from '~icons/lucide/layers' import IconLucideLayers from '~icons/lucide/layers'
@@ -328,12 +329,19 @@ const handleSubmit = async () => {
form.submitting = true form.submitting = true
try { try {
const normalizedStructure = normalizeStructureForSave(form.data.structure)
const validationResult = componentModelStructureValidator.safeParse(normalizedStructure)
if (!validationResult.success) {
showError(`Structure invalide: ${validationResult.issues.join(', ')}`)
return
}
if (form.mode === 'create') { if (form.mode === 'create') {
const result = await createComponentModel({ const result = await createComponentModel({
name: form.data.name.trim(), name: form.data.name.trim(),
description: form.data.description.trim() || undefined, description: form.data.description.trim() || undefined,
typeComposantId: form.data.typeComposantId, typeComposantId: form.data.typeComposantId,
structure: normalizeStructureForSave(form.data.structure), structure: normalizedStructure,
}) })
if (!result.success) { if (!result.success) {
showError(result.error || 'Impossible de créer le modèle') showError(result.error || 'Impossible de créer le modèle')
@@ -345,7 +353,7 @@ const handleSubmit = async () => {
name: form.data.name.trim(), name: form.data.name.trim(),
description: form.data.description.trim() || undefined, description: form.data.description.trim() || undefined,
typeComposantId: form.data.typeComposantId, typeComposantId: form.data.typeComposantId,
structure: normalizeStructureForSave(form.data.structure), structure: normalizedStructure,
}) })
if (!result.success) { if (!result.success) {
showError(result.error || 'Impossible de mettre à jour le modèle') showError(result.error || 'Impossible de mettre à jour le modèle')

View File

@@ -1,3 +1,11 @@
import {
createEmptyComponentModelStructure,
type ComponentModelCustomField,
type ComponentModelPiece,
type ComponentModelStructure,
type ComponentModelStructureNode,
} from './types/inventory'
export const isPlainObject = (value: unknown): value is Record<string, unknown> => { export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return value !== null && typeof value === 'object' && !Array.isArray(value) return value !== null && typeof value === 'object' && !Array.isArray(value)
} }
@@ -5,24 +13,50 @@ export const isPlainObject = (value: unknown): value is Record<string, unknown>
export interface ModelStructurePreview { export interface ModelStructurePreview {
customFields: number customFields: number
pieces: number pieces: number
subComponents: number subcomponents: number
} }
export const defaultStructure = () => ({ export const defaultStructure = (): ComponentModelStructure => ({
customFields: [], ...createEmptyComponentModelStructure(),
pieces: [],
subComponents: [],
}) })
export const cloneStructure = (input: any) => { const ensureStructureShape = (input: any): ComponentModelStructure => {
const base = createEmptyComponentModelStructure()
if (!isPlainObject(input)) {
return base
}
const clone: ComponentModelStructure = {
...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [],
subcomponents: Array.isArray((input as any).subcomponents)
? (input as any).subcomponents
: Array.isArray((input as any).subComponents)
? (input as any).subComponents
: [],
typeComposantId: typeof (input as any).typeComposantId === 'string' ? (input as any).typeComposantId : undefined,
typeComposantLabel: typeof (input as any).typeComposantLabel === 'string'
? (input as any).typeComposantLabel
: undefined,
modelId: typeof (input as any).modelId === 'string' ? (input as any).modelId : undefined,
familyCode: typeof (input as any).familyCode === 'string' ? (input as any).familyCode : undefined,
alias: typeof (input as any).alias === 'string' ? (input as any).alias : undefined,
}
return clone
}
export const cloneStructure = (input: any): ComponentModelStructure => {
try { try {
return JSON.parse(JSON.stringify(input ?? defaultStructure())) const cloned = JSON.parse(JSON.stringify(input ?? defaultStructure()))
return ensureStructureShape(cloned)
} catch (error) { } catch (error) {
return defaultStructure() return defaultStructure()
} }
} }
const sanitizeCustomFields = (fields: any[]): any[] => { const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
if (!Array.isArray(fields)) { if (!Array.isArray(fields)) {
return [] return []
} }
@@ -51,16 +85,16 @@ const sanitizeCustomFields = (fields: any[]): any[] => {
options = parsed.length > 0 ? parsed : undefined options = parsed.length > 0 ? parsed : undefined
} }
const result: Record<string, unknown> = { name, type, required } const result: ComponentModelCustomField = { name, type, required }
if (options) { if (options) {
result.options = options result.options = options
} }
return result return result
}) })
.filter(Boolean) .filter((field): field is ComponentModelCustomField => !!field)
} }
const sanitizePieces = (pieces: any[]): any[] => { const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) { if (!Array.isArray(pieces)) {
return [] return []
} }
@@ -81,26 +115,18 @@ const sanitizePieces = (pieces: any[]): any[] => {
: '' : ''
const typePieceLabel = rawTypePieceLabel.length > 0 ? rawTypePieceLabel : undefined const typePieceLabel = rawTypePieceLabel.length > 0 ? rawTypePieceLabel : undefined
const rawName = typeof piece?.name === 'string' ? piece.name.trim() : ''
const name = rawName || typePieceLabel || ''
if (!name) {
return null
}
const reference = typeof piece?.reference === 'string' && piece.reference.trim().length > 0 const reference = typeof piece?.reference === 'string' && piece.reference.trim().length > 0
? piece.reference.trim() ? piece.reference.trim()
: undefined : undefined
const quantity = Number(piece?.quantity) if (!typePieceId && !typePieceLabel && !reference) {
const normalizedQuantity = Number.isFinite(quantity) && quantity > 0 ? quantity : undefined return null
}
const result: Record<string, unknown> = { name } const result: ComponentModelPiece = {}
if (reference !== undefined) { if (reference !== undefined) {
result.reference = reference result.reference = reference
} }
if (normalizedQuantity !== undefined) {
result.quantity = normalizedQuantity
}
if (typePieceId) { if (typePieceId) {
result.typePieceId = typePieceId result.typePieceId = typePieceId
} }
@@ -109,10 +135,10 @@ const sanitizePieces = (pieces: any[]): any[] => {
} }
return result return result
}) })
.filter(Boolean) .filter((piece): piece is ComponentModelPiece => !!piece)
} }
const sanitizeSubComponents = (components: any[]): any[] => { const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) { if (!Array.isArray(components)) {
return [] return []
} }
@@ -126,63 +152,68 @@ const sanitizeSubComponents = (components: any[]): any[] => {
: '' : ''
const typeComposantId = rawTypeComposantId.length > 0 ? rawTypeComposantId : undefined const typeComposantId = rawTypeComposantId.length > 0 ? rawTypeComposantId : undefined
const rawTypeComposantLabel = typeof component?.typeComposantLabel === 'string' const modelId = typeof component?.modelId === 'string' && component.modelId.trim().length > 0
? component.modelId.trim()
: undefined
const familyCode = typeof component?.familyCode === 'string' && component.familyCode.trim().length > 0
? component.familyCode.trim()
: undefined
const alias = typeof component?.alias === 'string' && component.alias.trim().length > 0
? component.alias.trim()
: undefined
if (!typeComposantId && !modelId && !familyCode) {
return null
}
const result: ComponentModelStructureNode = {
subcomponents: sanitizeSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}
if (typeComposantId) {
result.typeComposantId = typeComposantId
}
const typeComposantLabel = typeof component?.typeComposantLabel === 'string'
? component.typeComposantLabel.trim() ? component.typeComposantLabel.trim()
: typeof component?.typeComposant?.name === 'string' : typeof component?.typeComposant?.name === 'string'
? component.typeComposant.name.trim() ? component.typeComposant.name.trim()
: '' : ''
const typeComposantLabel = rawTypeComposantLabel.length > 0 ? rawTypeComposantLabel : undefined
const rawName = typeof component?.name === 'string' ? component.name.trim() : ''
const name = rawName || typeComposantLabel || ''
if (!name) {
return null
}
const description = typeof component?.description === 'string' && component.description.trim().length > 0
? component.description.trim()
: undefined
const quantity = Number(component?.quantity)
const normalizedQuantity = Number.isFinite(quantity) && quantity > 0 ? quantity : undefined
const result: Record<string, unknown> = {
name,
}
if (description !== undefined) {
result.description = description
}
if (normalizedQuantity !== undefined) {
result.quantity = normalizedQuantity
}
if (typeComposantId) {
result.typeComposantId = typeComposantId
}
if (typeComposantLabel) { if (typeComposantLabel) {
result.typeComposantLabel = typeComposantLabel result.typeComposantLabel = typeComposantLabel
} }
if (modelId) {
const nestedSubComponents = sanitizeSubComponents(component?.subComponents) result.modelId = modelId
if (nestedSubComponents.length > 0) { }
result.subComponents = nestedSubComponents if (familyCode) {
} else { result.familyCode = familyCode
result.subComponents = [] }
if (alias) {
result.alias = alias
} }
// Sub components only carry structural info (they will be resolved via their own models)
return result return result
}) })
.filter(Boolean) .filter((component): component is ComponentModelStructureNode => !!component)
} }
export const normalizeStructureForSave = (input: any) => { export const normalizeStructureForSave = (input: any): ComponentModelStructure => {
const source = cloneStructure(input) const source = cloneStructure(input)
return { return {
customFields: sanitizeCustomFields(source.customFields), customFields: sanitizeCustomFields(source.customFields),
pieces: sanitizePieces(source.pieces), pieces: sanitizePieces(source.pieces),
subComponents: sanitizeSubComponents(source.subComponents), subcomponents: sanitizeSubcomponents(source.subcomponents),
typeComposantId: source.typeComposantId,
typeComposantLabel: source.typeComposantLabel,
modelId: source.modelId,
familyCode: source.familyCode,
alias: source.alias,
} }
} }
@@ -200,43 +231,50 @@ const hydrateCustomFields = (fields: any[]): any[] => {
})) }))
} }
const hydratePieces = (pieces: any[]): any[] => { const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) { if (!Array.isArray(pieces)) {
return [] return []
} }
return pieces.map((piece) => ({ return pieces.map((piece) => ({
name: piece?.name ?? piece?.typePiece?.name ?? piece?.typePieceLabel ?? '',
reference: piece?.reference ?? '',
quantity: piece?.quantity ?? piece?.quantite ?? undefined,
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '', typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '', typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
reference: piece?.reference ?? '',
})) }))
} }
const hydrateSubComponents = (components: any[]): any[] => { const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) { if (!Array.isArray(components)) {
return [] return []
} }
return components.map((component) => ({ return components.map((component) => ({
name: component?.name ?? component?.typeComposant?.name ?? component?.typeComposantLabel ?? '',
description: component?.description ?? '',
quantity: component?.quantity ?? component?.quantite ?? undefined,
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '', typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '', typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
customFields: [], modelId: component?.modelId ?? '',
pieces: [], familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
subComponents: hydrateSubComponents(component?.subComponents), alias: component?.alias ?? component?.name ?? '',
subcomponents: hydrateSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
})) }))
} }
export const hydrateStructureForEditor = (input: any) => { export const hydrateStructureForEditor = (input: any): ComponentModelStructure => {
const source = cloneStructure(input) const source = cloneStructure(input)
return { return {
customFields: hydrateCustomFields(source.customFields), customFields: hydrateCustomFields(source.customFields),
pieces: hydratePieces(source.pieces), pieces: hydratePieces(source.pieces),
subComponents: hydrateSubComponents(source.subComponents), subcomponents: hydrateSubcomponents(
Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents,
),
typeComposantId: source.typeComposantId ?? '',
typeComposantLabel: source.typeComposantLabel ?? '',
modelId: source.modelId ?? '',
familyCode: source.familyCode ?? '',
alias: source.alias ?? '',
} }
} }
@@ -263,32 +301,32 @@ const mapComponentCustomFields = (fields: any[]) => {
})) }))
} }
const mapComponentPieces = (pieces: any[]) => { const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) { if (!Array.isArray(pieces)) {
return [] return []
} }
return pieces.map((piece) => ({ return pieces.map((piece) => ({
name: piece?.name ?? piece?.typePiece?.name ?? '',
reference: piece?.reference ?? '', reference: piece?.reference ?? '',
quantity: piece?.quantity ?? piece?.quantite ?? undefined,
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '', typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '', typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
})) }))
} }
const mapSubComponents = (components: any[]): any[] => { const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) { if (!Array.isArray(components)) {
return [] return []
} }
return components.map((component) => ({ return components.map((component) => ({
name: component?.name ?? component?.typeComposant?.name ?? '',
description: component?.description ?? '',
quantity: component?.quantity ?? component?.quantite ?? undefined,
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '', typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '', typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
customFields: [], modelId: component?.modelId ?? '',
pieces: [], familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
subComponents: mapSubComponents(component?.subComponents), alias: component?.alias ?? component?.name ?? '',
subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
})) }))
} }
@@ -300,7 +338,16 @@ export const extractStructureFromComponent = (component: any) => {
const raw = { const raw = {
customFields: mapComponentCustomFields(component.customFields), customFields: mapComponentCustomFields(component.customFields),
pieces: mapComponentPieces(component.pieces), pieces: mapComponentPieces(component.pieces),
subComponents: mapSubComponents(component.subComponents), subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposant?.name ?? component?.typeComposantLabel ?? '',
modelId: component?.modelId ?? '',
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
alias: component?.alias ?? component?.name ?? '',
} }
return normalizeStructureForSave(raw) return normalizeStructureForSave(raw)
@@ -308,25 +355,29 @@ export const extractStructureFromComponent = (component: any) => {
export const computeStructureStats = (structure: any): ModelStructurePreview => { export const computeStructureStats = (structure: any): ModelStructurePreview => {
if (!structure || typeof structure !== 'object') { if (!structure || typeof structure !== 'object') {
return { customFields: 0, pieces: 0, subComponents: 0 } return { customFields: 0, pieces: 0, subcomponents: 0 }
} }
return { return {
customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0, customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0,
pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0, pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0,
subComponents: Array.isArray(structure.subComponents) ? structure.subComponents.length : 0, subcomponents: Array.isArray(structure.subcomponents)
? structure.subcomponents.length
: Array.isArray(structure.subComponents)
? structure.subComponents.length
: 0,
} }
} }
export const formatStructurePreview = (structure: any) => { export const formatStructurePreview = (structure: any) => {
const stats = computeStructureStats(structure) const stats = computeStructureStats(structure)
if (!stats.customFields && !stats.pieces && !stats.subComponents) { if (!stats.customFields && !stats.pieces && !stats.subcomponents) {
return 'Structure vide' return 'Structure vide'
} }
const segments: string[] = [] const segments: string[] = []
if (stats.customFields) segments.push(`${stats.customFields} champ(s)`) if (stats.customFields) segments.push(`${stats.customFields} champ(s)`)
if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`) if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`)
if (stats.subComponents) segments.push(`${stats.subComponents} sous-composant(s)`) if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`)
return segments.join(' • ') return segments.join(' • ')
} }

View File

@@ -0,0 +1,221 @@
export type ComponentModelCustomFieldType = 'text' | 'number' | 'select' | 'boolean' | 'date'
export interface ComponentModelCustomField {
name: string
type: ComponentModelCustomFieldType
required: boolean
options?: string[]
}
export interface ComponentModelPiece {
typePieceId?: string
typePieceLabel?: string
reference?: string
}
export interface ComponentModelStructureNode {
typeComposantId?: string
typeComposantLabel?: string
modelId?: string
familyCode?: string
alias?: string
subcomponents: ComponentModelStructureNode[]
}
export interface ComponentModelStructure extends ComponentModelStructureNode {
customFields: ComponentModelCustomField[]
pieces: ComponentModelPiece[]
}
const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0
const ensureString = (value: unknown): string | undefined => {
if (!isNonEmptyString(value)) {
return undefined
}
return value.trim()
}
const validateCustomField = (
value: unknown,
issues: string[],
path: string,
): ComponentModelCustomField | null => {
if (!isPlainObject(value)) {
issues.push(`${path}: expected object`)
return null
}
const name = ensureString(value.name)
if (!name) {
issues.push(`${path}.name: valeur manquante`)
return null
}
const type = isNonEmptyString(value.type) && FIELD_TYPES.includes(value.type as ComponentModelCustomFieldType)
? (value.type as ComponentModelCustomFieldType)
: 'text'
const required = !!value.required
let options: string[] | undefined
if (value.options !== undefined) {
if (!Array.isArray(value.options)) {
issues.push(`${path}.options: attendu tableau`)
} else {
const parsed = value.options.map(ensureString).filter((option): option is string => !!option)
if (parsed.length) {
options = parsed
}
}
}
return { name, type, required, ...(options ? { options } : {}) }
}
const validatePiece = (
value: unknown,
issues: string[],
path: string,
): ComponentModelPiece | null => {
if (!isPlainObject(value)) {
issues.push(`${path}: expected object`)
return null
}
const typePieceId = ensureString(value.typePieceId)
const typePieceLabel = ensureString(value.typePieceLabel)
const reference = ensureString(value.reference)
if (!typePieceId && !typePieceLabel && !reference) {
issues.push(`${path}: au moins un identifiant ou libellé de pièce est requis`)
return null
}
return {
...(typePieceId ? { typePieceId } : {}),
...(typePieceLabel ? { typePieceLabel } : {}),
...(reference ? { reference } : {}),
}
}
const validateStructureNode = (
value: unknown,
issues: string[],
path: string,
): ComponentModelStructureNode | null => {
if (!isPlainObject(value)) {
issues.push(`${path}: expected object`)
return null
}
const typeComposantId = ensureString(value.typeComposantId)
const typeComposantLabel = ensureString(value.typeComposantLabel)
const modelId = ensureString(value.modelId)
const familyCode = ensureString(value.familyCode)
const alias = ensureString(value.alias)
const rawSubcomponents = Array.isArray((value as any).subcomponents)
? (value as any).subcomponents
: []
const subcomponents: ComponentModelStructureNode[] = []
rawSubcomponents.forEach((subValue, index) => {
const parsed = validateStructureNode(subValue, issues, `${path}.subcomponents[${index}]`)
if (parsed) {
subcomponents.push(parsed)
}
})
return {
...(typeComposantId ? { typeComposantId } : {}),
...(typeComposantLabel ? { typeComposantLabel } : {}),
...(modelId ? { modelId } : {}),
...(familyCode ? { familyCode } : {}),
...(alias ? { alias } : {}),
subcomponents,
}
}
export interface ComponentModelStructureValidationSuccess {
success: true
data: ComponentModelStructure
}
export interface ComponentModelStructureValidationFailure {
success: false
issues: string[]
}
export type ComponentModelStructureValidationResult =
| ComponentModelStructureValidationSuccess
| ComponentModelStructureValidationFailure
export const componentModelStructureValidator = {
safeParse(value: unknown): ComponentModelStructureValidationResult {
const issues: string[] = []
if (!isPlainObject(value)) {
issues.push('Structure invalide: attendu un objet')
return { success: false, issues }
}
const customFieldsInput = Array.isArray(value.customFields) ? value.customFields : []
const piecesInput = Array.isArray(value.pieces) ? value.pieces : []
const customFields: ComponentModelCustomField[] = []
customFieldsInput.forEach((field, index) => {
const parsed = validateCustomField(field, issues, `customFields[${index}]`)
if (parsed) {
customFields.push(parsed)
}
})
const pieces: ComponentModelPiece[] = []
piecesInput.forEach((piece, index) => {
const parsed = validatePiece(piece, issues, `pieces[${index}]`)
if (parsed) {
pieces.push(parsed)
}
})
const node = validateStructureNode(value, issues, 'structure')
if (!node) {
issues.push('Structure racine invalide')
return { success: false, issues }
}
if (issues.length > 0) {
return { success: false, issues }
}
return {
success: true,
data: {
...node,
customFields,
pieces,
},
}
},
parse(value: unknown): ComponentModelStructure {
const result = this.safeParse(value)
if (!result.success) {
throw new Error(result.issues.join('\n'))
}
return result.data
},
}
export const createEmptyComponentModelStructure = (): ComponentModelStructure => ({
customFields: [],
pieces: [],
subcomponents: [],
})