refactor(machine): decompose create page into composable + 5 components (F1.2)
Extract useMachineCreatePage composable and 5 preview/selector components from machines/new.vue, reducing it from 1231 to 196 LOC. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
205
app/components/machine/create/MachineCreatePreview.vue
Normal file
205
app/components/machine/create/MachineCreatePreview.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="preview" class="space-y-4">
|
||||||
|
<div class="border border-base-200 rounded-lg bg-base-100/80">
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||||
|
<IconLucideEye class="w-4 h-4" aria-hidden="true" />
|
||||||
|
<span>Prévisualisation avant création</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge" :class="getStatusBadgeClass(preview.status)">
|
||||||
|
{{ preview.status === 'ready' ? 'Prête à créer' : preview.status === 'warning' ? 'À compléter' : 'Bloquante' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div
|
||||||
|
v-for="field in preview.base.fields"
|
||||||
|
:key="field.key"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<span class="text-[11px] uppercase tracking-wide text-gray-500">{{ field.label }}</span>
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="field.status === 'missing'
|
||||||
|
? 'text-error'
|
||||||
|
: field.status === 'optional'
|
||||||
|
? 'text-gray-500 italic'
|
||||||
|
: 'text-gray-900'"
|
||||||
|
>
|
||||||
|
{{ field.display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs text-gray-500">
|
||||||
|
<span class="badge badge-ghost badge-sm">Type : {{ preview.type.name }}</span>
|
||||||
|
<span v-if="preview.type.category" class="badge badge-ghost badge-sm">Catégorie : {{ preview.type.category }}</span>
|
||||||
|
<span class="badge badge-ghost badge-sm">Structure JSON : {{ preview.type.hasStructuredDefinition ? 'Oui' : 'Legacy' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Base issues -->
|
||||||
|
<div v-if="preview.base.issues.length" class="rounded-md bg-warning/10 border border-warning/30 p-3 text-xs text-warning">
|
||||||
|
<p class="font-medium mb-1">
|
||||||
|
Informations générales incomplètes :
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li v-for="issue in preview.base.issues" :key="issue.message">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-start gap-2 text-left hover:underline"
|
||||||
|
@click="handleIssueClick(issue)"
|
||||||
|
>
|
||||||
|
<span class="mt-0.5 text-[8px] leading-none">•</span>
|
||||||
|
<span>{{ issue.message }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Component groups -->
|
||||||
|
<div v-if="preview.componentGroups.length" class="space-y-3">
|
||||||
|
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Composants hérités
|
||||||
|
</h5>
|
||||||
|
<PreviewRequirementGroup
|
||||||
|
v-for="group in preview.componentGroups"
|
||||||
|
:key="group.id"
|
||||||
|
:group="group"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-xs text-gray-500">
|
||||||
|
Aucun composant n'est requis pour ce type de machine.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Piece groups -->
|
||||||
|
<div v-if="preview.pieceGroups.length" class="space-y-3">
|
||||||
|
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Pièces associées
|
||||||
|
</h5>
|
||||||
|
<PreviewRequirementGroup
|
||||||
|
v-for="group in preview.pieceGroups"
|
||||||
|
:key="group.id"
|
||||||
|
:group="group"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-xs text-gray-500">
|
||||||
|
Aucun groupe de pièces à configurer pour ce type.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product groups -->
|
||||||
|
<div v-if="preview.productGroups.length" class="space-y-3">
|
||||||
|
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Produits requis
|
||||||
|
</h5>
|
||||||
|
<div
|
||||||
|
v-for="group in preview.productGroups"
|
||||||
|
:key="group.id"
|
||||||
|
:id="`product-group-${group.id}`"
|
||||||
|
class="border border-base-200 rounded-md p-3 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold">
|
||||||
|
{{ group.label }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Catégorie : {{ group.typeName }} · Min {{ group.min }} ·
|
||||||
|
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
|
||||||
|
Couverture : {{ group.count }}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-ghost badge-sm">
|
||||||
|
Direct {{ group.completed }} / {{ group.total || 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
|
||||||
|
<ul class="list-disc pl-4 space-y-1">
|
||||||
|
<li v-for="issue in group.issues" :key="issue.message">
|
||||||
|
{{ issue.message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="group.entries?.length" class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="entry in group.entries"
|
||||||
|
:key="entry.key"
|
||||||
|
class="flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
|
||||||
|
class="w-4 h-4 mt-0.5"
|
||||||
|
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
|
||||||
|
{{ entry.title }}
|
||||||
|
</p>
|
||||||
|
<p v-if="entry.subtitle" class="text-xs text-gray-500">
|
||||||
|
{{ entry.subtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p v-else class="text-xs text-gray-500">
|
||||||
|
Couverture assurée via composants ou pièces liés.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global issues -->
|
||||||
|
<div
|
||||||
|
v-if="preview.issues.length && preview.status !== 'ready'"
|
||||||
|
class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<IconLucideAlertTriangle class="w-4 h-4 mt-0.5" aria-hidden="true" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="font-medium">
|
||||||
|
Points à vérifier avant la création :
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li v-for="issue in preview.issues" :key="`${issue.scope}-${issue.message}`">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-start gap-2 text-left hover:underline"
|
||||||
|
@click="handleIssueClick(issue)"
|
||||||
|
>
|
||||||
|
<span class="mt-0.5 text-[8px] leading-none">•</span>
|
||||||
|
<span>
|
||||||
|
<span class="font-medium">{{ issue.scope }} :</span> {{ issue.message }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
getStatusBadgeClass,
|
||||||
|
handleIssueClick,
|
||||||
|
} from '~/composables/useMachineCreatePreview'
|
||||||
|
import PreviewRequirementGroup from './PreviewRequirementGroup.vue'
|
||||||
|
import IconLucideEye from '~icons/lucide/eye'
|
||||||
|
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||||
|
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
|
||||||
|
import IconLucideCircle from '~icons/lucide/circle'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
preview: any
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
59
app/components/machine/create/PreviewRequirementGroup.vue
Normal file
59
app/components/machine/create/PreviewRequirementGroup.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="border border-base-200 rounded-md p-3 space-y-3">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold">
|
||||||
|
{{ group.label }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Type : {{ group.typeName }} · Min {{ group.min }} ·
|
||||||
|
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
|
||||||
|
{{ group.completed }} / {{ group.total || 0 }} complétée(s)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
|
||||||
|
<ul class="list-disc pl-4 space-y-1">
|
||||||
|
<li v-for="issue in group.issues" :key="issue.message">
|
||||||
|
{{ issue.message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="entry in group.entries"
|
||||||
|
:key="entry.key"
|
||||||
|
class="flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
|
||||||
|
class="w-4 h-4 mt-0.5"
|
||||||
|
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
|
||||||
|
{{ entry.title }}
|
||||||
|
</p>
|
||||||
|
<p v-if="entry.subtitle" class="text-xs text-gray-500">
|
||||||
|
{{ entry.subtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getStatusBadgeClass } from '~/composables/useMachineCreatePreview'
|
||||||
|
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
|
||||||
|
import IconLucideCircle from '~icons/lucide/circle'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
group: any
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
126
app/components/machine/create/RequirementComponentSelector.vue
Normal file
126
app/components/machine/create/RequirementComponentSelector.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="requirements?.length" class="space-y-4">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
Sélection des composants
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="requirement in requirements"
|
||||||
|
:id="`component-group-${requirement.id}`"
|
||||||
|
:key="requirement.id"
|
||||||
|
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h5 class="font-medium text-sm">
|
||||||
|
{{ requirement.label || requirement.typeComposant?.name || 'Famille de composants' }}
|
||||||
|
</h5>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Type : {{ requirement.typeComposant?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
||||||
|
· Max : {{ requirement.maxCount ?? '∞' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline"
|
||||||
|
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
||||||
|
@click="$emit('add-entry', requirement)"
|
||||||
|
>
|
||||||
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
||||||
|
Aucun composant sélectionné pour ce groupe.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
||||||
|
:key="`${requirement.id}-${entryIndex}`"
|
||||||
|
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
||||||
|
<span>
|
||||||
|
Type appliqué :
|
||||||
|
{{ resolveTypeLabel(requirement, entry) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-square btn-xs btn-error"
|
||||||
|
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
||||||
|
>
|
||||||
|
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs">Composant existant</span>
|
||||||
|
</label>
|
||||||
|
<SearchSelect
|
||||||
|
:model-value="entry.composantId || ''"
|
||||||
|
:options="getOptions(requirement, entry)"
|
||||||
|
:loading="loading"
|
||||||
|
size="sm"
|
||||||
|
placeholder="Rechercher un composant…"
|
||||||
|
empty-text="Aucun composant disponible"
|
||||||
|
:option-label="optionLabel"
|
||||||
|
:option-description="optionDescription"
|
||||||
|
@update:modelValue="$emit('set-component', requirement, entryIndex, $event || '')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="getOptions(requirement, entry).length === 0"
|
||||||
|
class="text-xs text-error"
|
||||||
|
>
|
||||||
|
Aucun composant disponible pour cette famille.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="entry.composantId"
|
||||||
|
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
||||||
|
>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ findById(entry.composantId)?.name || "Composant" }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Référence : {{ findById(entry.composantId)?.reference || "—" }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Fournisseur :
|
||||||
|
{{ findById(entry.composantId)?.constructeur?.name || findById(entry.composantId)?.constructeurName || "—" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
requirements: any[]
|
||||||
|
loading: boolean
|
||||||
|
getEntries: (requirementId: string) => any[]
|
||||||
|
getOptions: (requirement: any, entry: any) => any[]
|
||||||
|
resolveTypeLabel: (requirement: any, entry: any) => string
|
||||||
|
findById: (id: string) => any
|
||||||
|
optionLabel: (item: any) => string
|
||||||
|
optionDescription: (item: any) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'add-entry': [requirement: any]
|
||||||
|
'remove-entry': [requirementId: string, entryIndex: number]
|
||||||
|
'set-component': [requirement: any, entryIndex: number, componentId: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
130
app/components/machine/create/RequirementPieceSelector.vue
Normal file
130
app/components/machine/create/RequirementPieceSelector.vue
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="requirements?.length" class="space-y-4">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
Sélection des pièces principales
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="requirement in requirements"
|
||||||
|
:id="`piece-group-${requirement.id}`"
|
||||||
|
:key="requirement.id"
|
||||||
|
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h5 class="font-medium text-sm">
|
||||||
|
{{ requirement.label || requirement.typePiece?.name || 'Groupe de pièces' }}
|
||||||
|
</h5>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Type : {{ requirement.typePiece?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
||||||
|
· Max : {{ requirement.maxCount ?? '∞' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline"
|
||||||
|
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
||||||
|
@click="$emit('add-entry', requirement)"
|
||||||
|
>
|
||||||
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
||||||
|
Aucune pièce sélectionnée pour ce groupe.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
||||||
|
:key="`${requirement.id}-piece-${entryIndex}`"
|
||||||
|
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
||||||
|
<span>
|
||||||
|
Type appliqué :
|
||||||
|
{{ resolveTypeLabel(requirement, entry) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-square btn-xs btn-error"
|
||||||
|
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
||||||
|
>
|
||||||
|
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs">Pièce existante</span>
|
||||||
|
</label>
|
||||||
|
<SearchSelect
|
||||||
|
:model-value="entry.pieceId || ''"
|
||||||
|
:options="getOptions(requirement, entry, entryIndex)"
|
||||||
|
:loading="loading || pieceLoadingByKey[getPieceKey(requirement, entryIndex)]"
|
||||||
|
size="sm"
|
||||||
|
placeholder="Rechercher une pièce…"
|
||||||
|
empty-text="Aucune pièce disponible"
|
||||||
|
:option-label="optionLabel"
|
||||||
|
:option-description="optionDescription"
|
||||||
|
@search="(term: string) => $emit('search', requirement, entryIndex, term)"
|
||||||
|
@update:modelValue="$emit('set-piece', requirement, entryIndex, $event || '')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="getOptions(requirement, entry, entryIndex).length === 0"
|
||||||
|
class="text-xs text-error"
|
||||||
|
>
|
||||||
|
Aucune pièce disponible pour cette famille.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="entry.pieceId"
|
||||||
|
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
||||||
|
>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ findById(entry.pieceId)?.name || "Pièce" }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Référence : {{ findById(entry.pieceId)?.reference || "—" }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Fournisseur :
|
||||||
|
{{ findById(entry.pieceId)?.constructeur?.name || findById(entry.pieceId)?.constructeurName || "—" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
requirements: any[]
|
||||||
|
loading: boolean
|
||||||
|
pieceLoadingByKey: Record<string, boolean>
|
||||||
|
getEntries: (requirementId: string) => any[]
|
||||||
|
getOptions: (requirement: any, entry: any, entryIndex: number) => any[]
|
||||||
|
getPieceKey: (requirement: any, entryIndex: number) => string
|
||||||
|
resolveTypeLabel: (requirement: any, entry: any) => string
|
||||||
|
findById: (id: string) => any
|
||||||
|
optionLabel: (item: any) => string
|
||||||
|
optionDescription: (item: any) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'add-entry': [requirement: any]
|
||||||
|
'remove-entry': [requirementId: string, entryIndex: number]
|
||||||
|
'set-piece': [requirement: any, entryIndex: number, pieceId: string]
|
||||||
|
'search': [requirement: any, entryIndex: number, term: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
142
app/components/machine/create/RequirementProductSelector.vue
Normal file
142
app/components/machine/create/RequirementProductSelector.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="requirements?.length" class="space-y-4">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
Produits catalogue requis
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="requirement in requirements"
|
||||||
|
:id="`product-group-${requirement.id}`"
|
||||||
|
:key="requirement.id"
|
||||||
|
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h5 class="font-medium text-sm">
|
||||||
|
{{ requirement.label || requirement.typeProduct?.name || 'Groupe de produits' }}
|
||||||
|
</h5>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Catégorie : {{ requirement.typeProduct?.name || 'Non définie' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
|
||||||
|
· Max : {{ requirement.maxCount ?? '∞' }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="(requirement.allowNewModels ?? true) === false"
|
||||||
|
class="text-xs text-error"
|
||||||
|
>
|
||||||
|
Sélection de produits existants uniquement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline"
|
||||||
|
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
|
||||||
|
@click="$emit('add-entry', requirement)"
|
||||||
|
>
|
||||||
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
|
||||||
|
Aucun produit sélectionné pour ce groupe.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(entry, entryIndex) in getEntries(requirement.id)"
|
||||||
|
:key="`${requirement.id}-product-${entryIndex}`"
|
||||||
|
class="bg-base-200/60 rounded-md p-3 space-y-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
|
||||||
|
<span>
|
||||||
|
Catégorie appliquée :
|
||||||
|
{{ requirement.typeProduct?.name || 'Non définie' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-square btn-xs btn-error"
|
||||||
|
@click="$emit('remove-entry', requirement.id, entryIndex)"
|
||||||
|
>
|
||||||
|
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs">Produit existant</span>
|
||||||
|
</label>
|
||||||
|
<ProductSelect
|
||||||
|
:model-value="entry.productId || ''"
|
||||||
|
:type-product-id="requirement.typeProductId || requirement.typeProduct?.id || null"
|
||||||
|
:placeholder="productsLoading ? 'Chargement…' : 'Sélectionner un produit…'"
|
||||||
|
empty-text="Aucun produit disponible pour cette catégorie"
|
||||||
|
:disabled="productsLoading"
|
||||||
|
@update:modelValue="$emit('set-product', requirement, entryIndex, $event || '')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="!productsLoading && getProductOptions(requirement).length === 0"
|
||||||
|
class="text-xs text-error"
|
||||||
|
>
|
||||||
|
Aucun produit existant pour cette catégorie. Créez-en un depuis le catalogue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="entry.productId"
|
||||||
|
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
|
||||||
|
>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ findById(entry.productId)?.name || 'Produit' }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Référence : {{ findById(entry.productId)?.reference || "—" }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Prix indicatif :
|
||||||
|
<span
|
||||||
|
v-if="findById(entry.productId)?.supplierPrice !== undefined && findById(entry.productId)?.supplierPrice !== null"
|
||||||
|
>
|
||||||
|
{{ Number(findById(entry.productId)?.supplierPrice).toFixed(2) }} €
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Fournisseurs :
|
||||||
|
<span v-if="findById(entry.productId)?.constructeurs?.length">
|
||||||
|
{{ findById(entry.productId)?.constructeurs.map((constructeur: any) => constructeur?.name).filter(Boolean).join(', ') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ProductSelect from '~/components/ProductSelect.vue'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
requirements: any[]
|
||||||
|
productsLoading: boolean
|
||||||
|
getEntries: (requirementId: string) => any[]
|
||||||
|
getProductOptions: (requirement: any) => any[]
|
||||||
|
findById: (id: string) => any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'add-entry': [requirement: any]
|
||||||
|
'remove-entry': [requirementId: string, entryIndex: number]
|
||||||
|
'set-product': [requirement: any, entryIndex: number, productId: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
458
app/composables/useMachineCreatePage.ts
Normal file
458
app/composables/useMachineCreatePage.ts
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
/**
|
||||||
|
* Machine creation page – orchestration composable.
|
||||||
|
*
|
||||||
|
* Consolidates entity lookup maps, option filters, label helpers,
|
||||||
|
* template wrappers, and the finalization logic that were previously
|
||||||
|
* inlined in pages/machines/new.vue.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||||
|
import { useMachines } from '~/composables/useMachines'
|
||||||
|
import { useSites } from '~/composables/useSites'
|
||||||
|
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||||
|
import { useComposants } from '~/composables/useComposants'
|
||||||
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
|
||||||
|
import {
|
||||||
|
useMachineCreatePreview,
|
||||||
|
validateRequirementSelections as _validateRequirementSelections,
|
||||||
|
resolveComponentRequirementTypeLabel as _resolveComponentRequirementTypeLabel,
|
||||||
|
resolvePieceRequirementTypeLabel as _resolvePieceRequirementTypeLabel,
|
||||||
|
} from '~/composables/useMachineCreatePreview'
|
||||||
|
import {
|
||||||
|
getComponentMachineAssignments,
|
||||||
|
getPieceMachineAssignments,
|
||||||
|
getPieceComponentAssignments,
|
||||||
|
formatAssignmentList,
|
||||||
|
} from '~/shared/utils/assignmentUtils'
|
||||||
|
|
||||||
|
export function useMachineCreatePage() {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Composable calls
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines()
|
||||||
|
const { sites, loadSites } = useSites()
|
||||||
|
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
||||||
|
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
||||||
|
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
|
||||||
|
const { products, loadProducts, loading: productsLoading } = useProducts()
|
||||||
|
const { get } = useApi()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Local state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const newMachine = reactive({
|
||||||
|
name: '',
|
||||||
|
siteId: '',
|
||||||
|
typeMachineId: '',
|
||||||
|
reference: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedMachineType = computed(() => {
|
||||||
|
if (!newMachine.typeMachineId) return null
|
||||||
|
return (machineTypes as any).value.find((type: any) => type.id === newMachine.typeMachineId) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entity lookup maps
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const componentById = computed(() => {
|
||||||
|
const map = new Map()
|
||||||
|
;((composants as any).value || []).forEach((component: any) => {
|
||||||
|
if (component?.id) map.set(component.id, component)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieceById = computed(() => {
|
||||||
|
const map = new Map()
|
||||||
|
;((pieces as any).value || []).forEach((piece: any) => {
|
||||||
|
if (piece?.id) map.set(piece.id, piece)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const componentInventory = computed(() => (composants as any).value || [])
|
||||||
|
const pieceInventory = computed(() => (pieces as any).value || [])
|
||||||
|
const productInventory = computed(() => (products as any).value || [])
|
||||||
|
|
||||||
|
const productById = computed(() => {
|
||||||
|
const map = new Map()
|
||||||
|
;(productInventory.value || []).forEach((product: any) => {
|
||||||
|
if (product?.id) map.set(product.id, product)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entity finders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const findComponentById = (id: string) => {
|
||||||
|
if (!id) return null
|
||||||
|
return componentById.value.get(id) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const findPieceById = (id: string): any => {
|
||||||
|
if (!id) return null
|
||||||
|
return pieceById.value.get(id) || findPieceInCachedOptions(id) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const findProductById = (id: string) => {
|
||||||
|
if (!id) return null
|
||||||
|
return productById.value.get(id) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Selection state (from composable)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const {
|
||||||
|
pieceOptionsByKey,
|
||||||
|
pieceLoadingByKey,
|
||||||
|
selectedPieceIds,
|
||||||
|
getPieceKey,
|
||||||
|
findPieceInCachedOptions,
|
||||||
|
fetchPieceOptions,
|
||||||
|
getComponentRequirementEntries,
|
||||||
|
getPieceRequirementEntries,
|
||||||
|
getProductRequirementEntries,
|
||||||
|
addComponentSelectionEntry,
|
||||||
|
removeComponentSelectionEntry,
|
||||||
|
addPieceSelectionEntry,
|
||||||
|
removePieceSelectionEntry,
|
||||||
|
addProductSelectionEntry,
|
||||||
|
removeProductSelectionEntry,
|
||||||
|
setComponentRequirementComponent,
|
||||||
|
setPieceRequirementPiece,
|
||||||
|
setProductRequirementProduct: _setProductRequirementProduct,
|
||||||
|
clearRequirementSelections,
|
||||||
|
initializeRequirementSelections,
|
||||||
|
} = useMachineCreateSelections({
|
||||||
|
findComponentById,
|
||||||
|
findPieceById,
|
||||||
|
pieces: pieces as any,
|
||||||
|
get: get as any,
|
||||||
|
toast,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Preview / validation (from composable)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const { machinePreview, blockingPreviewIssues, canCreateMachine } = useMachineCreatePreview({
|
||||||
|
newMachine,
|
||||||
|
sites: sites as any,
|
||||||
|
selectedMachineType,
|
||||||
|
findComponentById,
|
||||||
|
findPieceById,
|
||||||
|
findProductById,
|
||||||
|
getComponentRequirementEntries,
|
||||||
|
getPieceRequirementEntries,
|
||||||
|
getProductRequirementEntries,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Template wrappers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const resolveComponentRequirementTypeLabel = (requirement: any, entry: any) =>
|
||||||
|
_resolveComponentRequirementTypeLabel(requirement, entry, findComponentById)
|
||||||
|
|
||||||
|
const resolvePieceRequirementTypeLabel = (requirement: any, entry: any) =>
|
||||||
|
_resolvePieceRequirementTypeLabel(requirement, entry, findPieceById)
|
||||||
|
|
||||||
|
const setProductRequirementProduct = (requirement: any, index: number, productId: string) =>
|
||||||
|
_setProductRequirementProduct(requirement, index, productId, findProductById)
|
||||||
|
|
||||||
|
const validateRequirementSelections = (type: any) =>
|
||||||
|
_validateRequirementSelections(type, {
|
||||||
|
newMachine,
|
||||||
|
sites: sites as any,
|
||||||
|
selectedMachineType,
|
||||||
|
findComponentById,
|
||||||
|
findPieceById,
|
||||||
|
findProductById,
|
||||||
|
getComponentRequirementEntries,
|
||||||
|
getPieceRequirementEntries,
|
||||||
|
getProductRequirementEntries,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Machine type helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const machineTypeLabel = (type: any) => {
|
||||||
|
if (!type) return ''
|
||||||
|
return type.name || 'Type de machine'
|
||||||
|
}
|
||||||
|
|
||||||
|
const machineTypeDescription = (type: any) => {
|
||||||
|
if (!type) return ''
|
||||||
|
const parts: string[] = []
|
||||||
|
if (type.category) parts.push(`Catégorie : ${type.category}`)
|
||||||
|
const componentCount = type.componentRequirements?.length ?? 0
|
||||||
|
const pieceCount = type.pieceRequirements?.length ?? 0
|
||||||
|
const productCount = type.productRequirements?.length ?? 0
|
||||||
|
parts.push(
|
||||||
|
`${componentCount} composant(s)`,
|
||||||
|
`${pieceCount} pièce(s)`,
|
||||||
|
`${productCount} produit(s)`,
|
||||||
|
)
|
||||||
|
return parts.join(' • ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Option filters
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const getComponentOptions = (requirement: any, currentEntry: any) => {
|
||||||
|
const requirementTypeId = requirement?.typeComposantId || requirement?.typeComposant?.id || null
|
||||||
|
return componentInventory.value.filter((component: any) => {
|
||||||
|
if (!component?.id) return false
|
||||||
|
if (requirementTypeId && component.typeComposantId !== requirementTypeId) {
|
||||||
|
return currentEntry?.composantId === component.id
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPieceOptions = (requirement: any, currentEntry: any, entryIndex: number) => {
|
||||||
|
const key = getPieceKey(requirement, entryIndex)
|
||||||
|
const cached = pieceOptionsByKey.value[key]
|
||||||
|
if (cached) return cached
|
||||||
|
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
|
||||||
|
const usedIds = new Set(
|
||||||
|
selectedPieceIds.value.filter((id: any) => id && (!currentEntry || id !== currentEntry.pieceId)),
|
||||||
|
)
|
||||||
|
return pieceInventory.value.filter((piece: any) => {
|
||||||
|
if (requirementTypeId && piece.typePieceId !== requirementTypeId) return false
|
||||||
|
if (!piece.id) return false
|
||||||
|
if (currentEntry?.pieceId === piece.id) return true
|
||||||
|
return !usedIds.has(piece.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProductOptions = (requirement: any) => {
|
||||||
|
const requirementTypeId = requirement?.typeProductId || requirement?.typeProduct?.id || null
|
||||||
|
return productInventory.value.filter((product: any) => {
|
||||||
|
if (!product?.id) return false
|
||||||
|
if (!requirementTypeId) return true
|
||||||
|
const productTypeId = product.typeProductId || product.typeProduct?.id || null
|
||||||
|
return productTypeId === requirementTypeId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Option label / description helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const componentOptionLabel = (component: any) => component?.name || 'Composant'
|
||||||
|
|
||||||
|
const componentOptionDescription = (component: any) => {
|
||||||
|
if (!component) return ''
|
||||||
|
const parts: string[] = []
|
||||||
|
if (component.reference) parts.push(`Réf. ${component.reference}`)
|
||||||
|
const constructeurName = component.constructeur?.name || component.constructeurName
|
||||||
|
if (constructeurName) parts.push(constructeurName)
|
||||||
|
const machineAssignments = getComponentMachineAssignments(component)
|
||||||
|
if (machineAssignments.length) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
|
||||||
|
const productTypeName = component.product?.typeProduct?.name
|
||||||
|
const productLabel = component.product?.name || component.product?.reference
|
||||||
|
if (productTypeName || productLabel) parts.push(`Produit: ${productTypeName || productLabel}`)
|
||||||
|
return parts.join(' • ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pieceOptionLabel = (piece: any) => piece?.name || 'Pièce'
|
||||||
|
|
||||||
|
const pieceOptionDescription = (piece: any) => {
|
||||||
|
if (!piece) return ''
|
||||||
|
const parts: string[] = []
|
||||||
|
if (piece.reference) parts.push(`Réf. ${piece.reference}`)
|
||||||
|
const constructeurName = piece.constructeur?.name || piece.constructeurName
|
||||||
|
if (constructeurName) parts.push(constructeurName)
|
||||||
|
const machineAssignments = getPieceMachineAssignments(piece)
|
||||||
|
if (machineAssignments.length) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
|
||||||
|
const componentAssignments = getPieceComponentAssignments(piece)
|
||||||
|
if (componentAssignments.length) parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`)
|
||||||
|
const productTypeName = piece.product?.typeProduct?.name
|
||||||
|
const productLabel = piece.product?.name || piece.product?.reference
|
||||||
|
if (productTypeName || productLabel) parts.push(`Produit: ${productTypeName || productLabel}`)
|
||||||
|
return parts.join(' • ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Machine creation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const finalizeMachineCreation = async () => {
|
||||||
|
if (submitting.value) return
|
||||||
|
const type = selectedMachineType.value
|
||||||
|
if (!type) {
|
||||||
|
toast.showError('Merci de sélectionner un type de machine')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!canCreateMachine.value) {
|
||||||
|
toast.showError('Compléter les informations obligatoires avant de créer la machine')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const baseMachineData = {
|
||||||
|
name: newMachine.name,
|
||||||
|
siteId: newMachine.siteId,
|
||||||
|
reference: newMachine.reference,
|
||||||
|
typeMachineId: type.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRequirements =
|
||||||
|
(type.componentRequirements?.length || 0) > 0 ||
|
||||||
|
(type.pieceRequirements?.length || 0) > 0 ||
|
||||||
|
(type.productRequirements?.length || 0) > 0
|
||||||
|
|
||||||
|
let componentLinks: any[] = []
|
||||||
|
let pieceLinks: any[] = []
|
||||||
|
let productLinks: any[] = []
|
||||||
|
|
||||||
|
if (hasRequirements) {
|
||||||
|
const validationResult = validateRequirementSelections(type)
|
||||||
|
if (!validationResult.valid) {
|
||||||
|
toast.showError(validationResult.error as string)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
componentLinks = validationResult.componentLinks as any[]
|
||||||
|
pieceLinks = validationResult.pieceLinks as any[]
|
||||||
|
productLinks = validationResult.productLinks as any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: any = hasRequirements
|
||||||
|
? await createMachine(baseMachineData as any)
|
||||||
|
: await createMachineFromType(baseMachineData as any, type)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (hasRequirements && result.data?.id) {
|
||||||
|
const skeletonResult: any = await reconfigureSkeleton(result.data.id, {
|
||||||
|
componentLinks,
|
||||||
|
pieceLinks,
|
||||||
|
productLinks,
|
||||||
|
} as any)
|
||||||
|
if (!skeletonResult.success) {
|
||||||
|
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newMachine.name = ''
|
||||||
|
newMachine.siteId = ''
|
||||||
|
newMachine.typeMachineId = ''
|
||||||
|
newMachine.reference = ''
|
||||||
|
clearRequirementSelections()
|
||||||
|
await navigateTo('/machines')
|
||||||
|
} else if (result.error) {
|
||||||
|
toast.showError(`Impossible de créer la machine: ${result.error}`)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.showError(`Erreur lors de la création: ${error.message}`)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Watchers & lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => newMachine.typeMachineId,
|
||||||
|
(typeId) => {
|
||||||
|
clearRequirementSelections()
|
||||||
|
if (!typeId) return
|
||||||
|
const type = (machineTypes as any).value.find((item: any) => item.id === typeId)
|
||||||
|
if (!type) return
|
||||||
|
initializeRequirementSelections(type)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
loadSites(),
|
||||||
|
loadMachineTypes(),
|
||||||
|
loadComposants(),
|
||||||
|
loadPieces(),
|
||||||
|
loadProducts(),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
submitting,
|
||||||
|
newMachine,
|
||||||
|
sites,
|
||||||
|
machineTypes,
|
||||||
|
machineTypesLoading,
|
||||||
|
composantsLoading,
|
||||||
|
piecesLoading,
|
||||||
|
productsLoading,
|
||||||
|
selectedMachineType,
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
pieceLoadingByKey,
|
||||||
|
getPieceKey,
|
||||||
|
fetchPieceOptions,
|
||||||
|
getComponentRequirementEntries,
|
||||||
|
getPieceRequirementEntries,
|
||||||
|
getProductRequirementEntries,
|
||||||
|
addComponentSelectionEntry,
|
||||||
|
removeComponentSelectionEntry,
|
||||||
|
addPieceSelectionEntry,
|
||||||
|
removePieceSelectionEntry,
|
||||||
|
addProductSelectionEntry,
|
||||||
|
removeProductSelectionEntry,
|
||||||
|
setComponentRequirementComponent,
|
||||||
|
setPieceRequirementPiece,
|
||||||
|
setProductRequirementProduct,
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
machinePreview,
|
||||||
|
blockingPreviewIssues,
|
||||||
|
canCreateMachine,
|
||||||
|
|
||||||
|
// Entity finders
|
||||||
|
findComponentById,
|
||||||
|
findPieceById,
|
||||||
|
findProductById,
|
||||||
|
|
||||||
|
// Options
|
||||||
|
getComponentOptions,
|
||||||
|
getPieceOptions,
|
||||||
|
getProductOptions,
|
||||||
|
|
||||||
|
// Label helpers
|
||||||
|
machineTypeLabel,
|
||||||
|
machineTypeDescription,
|
||||||
|
componentOptionLabel,
|
||||||
|
componentOptionDescription,
|
||||||
|
pieceOptionLabel,
|
||||||
|
pieceOptionDescription,
|
||||||
|
|
||||||
|
// Type label resolvers
|
||||||
|
resolveComponentRequirementTypeLabel,
|
||||||
|
resolvePieceRequirementTypeLabel,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
finalizeMachineCreation,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
type AnyRecord = Record<string, unknown>
|
type AnyRecord = Record<string, unknown>
|
||||||
|
|
||||||
@@ -17,14 +18,6 @@ export interface MachineCreateSelectionsDeps {
|
|||||||
toast: { showError: (msg: string) => void }
|
toast: { showError: (msg: string) => void }
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractCollection = (payload: unknown): unknown[] => {
|
|
||||||
if (Array.isArray(payload)) return payload
|
|
||||||
if (Array.isArray((payload as AnyRecord)?.member)) return (payload as AnyRecord).member as unknown[]
|
|
||||||
if (Array.isArray((payload as AnyRecord)?.['hydra:member'])) return (payload as AnyRecord)['hydra:member'] as unknown[]
|
|
||||||
if (Array.isArray((payload as AnyRecord)?.data)) return (payload as AnyRecord).data as unknown[]
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
|
export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
|
||||||
const { findComponentById, findPieceById, pieces, get, toast } = deps
|
const { findComponentById, findPieceById, pieces, get, toast } = deps
|
||||||
|
|
||||||
@@ -317,11 +310,12 @@ export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
|
|||||||
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
|
||||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||||
if (initialCount > 0) {
|
if (initialCount > 0) {
|
||||||
pieceRequirementSelections[requirement.id as string] = Array.from(
|
const entries = Array.from(
|
||||||
{ length: initialCount },
|
{ length: initialCount },
|
||||||
() => createPieceSelectionEntry(requirement),
|
() => createPieceSelectionEntry(requirement),
|
||||||
)
|
)
|
||||||
pieceRequirementSelections[requirement.id as string].forEach((_: unknown, index: number) => {
|
pieceRequirementSelections[requirement.id as string] = entries
|
||||||
|
entries.forEach((_: unknown, index: number) => {
|
||||||
fetchPieceOptions(requirement, index).catch(() => {})
|
fetchPieceOptions(requirement, index).catch(() => {})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user