refactor : merge Inventory_frontend submodule into frontend/ directory

Merges the full git history of Inventory_frontend into the monorepo
under frontend/. Removes the submodule in favor of a unified repo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-04-01 14:17:57 +02:00
226 changed files with 56920 additions and 4 deletions

View File

@@ -0,0 +1,404 @@
/**
* Custom field form normalization, merge, and persistence utilities.
*
* Extracted from pages/component/create.vue, component/[id]/edit.vue,
* pieces/create.vue, pieces/[id]/edit.vue, product/[id]/edit.vue.
*
* Every create/edit page was shipping its own copy of these helpers
* this module unifies them behind a single, entity-agnostic API.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CustomFieldInput {
id: string | null
name: string
type: string
required: boolean
options: string[]
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
export interface SaveCustomFieldDeps {
customFieldInputs: { value: CustomFieldInput[] }
upsertCustomFieldValue: (
definitionId: string | null,
entityType: string,
entityId: string,
value: string,
metadata?: Record<string, unknown>,
) => Promise<{ success: boolean; data?: any }>
updateCustomFieldValue: (
id: string,
payload: { value: string },
) => Promise<{ success: boolean }>
toast: { showError: (msg: string) => void }
}
// ---------------------------------------------------------------------------
// Primitive helpers
// ---------------------------------------------------------------------------
export const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) return ''
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return ''
}
export const fieldKey = (field: CustomFieldInput, index: number): string =>
field.customFieldValueId || field.id || `${field.name}-${index}`
// ---------------------------------------------------------------------------
// Field resolution helpers
// ---------------------------------------------------------------------------
export const resolveFieldName = (field: any): string => {
if (typeof field?.name === 'string' && field.name.trim()) return field.name.trim()
if (typeof field?.key === 'string' && field.key.trim()) return field.key.trim()
if (typeof field?.label === 'string' && field.label.trim()) return field.label.trim()
return ''
}
export const resolveFieldType = (field: any): string => {
const allowed = ['text', 'number', 'select', 'boolean', 'date']
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'
}
export const resolveRequiredFlag = (field: any): boolean => {
if (typeof field?.required === 'boolean') return field.required
const nested = field?.value?.required
if (typeof nested === 'boolean') return nested
if (typeof nested === 'string') {
const normalized = nested.toLowerCase()
return normalized === 'true' || normalized === '1'
}
return false
}
export 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<string, unknown>
for (const key of ['value', 'label', 'name']) {
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((o) => o.length > 0)
if (mapped.length) return mapped
}
}
return []
}
export 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.defaultValue !== undefined && field.value.defaultValue !== null) return field.value.defaultValue
if (field.value.value !== undefined && field.value.value !== null && typeof field.value.value !== 'object') return field.value.value
}
return null
}
export 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<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
}
if ('value' in (defaultValue as Record<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
}
return ''
}
if (type === 'boolean') {
const normalized = String(defaultValue).toLowerCase()
if (normalized === 'true' || normalized === '1') return 'true'
if (normalized === 'false' || normalized === '0') return 'false'
return ''
}
return String(defaultValue)
}
// ---------------------------------------------------------------------------
// Normalize a single raw custom-field definition into CustomFieldInput
// ---------------------------------------------------------------------------
export const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') return null
const name = resolveFieldName(rawField)
if (!name) return null
const type = resolveFieldType(rawField)
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' ? rawField.customFieldValueId : null
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
}
// ---------------------------------------------------------------------------
// Normalize ALL custom-field definitions from a structure
// ---------------------------------------------------------------------------
export const normalizeCustomFieldInputs = (structure: any): CustomFieldInput[] => {
if (!structure || typeof structure !== 'object') return []
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field: any, index: number) => normalizeCustomField(field, index))
.filter((field: CustomFieldInput | null): field is CustomFieldInput => field !== null)
.sort((a: CustomFieldInput, b: CustomFieldInput) => a.orderIndex - b.orderIndex)
}
// ---------------------------------------------------------------------------
// Extract stored value from a persisted custom-field entry
// ---------------------------------------------------------------------------
export 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 ''
}
// ---------------------------------------------------------------------------
// Build inputs for edit pages (merge definitions + stored values)
// ---------------------------------------------------------------------------
export const buildCustomFieldInputs = (
structure: any,
values: any[] | null | undefined,
): CustomFieldInput[] => {
const definitions = normalizeCustomFieldInputs(structure)
const valueList = Array.isArray(values) ? values : []
const mapById = new Map<string, any>()
const mapByName = new Map<string, any>()
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') return
const fieldId = entry.customField?.id || entry.customFieldId || null
if (fieldId) mapById.set(fieldId, entry)
const fieldName = entry.customField?.name || entry.name || entry.key || null
if (fieldName) mapByName.set(fieldName, entry)
})
const matchedIds = new Set<string>()
const matchedNames = new Set<string>()
const result = definitions
.map((definition) => {
const definitionId = definition.customFieldId || definition.id || null
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
if (!matched) {
return {
...definition,
customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null,
orderIndex: definition.orderIndex,
}
}
const matchedFieldId = matched.customField?.id || matched.customFieldId || null
if (matchedFieldId) matchedIds.add(matchedFieldId)
const matchedFieldName = matched.customField?.name || matched.name || null
if (matchedFieldName) matchedNames.add(matchedFieldName)
const resolvedValue = extractStoredCustomFieldValue(matched)
return {
...definition,
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
customFieldValueId: matched.id ?? null,
value: formatDefaultValue(definition.type, resolvedValue),
orderIndex: Math.min(
definition.orderIndex ?? 0,
typeof matched.customField?.orderIndex === 'number'
? matched.customField.orderIndex
: definition.orderIndex ?? 0,
),
}
})
// Include values with embedded definitions that didn't match any structure definition
valueList.forEach((entry, index) => {
if (!entry || typeof entry !== 'object') return
const cf = entry.customField
if (!cf || typeof cf !== 'object') return
const fieldId = cf.id || entry.customFieldId || null
const fieldName = cf.name || entry.name || null
if (fieldId && matchedIds.has(fieldId)) return
if (fieldName && matchedNames.has(fieldName)) return
const name = resolveFieldName(cf)
if (!name) return
const type = resolveFieldType(cf)
const resolvedValue = extractStoredCustomFieldValue(entry)
result.push({
id: fieldId,
name,
type,
required: resolveRequiredFlag(cf),
options: resolveOptions(cf),
value: formatDefaultValue(type, resolvedValue),
customFieldId: fieldId,
customFieldValueId: entry.id ?? null,
orderIndex: typeof cf.orderIndex === 'number' ? cf.orderIndex : definitions.length + index,
})
})
return result.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
}
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
export const buildCustomFieldMetadata = (field: CustomFieldInput): Record<string, unknown> => ({
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
})
export const shouldPersistField = (field: CustomFieldInput): boolean => {
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
return toFieldString(field.value).trim() !== ''
}
export const formatValueForPersistence = (field: CustomFieldInput): string => {
if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
return toFieldString(field.value).trim()
}
export const requiredCustomFieldsFilled = (inputs: CustomFieldInput[]): boolean =>
inputs.every((field) => {
if (!field.required) return true
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
return toFieldString(field.value).trim() !== ''
})
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
/**
* Save custom-field values for an entity.
*
* @param entityType - API entity slug ('composant' | 'piece' | 'product')
* @param entityId - ID of the created/updated entity
* @param definitionSources - arrays of raw definition objects to build a name→id map
* @param deps - injected composable references
* @returns list of field names that failed to save (empty = all OK)
*/
export const saveCustomFieldValues = async (
entityType: string,
entityId: string,
definitionSources: any[][],
deps: SaveCustomFieldDeps,
): Promise<string[]> => {
if (!entityId) return []
const definitionMap = new Map<string, string>()
const registerDefinitions = (fields: any[]) => {
if (!Array.isArray(fields)) return
fields.forEach((field) => {
if (!field || typeof field !== 'object') return
const name = typeof field.name === 'string' ? field.name : null
const id = typeof field.id === 'string' ? field.id : null
if (name && id && !definitionMap.has(name)) definitionMap.set(name, id)
})
}
definitionSources.forEach(registerDefinitions)
const resolveDefinitionId = (field: CustomFieldInput) => {
if (field.customFieldId) return field.customFieldId
if (field.id) return field.id
return definitionMap.get(field.name) ?? null
}
const failed: string[] = []
for (const field of deps.customFieldInputs.value) {
if (!shouldPersistField(field)) continue
const definitionId = resolveDefinitionId(field)
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
const value = formatValueForPersistence(field)
if (field.customFieldValueId) {
const result = await deps.updateCustomFieldValue(field.customFieldValueId, { value })
if (!result.success) {
deps.toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
failed.push(field.name)
} else if (definitionId && !field.customFieldId) {
field.customFieldId = definitionId
}
continue
}
const result = await deps.upsertCustomFieldValue(
definitionId,
entityType,
entityId,
value,
metadata,
)
if (!result.success) {
deps.toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
failed.push(field.name)
} else {
const createdValue = result.data
if (createdValue?.id) field.customFieldValueId = createdValue.id
const resolvedId = createdValue?.customField?.id || definitionId
if (resolvedId) field.customFieldId = resolvedId
}
}
return failed
}