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 {