feat(machines) : allow category-only links on machine structure
Enable adding a component, piece, or product to a machine by selecting only the category (ModelType) without a specific entity. The link displays a red "À remplir" badge; clicking it reopens the modal pre-filled with the category so the user can associate an item later. Backend: entity FKs made nullable on the 3 link tables, modelType FK added, controller/audit/version/MCP normalization adapted for null entities. Frontend: modal accepts category-only confirm, page handles fill mode, hierarchy builder creates pending nodes, display components show clickable badge with event propagation through the full hierarchy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@delete="$emit('delete')"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,5 +44,5 @@ defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
/>
|
||||
|
||||
<!-- Component Header -->
|
||||
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg cursor-pointer" :class="component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'" @click="toggleCollapse">
|
||||
<IconLucideChevronRight
|
||||
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
|
||||
:class="{ 'rotate-90': !isCollapsed }"
|
||||
@@ -22,9 +22,18 @@
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="text-sm font-semibold text-base-content truncate">
|
||||
<h3 class="text-sm font-semibold truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
|
||||
{{ component.name }}
|
||||
</h3>
|
||||
<button
|
||||
v-if="component.pendingEntity"
|
||||
type="button"
|
||||
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
|
||||
title="Cliquer pour associer un item"
|
||||
@click.stop="$emit('fill-entity', component.linkId, component.modelTypeId)"
|
||||
>
|
||||
À remplir
|
||||
</button>
|
||||
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
|
||||
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}€</span>
|
||||
</div>
|
||||
@@ -54,7 +63,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Expanded content -->
|
||||
<div v-show="!isCollapsed" class="mt-3 space-y-4 pl-7">
|
||||
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7">
|
||||
<!-- Info fields -->
|
||||
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
@@ -241,6 +250,7 @@
|
||||
@update="updatePiece"
|
||||
@edit="editPiece"
|
||||
@custom-field-update="updatePieceCustomField"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,6 +286,7 @@
|
||||
@update="$emit('update', $event)"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,7 +328,7 @@ const props = defineProps({
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
|
||||
|
||||
// --- Shared composables ---
|
||||
const {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
/>
|
||||
|
||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
||||
<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 justify-between p-4 rounded-lg" :class="piece._emptySlot || piece.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'">
|
||||
<div class="flex items-start gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -28,9 +28,18 @@
|
||||
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot }">
|
||||
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
|
||||
{{ pieceData.name }}
|
||||
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1">— manquant</span>
|
||||
<button
|
||||
v-if="piece.pendingEntity"
|
||||
type="button"
|
||||
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors ml-1"
|
||||
title="Cliquer pour associer un item"
|
||||
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
|
||||
>
|
||||
À remplir
|
||||
</button>
|
||||
<span
|
||||
v-if="displayQuantity > 1"
|
||||
class="text-sm font-normal text-base-content/60 ml-1"
|
||||
@@ -77,7 +86,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="!isCollapsed" class="space-y-4">
|
||||
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4">
|
||||
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="isEditMode" class="form-control">
|
||||
@@ -308,7 +317,7 @@ const props = defineProps({
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
|
||||
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete', 'fill-entity'])
|
||||
|
||||
// --- Local reactive data for editing ---
|
||||
const pieceData = reactive({
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTypeName && !selectedEntityId && !loadingEntities" class="bg-warning/10 border border-warning rounded-lg p-3 mb-4">
|
||||
<p class="text-sm text-warning font-medium">
|
||||
Aucun item sélectionné — la catégorie sera ajoutée avec le statut "À remplir".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary of selection -->
|
||||
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
|
||||
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
|
||||
@@ -64,10 +70,10 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedEntityId"
|
||||
:disabled="!selectedTypeId"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
Ajouter
|
||||
{{ selectedEntityId ? 'Ajouter' : 'Ajouter (catégorie seule)' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,11 +96,12 @@ type EntityKind = 'component' | 'piece' | 'product'
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
entityKind: EntityKind
|
||||
prefillTypeId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [entityId: string]
|
||||
confirm: [payload: { entityId?: string; modelTypeId: string; modelTypeName: string }]
|
||||
}>()
|
||||
|
||||
const selectedTypeId = ref('')
|
||||
@@ -166,6 +173,10 @@ watch(() => props.open, async (isOpen) => {
|
||||
if (props.entityKind === 'component') await loadComponentTypes()
|
||||
else if (props.entityKind === 'piece') await loadPieceTypes()
|
||||
else await loadProductTypes()
|
||||
|
||||
if (props.prefillTypeId) {
|
||||
selectedTypeId.value = props.prefillTypeId
|
||||
}
|
||||
})
|
||||
|
||||
// Load entities when type changes
|
||||
@@ -222,8 +233,12 @@ const handleClose = () => {
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedEntityId.value) return
|
||||
emit('confirm', selectedEntityId.value)
|
||||
if (!selectedTypeId.value) return
|
||||
emit('confirm', {
|
||||
entityId: selectedEntityId.value || undefined,
|
||||
modelTypeId: selectedTypeId.value,
|
||||
modelTypeName: selectedTypeName.value,
|
||||
})
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
:toggle-token="collapseToggleToken"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@delete="$emit('remove-component', component.linkId || component.id)"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,5 +69,6 @@ defineEmits<{
|
||||
'custom-field-update': [fieldUpdate: any]
|
||||
'add-component': []
|
||||
'remove-component': [linkId: string]
|
||||
'fill-entity': [linkId: string, modelTypeId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
@update="$emit('update-piece', $event)"
|
||||
@edit="$emit('edit-piece', $event)"
|
||||
@delete="$emit('remove-piece', piece.linkId || piece.id)"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,5 +68,6 @@ defineEmits<{
|
||||
'edit-piece': [piece: any]
|
||||
'add-piece': []
|
||||
'remove-piece': [linkId: string]
|
||||
'fill-entity': [linkId: string, modelTypeId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -23,14 +23,24 @@
|
||||
<div v-if="products.length" class="space-y-3">
|
||||
<div
|
||||
v-for="product in products"
|
||||
:key="product.id || product.name"
|
||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2"
|
||||
:key="product.id || product.linkId || product.name"
|
||||
class="rounded border p-3 text-sm space-y-2"
|
||||
:class="product.pendingEntity ? 'border-error bg-error/10' : 'border-base-200 bg-base-200/60'"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<p class="font-semibold text-base-content">
|
||||
<p class="font-semibold" :class="product.pendingEntity ? 'text-error' : 'text-base-content'">
|
||||
{{ product.name }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="product.pendingEntity"
|
||||
type="button"
|
||||
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
|
||||
title="Cliquer pour associer un item"
|
||||
@click="$emit('fill-entity', (product.linkId || product.id) as string, product.modelTypeId as string)"
|
||||
>
|
||||
À remplir
|
||||
</button>
|
||||
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
|
||||
{{ product.groupLabel }}
|
||||
</span>
|
||||
@@ -141,6 +151,9 @@ defineProps<{
|
||||
supplierLabel?: string | null
|
||||
priceLabel?: string | null
|
||||
groupLabel?: string
|
||||
pendingEntity?: boolean
|
||||
modelTypeId?: string | null
|
||||
modelType?: string | null
|
||||
documents?: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
@@ -156,6 +169,7 @@ defineProps<{
|
||||
defineEmits<{
|
||||
'add-product': []
|
||||
'remove-product': [linkId: string]
|
||||
'fill-entity': [linkId: string, modelTypeId: string]
|
||||
}>()
|
||||
|
||||
const previewDocument = ref<any>(null)
|
||||
|
||||
Reference in New Issue
Block a user