feat(ui) : highlight empty slots with category name in red

- Empty component slots (pieces, products, subcomponents) now display
  the category/type name with red styling instead of generic labels
- Machine view: empty structure pieces show type name + "manquant" in red
- Backend: include typePiece in structure slot data for name resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-04-03 09:21:25 +02:00
parent 12c2b1e1b3
commit d6441bef06
7 changed files with 40 additions and 23 deletions

View File

@@ -14,7 +14,7 @@
/> />
<!-- Piece Header (collapsible, same pattern as ComponentItem) --> <!-- Piece Header (collapsible, same pattern as ComponentItem) -->
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg"> <div class="flex items-start justify-between p-4 rounded-lg" :class="piece._emptySlot ? 'bg-error/10 border border-error' : 'bg-base-200'">
<div class="flex items-start gap-3 flex-1 min-w-0"> <div class="flex items-start gap-3 flex-1 min-w-0">
<button <button
type="button" type="button"
@@ -28,8 +28,9 @@
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span> <span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
</button> </button>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold"> <h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot }">
{{ pieceData.name }} {{ pieceData.name }}
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1"> manquant</span>
<span <span
v-if="displayQuantity > 1" v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1" class="text-sm font-normal text-base-content/60 ml-1"

View File

@@ -288,14 +288,16 @@ export function useComponentEdit(componentId: string) {
if (!structure?.pieces) return [] if (!structure?.pieces) return []
return (structure.pieces as any[]).map((slot: any, i: number) => { return (structure.pieces as any[]).map((slot: any, i: number) => {
const edits = slotEdits.pieces[slot.slotId] const edits = slotEdits.pieces[slot.slotId]
const selectedPieceId = edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null)
return { return {
slotId: slot.slotId, slotId: slot.slotId,
typePieceId: slot.typePieceId, typePieceId: slot.typePieceId,
selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null), selectedPieceId,
selectedPieceName: slot.selectedPieceName ?? null, selectedPieceName: slot.selectedPieceName ?? null,
quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1), quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1),
position: slot.position ?? i, position: slot.position ?? i,
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`, label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
isEmpty: !selectedPieceId,
} }
}) })
}) })
@@ -305,14 +307,16 @@ export function useComponentEdit(componentId: string) {
if (!structure?.products) return [] if (!structure?.products) return []
return (structure.products as any[]).map((slot: any, i: number) => { return (structure.products as any[]).map((slot: any, i: number) => {
const edits = slotEdits.products[slot.slotId] const edits = slotEdits.products[slot.slotId]
const selectedProductId = edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null)
return { return {
slotId: slot.slotId, slotId: slot.slotId,
typeProductId: slot.typeProductId, typeProductId: slot.typeProductId,
selectedProductId: edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null), selectedProductId,
selectedProductName: slot.selectedProductName ?? null, selectedProductName: slot.selectedProductName ?? null,
familyCode: slot.familyCode, familyCode: slot.familyCode,
position: slot.position ?? i, position: slot.position ?? i,
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`, label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
isEmpty: !selectedProductId,
} }
}) })
}) })
@@ -322,15 +326,17 @@ export function useComponentEdit(componentId: string) {
if (!structure?.subcomponents) return [] if (!structure?.subcomponents) return []
return (structure.subcomponents as any[]).map((slot: any, i: number) => { return (structure.subcomponents as any[]).map((slot: any, i: number) => {
const edits = slotEdits.subcomponents[slot.slotId] const edits = slotEdits.subcomponents[slot.slotId]
const selectedComponentId = edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null)
return { return {
slotId: slot.slotId, slotId: slot.slotId,
typeComposantId: slot.typeComposantId, typeComposantId: slot.typeComposantId,
selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null), selectedComponentId,
selectedComponentName: slot.selectedComponentName ?? null, selectedComponentName: slot.selectedComponentName ?? null,
alias: slot.alias, alias: slot.alias,
familyCode: slot.familyCode, familyCode: slot.familyCode,
position: slot.position ?? i, position: slot.position ?? i,
label: slot.alias || `Sous-composant #${i + 1}`, label: slot.alias || `Sous-composant #${i + 1}`,
isEmpty: !selectedComponentId,
} }
}) })
}) })

View File

