feat: improve machine component hierarchy handling
This commit is contained in:
312
app/components/ComponentStructureAssignmentNode.vue
Normal file
312
app/components/ComponentStructureAssignmentNode.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<section v-if="!isRoot" class="rounded-lg border border-base-200 bg-base-100 p-4 space-y-3">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-base-content">
|
||||
{{ requirementLabel }}
|
||||
</h4>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ requirementDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs">Sélectionner un composant</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="assignment.selectedComponentId"
|
||||
class="select select-bordered select-sm"
|
||||
>
|
||||
<option value="">
|
||||
{{ componentOptions.length ? 'Choisir un composant compatible' : 'Aucun composant disponible' }}
|
||||
</option>
|
||||
<option
|
||||
v-for="component in componentOptions"
|
||||
:key="component.id"
|
||||
:value="component.id"
|
||||
>
|
||||
{{ formatComponentOption(component) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="assignment.pieces.length" class="rounded-lg border border-dashed border-base-300 bg-base-200/40 p-4 space-y-4">
|
||||
<header class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-base-content">
|
||||
{{ isRoot ? 'Pièces requises par le squelette' : 'Pièces associées à ce sous-composant' }}
|
||||
</h4>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Sélectionnez les pièces concrètes à associer pour chaque emplacement.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-for="pieceAssignment in assignment.pieces"
|
||||
:key="pieceAssignment.path"
|
||||
class="rounded-md border border-base-200 bg-base-100 p-3 space-y-2"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium text-base-content">
|
||||
{{ describePieceRequirement(pieceAssignment.definition) }}
|
||||
</p>
|
||||
<p v-if="!getPieceOptions(pieceAssignment.definition).length" class="text-[11px] text-error">
|
||||
Aucune pièce disponible pour cette famille.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<select
|
||||
v-model="pieceAssignment.selectedPieceId"
|
||||
class="select select-bordered select-xs"
|
||||
>
|
||||
<option value="">
|
||||
{{ getPieceOptions(pieceAssignment.definition).length ? 'Choisir une pièce' : 'Sélection impossible' }}
|
||||
</option>
|
||||
<option
|
||||
v-for="piece in getPieceOptions(pieceAssignment.definition)"
|
||||
:key="piece.id"
|
||||
:value="piece.id"
|
||||
>
|
||||
{{ formatPieceOption(piece) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="assignment.subcomponents.length" class="space-y-4">
|
||||
<header class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-base-content">
|
||||
{{ isRoot ? 'Sous-composants définis par le squelette' : 'Sous-composants imbriqués' }}
|
||||
</h4>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Choisissez un composant existant pour chaque sous-niveau requis.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ComponentStructureAssignmentNode
|
||||
v-for="subAssignment in assignment.subcomponents"
|
||||
:key="subAssignment.path"
|
||||
:assignment="subAssignment"
|
||||
:pieces="pieces"
|
||||
:components="components"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import type {
|
||||
ComponentModelPiece,
|
||||
ComponentModelStructureNode,
|
||||
} from '~/shared/types/inventory';
|
||||
|
||||
interface ComponentOption {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
reference?: string | null;
|
||||
typeComposantId?: string | null;
|
||||
typeComposant?: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
code?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface PieceOption {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
reference?: string | null;
|
||||
typePieceId?: string | null;
|
||||
typePiece?: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
code?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface StructurePieceAssignment {
|
||||
path: string;
|
||||
definition: ComponentModelPiece;
|
||||
selectedPieceId: string;
|
||||
}
|
||||
|
||||
export interface StructureAssignmentNode {
|
||||
path: string;
|
||||
definition: ComponentModelStructureNode;
|
||||
selectedComponentId: string;
|
||||
pieces: StructurePieceAssignment[];
|
||||
subcomponents: StructureAssignmentNode[];
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
assignment: StructureAssignmentNode;
|
||||
pieces: PieceOption[] | null;
|
||||
components: ComponentOption[] | null;
|
||||
depth?: number;
|
||||
}>(),
|
||||
{
|
||||
depth: 0,
|
||||
pieces: () => [],
|
||||
components: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const depth = computed(() => props.depth ?? 0);
|
||||
const isRoot = computed(() => depth.value === 0);
|
||||
|
||||
const wrapperClass = computed(() =>
|
||||
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
|
||||
);
|
||||
|
||||
const componentOptions = computed(() => {
|
||||
if (isRoot.value) {
|
||||
return [];
|
||||
}
|
||||
const definition = props.assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeComposantId || definition.modelId || null;
|
||||
const requiredFamilyCode = definition.familyCode || null;
|
||||
|
||||
return (props.components || []).filter((component) => {
|
||||
if (!component || typeof component !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
return component.typeComposantId === requiredTypeId;
|
||||
}
|
||||
if (requiredFamilyCode) {
|
||||
return (
|
||||
component.typeComposant?.code === requiredFamilyCode ||
|
||||
component.typeComposantId === requiredFamilyCode
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
componentOptions,
|
||||
(options) => {
|
||||
if (isRoot.value) {
|
||||
return;
|
||||
}
|
||||
const hasMatch = options.some(
|
||||
(component) => component.id === props.assignment.selectedComponentId,
|
||||
);
|
||||
if (!hasMatch) {
|
||||
props.assignment.selectedComponentId = '';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const formatComponentOption = (component: ComponentOption) => {
|
||||
const name = component.name || 'Composant sans nom';
|
||||
const reference = component.reference ? ` • Ref. ${component.reference}` : '';
|
||||
const typeLabel =
|
||||
component.typeComposant?.name || component.typeComposant?.code || '';
|
||||
const typed =
|
||||
typeLabel && component.typeComposant?.name !== name
|
||||
? ` (${typeLabel})`
|
||||
: '';
|
||||
return `${name}${typed}${reference}`;
|
||||
};
|
||||
|
||||
const describePieceRequirement = (definition: ComponentModelPiece) => {
|
||||
const parts: string[] = [];
|
||||
if (definition.role) {
|
||||
parts.push(definition.role);
|
||||
}
|
||||
if (definition.typePieceLabel) {
|
||||
parts.push(definition.typePieceLabel);
|
||||
} else if ((definition as any).typePiece?.name) {
|
||||
parts.push((definition as any).typePiece.name);
|
||||
} else if (definition.familyCode) {
|
||||
parts.push(definition.familyCode);
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
|
||||
};
|
||||
|
||||
const requirementLabel = computed(() => {
|
||||
const definition = props.assignment.definition || {};
|
||||
const alias = definition.alias || definition.typeComposantLabel;
|
||||
if (alias) {
|
||||
return alias;
|
||||
}
|
||||
if (definition.typeComposant?.name) {
|
||||
return definition.typeComposant.name;
|
||||
}
|
||||
if (definition.familyCode) {
|
||||
return `Famille ${definition.familyCode}`;
|
||||
}
|
||||
return 'Sous-composant';
|
||||
});
|
||||
|
||||
const requirementDescription = computed(() => {
|
||||
const definition = props.assignment.definition || {};
|
||||
const family =
|
||||
definition.typeComposantLabel ||
|
||||
definition.typeComposant?.name ||
|
||||
definition.familyCode;
|
||||
if (family) {
|
||||
return `Doit appartenir à la famille "${family}".`;
|
||||
}
|
||||
return 'Sélectionnez un composant enfant conforme à cette position.';
|
||||
});
|
||||
|
||||
const getPieceOptions = (definition: ComponentModelPiece) => {
|
||||
const requiredTypeId =
|
||||
definition.typePieceId ||
|
||||
(definition as any).typePiece?.id ||
|
||||
definition.familyCode ||
|
||||
null;
|
||||
|
||||
return (props.pieces || []).filter((piece) => {
|
||||
if (!piece || typeof piece !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (!requiredTypeId) {
|
||||
return true;
|
||||
}
|
||||
if (definition.typePieceId || (definition as any).typePiece?.id) {
|
||||
return (
|
||||
piece.typePieceId === requiredTypeId ||
|
||||
piece.typePiece?.id === requiredTypeId
|
||||
);
|
||||
}
|
||||
if (definition.familyCode) {
|
||||
return (
|
||||
piece.typePiece?.code === requiredTypeId ||
|
||||
piece.typePieceId === requiredTypeId
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const formatPieceOption = (piece: PieceOption) => {
|
||||
const name = piece.name || 'Pièce';
|
||||
const reference = piece.reference ? ` • Ref. ${piece.reference}` : '';
|
||||
return `${name}${reference}`;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.pieces, props.assignment.pieces],
|
||||
() => {
|
||||
for (const pieceAssignment of props.assignment.pieces) {
|
||||
const options = getPieceOptions(pieceAssignment.definition);
|
||||
if (
|
||||
pieceAssignment.selectedPieceId &&
|
||||
!options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
|
||||
) {
|
||||
pieceAssignment.selectedPieceId = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
</script>
|
||||
Reference in New Issue
Block a user