fix(custom-fields) : context fields display, batch save, hierarchy propagation and UniqueEntity fix

- ComponentItem/PieceItem: DaisyUI divider, emit context-field-update for batch save
- CustomFieldDisplay: support editable/emit-blur/title/show-header props
- MachineComponentsCard/MachinePiecesCard: propagate custom-field-update events
- useMachineDetailCustomFields: pendingContextFieldUpdates + saveAllContextCustomFields
- useMachineDetailData: wire context field save into submitEdition
- useMachineDetailUpdates: only PATCH changed machine fields
- useMachineHierarchy: propagate contextCustomFields/Values from link to nodes
- componentStructure: include machineContextOnly in normalizeStructureForEditor
- Machine entity: convert empty reference to null, ignoreNull on UniqueEntity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 13:46:39 +02:00
parent c54e5c33f2
commit 0049638e3c
14 changed files with 217 additions and 91 deletions

View File

@@ -200,21 +200,26 @@
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:columns="2"
title="Champs personnalisés item"
:editable="false"
@field-blur="updateComponentCustomField"
/>
<!-- Context custom fields (machine-specific) -->
<div v-if="mergedContextFields.length" class="mt-4">
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
Champs contextuels
</h4>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
@field-blur="updateContextCustomField"
/>
<template v-if="mergedContextFields.length">
<div class="divider my-4 text-xs text-base-content/50">
Champs personnalisés machine
</div>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<!-- Documents -->
<div class="space-y-2">
@@ -323,7 +328,6 @@ import {
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils'
import {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentIcon,
@@ -332,9 +336,12 @@ import {
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import {
mergeFieldDefinitionsWithValues,
dedupeMergedFields,
resolveCustomFieldId,
resolveFieldId,
} from '~/shared/utils/entityCustomFieldLogic'
const props = defineProps({
component: { type: Object, required: true },
@@ -374,9 +381,6 @@ const {
updateCustomField: updateComponentCustomField,
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
const { upsertCustomFieldValue } = useCustomFields()
const { showSuccess, showError } = useToast()
const mergedContextFields = computed(() => {
const definitions = props.component?.contextCustomFields ?? []
const values = props.component?.contextCustomFieldValues ?? []
@@ -386,26 +390,23 @@ const mergedContextFields = computed(() => {
)
})
const updateContextCustomField = async (field) => {
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.component?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId || field.customField?.id
if (!customFieldId) return
const customFieldId = resolveCustomFieldId(field)
const customFieldValueId = resolveFieldId(field)
if (!customFieldId && !customFieldValueId) return
const result = await upsertCustomFieldValue(
customFieldId,
'machineComponentLink',
linkId,
field.value ?? '',
)
if (result.success) {
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
}
else {
showError(`Erreur lors de la mise à jour du champ contextuel`)
}
field.value = value
emit('custom-field-update', {
entityType: 'machineComponentLink',
entityId: linkId,
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || field.customField?.name || 'Champ contextuel',
})
}
// --- Document edit modal ---

View File

@@ -241,22 +241,27 @@
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
title="Champs personnalisés item"
:editable="false"
@field-input="handleCustomFieldInput"
@field-blur="handleCustomFieldBlur"
/>
<!-- Context custom fields (machine-specific) -->
<div v-if="mergedContextFields.length" class="mt-4">
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
Champs contextuels
</h4>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
@field-blur="updateContextCustomField"
/>
</div>
<template v-if="mergedContextFields.length">
<div class="divider my-4 text-xs text-base-content/50">
Champs personnalisés machine
</div>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
<div class="flex items-center justify-between">
@@ -316,13 +321,14 @@ import {
} from '~/shared/constructeurUtils'
import {
resolveFieldId,
resolveFieldReadOnly, mergeFieldDefinitionsWithValues, dedupeMergedFields
resolveFieldReadOnly,
resolveCustomFieldId,
mergeFieldDefinitionsWithValues,
dedupeMergedFields,
} from '~/shared/utils/entityCustomFieldLogic'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
const props = defineProps({
piece: { type: Object, required: true },
@@ -390,9 +396,6 @@ const {
updateCustomField,
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
const { upsertCustomFieldValue } = useCustomFields()
const { showSuccess, showError } = useToast()
const mergedContextFields = computed(() => {
const definitions = props.piece?.contextCustomFields ?? []
const values = props.piece?.contextCustomFieldValues ?? []
@@ -402,26 +405,23 @@ const mergedContextFields = computed(() => {
)
})
const updateContextCustomField = async (field) => {
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.piece?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId || field.customField?.id
if (!customFieldId) return
const customFieldId = resolveCustomFieldId(field)
const customFieldValueId = resolveFieldId(field)
if (!customFieldId && !customFieldValueId) return
const result = await upsertCustomFieldValue(
customFieldId,
'machinePieceLink',
linkId,
field.value ?? '',
)
if (result.success) {
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
}
else {
showError(`Erreur lors de la mise à jour du champ contextuel`)
}
field.value = value
emit('custom-field-update', {
entityType: 'machinePieceLink',
entityId: linkId,
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || field.customField?.name || 'Champ contextuel',
})
}
// --- Document edit modal ---

View File

@@ -1,10 +1,10 @@
<template>
<div
v-if="fields.length"
class="mt-4 pt-4 border-t border-base-200"
:class="containerClass"
>
<h5 class="text-sm font-medium text-base-content/80 mb-3">
Champs personnalisés
<h5 v-if="showHeader" class="text-sm font-medium text-base-content/80 mb-3">
{{ title }}
</h5>
<div :class="layoutClass">
<div
@@ -23,7 +23,7 @@
</label>
<!-- Mode édition -->
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
<template v-if="isFieldEditable(field)">
<!-- Champ de type TEXT -->
<input
v-if="resolveFieldType(field) === 'text'"
@@ -142,6 +142,11 @@ const props = defineProps<{
fields: any[]
isEditMode: boolean
columns?: 1 | 2
title?: string
showHeader?: boolean
withTopBorder?: boolean
editable?: boolean
emitBlur?: boolean
}>()
const emit = defineEmits<{
@@ -155,6 +160,20 @@ const layoutClass = computed(() =>
: 'space-y-3',
)
const title = computed(() => props.title ?? 'Champs personnalisés')
const showHeader = computed(() => props.showHeader ?? true)
const containerClass = computed(() =>
props.withTopBorder === false
? ''
: 'mt-4 pt-4 border-t border-base-200',
)
const editable = computed(() => props.editable ?? true)
const emitBlur = computed(() => props.emitBlur ?? true)
function isFieldEditable(field: any) {
return props.isEditMode && editable.value && !resolveFieldReadOnly(field)
}
function onInput(field: any, value: string) {
field.value = value
emit('field-input', field, value)
@@ -164,10 +183,14 @@ function onBooleanChange(field: any, checked: boolean) {
const value = checked ? 'true' : 'false'
field.value = value
emit('field-input', field, value)
emit('field-blur', field)
if (emitBlur.value) {
emit('field-blur', field)
}
}
function onBlur(field: any) {
emit('field-blur', field)
if (emitBlur.value) {
emit('field-blur', field)
}
}
</script>

View File

@@ -28,11 +28,13 @@
<div v-for="component in components" :key="component.id">
<ComponentHierarchy
:components="[component]"
:is-edit-mode="false"
:is-edit-mode="isEditMode"
:show-delete="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@update="$emit('update-component', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('remove-component', component.linkId || component.id)"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>

View File

@@ -34,6 +34,7 @@
:toggle-token="collapseToggleToken"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('remove-piece', piece.linkId || piece.id)"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
@@ -66,6 +67,7 @@ defineEmits<{
'toggle-collapse': []
'update-piece': [piece: any]
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
'add-piece': []
'remove-piece': [linkId: string]
'fill-entity': [linkId: string, modelTypeId: string]

View File

@@ -44,6 +44,7 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
// ---------------------------------------------------------------------------
const machineCustomFields = ref<AnyRecord[]>([])
const pendingContextFieldUpdates = ref<AnyRecord[]>([])
// ---------------------------------------------------------------------------
// Computed
@@ -380,6 +381,83 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
}
}
const handleCustomFieldUpdate = async (fieldUpdate: AnyRecord) => {
if (fieldUpdate?.entityType && fieldUpdate?.entityId) {
queueContextFieldUpdate(fieldUpdate)
return
}
await updatePieceCustomField(fieldUpdate)
}
const queueContextFieldUpdate = (fieldUpdate: AnyRecord) => {
const entityType = fieldUpdate.entityType as string | undefined
const entityId = fieldUpdate.entityId as string | undefined
const fieldId = fieldUpdate.fieldId as string | undefined
const customFieldValueId = fieldUpdate.customFieldValueId as string | undefined
if (!entityType || !entityId || (!fieldId && !customFieldValueId)) return
const nextUpdate = {
entityType,
entityId,
fieldId,
customFieldValueId,
value: fieldUpdate.value ?? '',
fieldName: fieldUpdate.fieldName ?? 'Champ contextuel',
}
const existingIndex = pendingContextFieldUpdates.value.findIndex(
(item) =>
item.entityType === entityType &&
item.entityId === entityId &&
item.fieldId === fieldId &&
item.customFieldValueId === customFieldValueId,
)
if (existingIndex >= 0) {
pendingContextFieldUpdates.value[existingIndex] = nextUpdate
return
}
pendingContextFieldUpdates.value.push(nextUpdate)
}
const clearPendingContextFieldUpdates = () => {
pendingContextFieldUpdates.value = []
}
const saveAllContextCustomFields = async () => {
const updates = pendingContextFieldUpdates.value.slice()
if (!updates.length) return
try {
for (const update of updates) {
if (update.customFieldValueId) {
await updateCustomFieldValueApi(update.customFieldValueId as string, {
value: update.value ?? '',
} as any)
continue
}
if (!update.fieldId) {
continue
}
await upsertCustomFieldValue(
update.fieldId as string,
update.entityType as string,
update.entityId as string,
update.value ?? '',
)
}
clearPendingContextFieldUpdates()
} catch (error) {
console.error('Erreur lors de la sauvegarde batch des champs contextuels:', error)
throw error
}
}
const saveAllMachineCustomFields = async () => {
if (!machine.value) return
@@ -435,6 +513,7 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
return {
// State
machineCustomFields,
pendingContextFieldUpdates,
// Computed
visibleMachineCustomFields,
@@ -448,6 +527,10 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
handleCustomFieldUpdate,
queueContextFieldUpdate,
clearPendingContextFieldUpdates,
saveAllMachineCustomFields,
saveAllContextCustomFields,
}
}

View File

@@ -151,13 +151,18 @@ export function useMachineDetailData(machineId: string) {
const {
machineCustomFields,
visibleMachineCustomFields,
pendingContextFieldUpdates,
transformCustomFields,
transformComponentCustomFields,
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
handleCustomFieldUpdate,
queueContextFieldUpdate,
clearPendingContextFieldUpdates,
saveAllMachineCustomFields,
saveAllContextCustomFields,
} = useMachineDetailCustomFields({
machine,
isEditMode,
@@ -333,10 +338,13 @@ export function useMachineDetailData(machineId: string) {
// 2. Save all custom field values
await saveAllMachineCustomFields()
// 3. Reload machine data to get fresh state
// 3. Save contextual custom field values queued from piece/component inputs
await saveAllContextCustomFields()
// 4. Reload machine data to get fresh state
await loadMachineData()
// 4. Exit edit mode
// 5. Exit edit mode
isEditMode.value = false
toast.showSuccess('Machine mise à jour avec succès')
} catch (error) {
@@ -350,6 +358,7 @@ export function useMachineDetailData(machineId: string) {
const cancelEdition = () => {
initMachineFields()
syncMachineCustomFields()
clearPendingContextFieldUpdates()
constructeurLinks.value = originalConstructeurLinks.value.map(l => ({ ...l }))
machineConstructeurIds.value = constructeurIdsFromLinks(constructeurLinks.value)
isEditMode.value = false
@@ -486,7 +495,7 @@ export function useMachineDetailData(machineId: string) {
// UI state
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
machineCustomFields, previewDocument, previewVisible,
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
isEditMode, debug,
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
@@ -499,7 +508,8 @@ export function useMachineDetailData(machineId: string) {
findProductById, resolveProductReference, getProductDisplay,
initMachineFields, getMachineFieldId,
syncMachineCustomFields, setMachineCustomFieldValue,
updateMachineCustomField, updatePieceCustomField,
updateMachineCustomField, updatePieceCustomField, handleCustomFieldUpdate,
queueContextFieldUpdate, clearPendingContextFieldUpdates, saveAllContextCustomFields,
refreshMachineDocuments, handleMachineFilesAdded, removeMachineDocument,
openPreview, closePreview,
updateMachineInfo, updateComponent, updatePieceFromComponent,

View File

@@ -73,11 +73,11 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const updateMachineInfo = async () => {
if (!machine.value) return
try {
const result: any = await updateMachineApi(machine.value.id as string, {
name: machineName.value,
reference: machineReference.value,
siteId: machineSiteId.value || undefined,
} as any)
const payload: Record<string, unknown> = {}
if (machineName.value !== machine.value.name) payload.name = machineName.value
if (machineReference.value !== machine.value.reference) payload.reference = machineReference.value
if ((machineSiteId.value || undefined) !== ((machine.value.siteId as string) || (machine.value.site as any)?.id || undefined)) payload.siteId = machineSiteId.value || undefined
const result: any = await updateMachineApi(machine.value.id as string, payload as any)
if (result.success) {
const machinePayload =
result.data?.machine && typeof result.data.machine === 'object'

View File

@@ -208,6 +208,8 @@ export const buildMachineHierarchyFromLinks = (
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
definition: appliedPiece.definition || originalPiece?.definition || {},
customFields: appliedPiece.customFields || [],
contextCustomFields: link.contextCustomFields || [],
contextCustomFieldValues: link.contextCustomFieldValues || [],
}
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
@@ -335,6 +337,8 @@ export const buildMachineHierarchyFromLinks = (
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
definition: appliedComponent.definition || originalComponent?.definition || {},
customFields: appliedComponent.customFields || [],
contextCustomFields: link.contextCustomFields || [],
contextCustomFieldValues: link.contextCustomFieldValues || [],
pieces,
subComponents,
subcomponents: subComponents,

View File

@@ -113,7 +113,7 @@
@toggle-collapse="d.toggleAllComponents"
@update-component="d.updateComponent"
@edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.updatePieceCustomField"
@custom-field-update="d.handleCustomFieldUpdate"
@add-component="openAddModal('component')"
@remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'component', typeId)"
@@ -128,7 +128,7 @@
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
@update-piece="d.updatePieceInfo"
@edit-piece="d.editPiece"
@custom-field-update="d.updatePieceCustomField"
@custom-field-update="d.handleCustomFieldUpdate"
@add-piece="openAddModal('piece')"
@remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'piece', typeId)"

View File

@@ -98,6 +98,7 @@ export const normalizeStructureForEditor = (input: any): ComponentModelStructure
name: field.name,
type: field.type,
required: field.required,
machineContextOnly: !!field.machineContextOnly,
options,
defaultValue,
optionsText,

View File

@@ -53,6 +53,6 @@ final class MaintenanceController extends AbstractController
private function flagPath(): string
{
return $this->kernel->getProjectDir() . '/var/maintenance';
return $this->kernel->getProjectDir().'/var/maintenance';
}
}

View File

@@ -25,7 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: MachineRepository::class)]
#[ORM\Table(name: 'machines')]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.')]
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
#[ApiResource(
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
operations: [
@@ -150,7 +150,7 @@ class Machine
public function setReference(?string $reference): static
{
$this->reference = $reference;
$this->reference = (null !== $reference && '' !== trim($reference)) ? $reference : null;
return $this;
}

View File

@@ -24,14 +24,14 @@ final class MaintenanceModeListener
return;
}
$flagFile = $this->kernel->getProjectDir() . '/var/maintenance';
$flagFile = $this->kernel->getProjectDir().'/var/maintenance';
if (!file_exists($flagFile)) {
return;
}
$request = $event->getRequest();
$path = $request->getPathInfo();
$path = $request->getPathInfo();
// Always allow maintenance status endpoint and session endpoints
if (str_starts_with($path, '/api/admin/maintenance')