@@ -227,11 +227,13 @@ export const buildMachineHierarchyFromLinks = (
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1) const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
const isEmpty = !resolved
const typePieceName = (resolved?.typePiece as AnyRecord)?.name || (definition.typePiece as AnyRecord)?.name || (def.typePiece as AnyRecord)?.name || null
return { return {
...(resolved || {}), ...(resolved || {}),
id: resolved?.id || `structure-piece-${composantId}-${index}`, id: resolved?.id || `structure-piece-${composantId}-${index}`,
pieceId: resolved?.id || null, pieceId: resolved?.id || null,
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`, name: resolved?.name || definition.role || definition.name || def.role || def.name || (typePieceName ? `${typePieceName}` : `Pièce ${index + 1}`),
reference: resolved?.reference || definition.reference || def.reference || null, reference: resolved?.reference || definition.reference || def.reference || null,
prix: resolved?.prix ?? null, prix: resolved?.prix ?? null,
constructeurs: resolved?.constructeurs || [], constructeurs: resolved?.constructeurs || [],
@@ -243,6 +245,7 @@ export const buildMachineHierarchyFromLinks = (
parentComponentLinkId: machineComponentLinkId, parentComponentLinkId: machineComponentLinkId,
parentComponentName: componentName, parentComponentName: componentName,
_structurePiece: true, _structurePiece: true,
_emptySlot: isEmpty,
} }
}) as AnyRecord[] }) as AnyRecord[]

View File

@@ -195,7 +195,7 @@
class="form-control" class="form-control"
> >
<label class="label"> <label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span> <span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label> </label>
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<div class="flex-1"> <div class="flex-1">
@@ -231,7 +231,7 @@
class="form-control" class="form-control"
> >
<label class="label"> <label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span> <span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label> </label>
<ProductSelect <ProductSelect
:model-value="slot.selectedProductId" :model-value="slot.selectedProductId"
@@ -252,7 +252,7 @@
class="form-control" class="form-control"
> >
<label class="label"> <label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span> <span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label> </label>
<ComposantSelect <ComposantSelect
:model-value="slot.selectedComponentId" :model-value="slot.selectedComponentId"

View File

@@ -230,7 +230,7 @@
class="form-control" class="form-control"
> >
<label class="label"> <label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span> <span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label> </label>
<template v-if="isEditMode"> <template v-if="isEditMode">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
@@ -255,9 +255,12 @@
</div> </div>
</div> </div>
</template> </template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2"> <div v-else class="input input-bordered input-sm md:input-md flex items-center gap-2" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
{{ slot.selectedPieceName || '— Non sélectionné' }} <template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span> <template v-else>
{{ slot.selectedPieceName }}
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -272,7 +275,7 @@
class="form-control" class="form-control"
> >
<label class="label"> <label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span> <span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label> </label>
<template v-if="isEditMode"> <template v-if="isEditMode">
<ProductSelect <ProductSelect
@@ -282,8 +285,9 @@
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)" @update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/> />
</template> </template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center"> <div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
{{ slot.selectedProductName || '— Non sélectionné' }} <template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedProductName }}</template>
</div> </div>
</div> </div>
</div> </div>
@@ -298,7 +302,7 @@
class="form-control" class="form-control"
> >
<label class="label"> <label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span> <span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label> </label>
<template v-if="isEditMode"> <template v-if="isEditMode">
<ComposantSelect <ComposantSelect
@@ -308,8 +312,9 @@
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)" @update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/> />
</template> </template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center"> <div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
{{ slot.selectedComponentName || '— Non sélectionné' }} <template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedComponentName }}</template>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -224,7 +224,7 @@
class="form-control" class="form-control"
> >
<label class="label"> <label class="label">
<span class="label-text text-xs font-medium"> <span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelections[entry.index] }">
{{ entry.label }} {{ entry.label }}
</span> </span>
</label> </label>
@@ -244,10 +244,11 @@
class="form-control" class="form-control"
> >
<label class="label"> <label class="label">
<span class="label-text text-xs font-medium">{{ entry.label }}</span> <span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelectionLabels[index] }">{{ entry.label }}</span>
</label> </label>
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center"> <div class="input input-bordered input-sm md:input-md flex items-center" :class="productSelectionLabels[index] ? 'bg-base-200' : 'border-error bg-error/10 text-error font-semibold'">
{{ productSelectionLabels[index] || '— Non sélectionné' }} <template v-if="!productSelectionLabels[index]">{{ entry.label }} — manquant</template>
<template v-else>{{ productSelectionLabels[index] }}</template>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -728,6 +728,7 @@ class MachineStructureController extends AbstractController
$pieceData = [ $pieceData = [
'slotId' => $slot->getId(), 'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(), 'typePieceId' => $slot->getTypePiece()?->getId(),
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
'quantity' => $slot->getQuantity(), 'quantity' => $slot->getQuantity(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(), 'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
]; ];