refactor(machine): decompose detail page into composables + 7 components (F1.1)
Extract 2 composables (useMachineDetailData, useMachineSkeletonEditor) and 7 UI components from machine/[id].vue, reducing it from 2989 to 219 LOC. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
53
app/components/machine/MachineComponentsCard.vue
Normal file
53
app/components/machine/MachineComponentsCard.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="card-title">Composants</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
|
@click="$emit('toggle-collapse')"
|
||||||
|
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
|
||||||
|
>
|
||||||
|
<IconLucideChevronRight
|
||||||
|
class="w-5 h-5 transition-transform"
|
||||||
|
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">
|
||||||
|
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ComponentHierarchy
|
||||||
|
:components="components"
|
||||||
|
:is-edit-mode="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)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ComponentHierarchy from '~/components/ComponentHierarchy.vue'
|
||||||
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
components: any[]
|
||||||
|
isEditMode: boolean
|
||||||
|
collapsed: boolean
|
||||||
|
collapseToggleToken: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'toggle-collapse': []
|
||||||
|
'update-component': [component: any]
|
||||||
|
'edit-piece': [piece: any]
|
||||||
|
'custom-field-update': [fieldUpdate: any]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
76
app/components/machine/MachineDetailHeader.vue
Normal file
76
app/components/machine/MachineDetailHeader.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h1 class="text-3xl font-bold">
|
||||||
|
{{ title }}
|
||||||
|
</h1>
|
||||||
|
<div class="btn-group w-full max-w-xs print:hidden" data-print-hide>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="isDetailsView ? 'btn-primary' : 'btn-outline'"
|
||||||
|
@click="$emit('change-view', 'details')"
|
||||||
|
>
|
||||||
|
Vue machine
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="isSkeletonView ? 'btn-primary' : 'btn-outline'"
|
||||||
|
:disabled="!hasSkeletonRequirements"
|
||||||
|
@click="$emit('change-view', 'skeleton')"
|
||||||
|
>
|
||||||
|
Squelette
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
||||||
|
<button
|
||||||
|
@click="$emit('toggle-edit')"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:class="{ 'btn-outline': isEditMode }"
|
||||||
|
>
|
||||||
|
<IconLucideSquarePen
|
||||||
|
v-if="!isEditMode"
|
||||||
|
class="w-5 h-5 mr-2"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<IconLucideEye
|
||||||
|
v-else
|
||||||
|
class="w-5 h-5 mr-2"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isDetailsView && !isEditMode"
|
||||||
|
@click="$emit('open-print')"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-secondary"
|
||||||
|
>
|
||||||
|
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||||
|
Imprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||||
|
import IconLucideEye from '~icons/lucide/eye'
|
||||||
|
import IconLucidePrinter from '~icons/lucide/printer'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
isDetailsView: boolean
|
||||||
|
isSkeletonView: boolean
|
||||||
|
isEditMode: boolean
|
||||||
|
hasSkeletonRequirements: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'change-view': [view: 'details' | 'skeleton']
|
||||||
|
'toggle-edit': []
|
||||||
|
'open-print': []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
116
app/components/machine/MachineDocumentsCard.vue
Normal file
116
app/components/machine/MachineDocumentsCard.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 shadow-lg mt-6">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title">Documents de la machine</h2>
|
||||||
|
<p class="text-xs text-gray-500">Ajoutez ou consultez les documents liés à cette machine.</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="isEditMode && files.length" class="badge badge-outline">
|
||||||
|
{{ files.length }} fichier{{ files.length > 1 ? 's' : '' }} sélectionné{{ files.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocumentUpload
|
||||||
|
v-if="isEditMode"
|
||||||
|
:model-value="files"
|
||||||
|
@update:model-value="$emit('update:files', $event)"
|
||||||
|
title="Déposer des fichiers pour la machine"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents..."
|
||||||
|
@files-added="$emit('files-added', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="documents.length" class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="doc in documents"
|
||||||
|
:key="doc.id"
|
||||||
|
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
|
||||||
|
:class="documentThumbnailClass(doc)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="isImageDocument(doc) && doc.path"
|
||||||
|
:src="doc.path"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:alt="`Aperçu de ${doc.name}`"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
v-else-if="shouldInlinePdf(doc)"
|
||||||
|
:src="documentPreviewSrc(doc)"
|
||||||
|
class="h-full w-full border-0 bg-white"
|
||||||
|
title="Aperçu PDF"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="documentIcon(doc).component"
|
||||||
|
class="h-6 w-6"
|
||||||
|
:class="documentIcon(doc).colorClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ doc.name }}</div>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
{{ doc.mimeType || 'Inconnu' }} • {{ formatSize(doc.size) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="!canPreviewDocument(doc)"
|
||||||
|
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||||
|
@click="$emit('preview', doc)"
|
||||||
|
>
|
||||||
|
Consulter
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="$emit('download', doc)">
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isEditMode"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-xs"
|
||||||
|
:disabled="uploading"
|
||||||
|
@click="$emit('remove', doc.id)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-gray-500">Aucun document lié à cette machine.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
|
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||||
|
import {
|
||||||
|
formatSize,
|
||||||
|
shouldInlinePdf,
|
||||||
|
documentPreviewSrc,
|
||||||
|
documentThumbnailClass,
|
||||||
|
documentIcon,
|
||||||
|
} from '~/shared/utils/documentDisplayUtils'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
documents: any[]
|
||||||
|
isEditMode: boolean
|
||||||
|
uploading: boolean
|
||||||
|
files: File[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:files': [files: File[]]
|
||||||
|
'files-added': [files: File[]]
|
||||||
|
'preview': [doc: any]
|
||||||
|
'download': [doc: any]
|
||||||
|
'remove': [documentId: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
185
app/components/machine/MachineInfoCard.vue
Normal file
185
app/components/machine/MachineInfoCard.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Informations de la machine</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Nom</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditMode"
|
||||||
|
:id="getMachineFieldId('name')"
|
||||||
|
:value="machineName"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered"
|
||||||
|
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
|
||||||
|
@blur="$emit('blur-field')"
|
||||||
|
/>
|
||||||
|
<div v-else class="input input-bordered bg-base-200">
|
||||||
|
{{ machineName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isEditMode || machineReference" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Référence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditMode"
|
||||||
|
:id="getMachineFieldId('reference')"
|
||||||
|
:value="machineReference"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered"
|
||||||
|
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
|
||||||
|
@blur="$emit('blur-field')"
|
||||||
|
/>
|
||||||
|
<div v-else class="input input-bordered bg-base-200">
|
||||||
|
{{ machineReference }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isEditMode || hasMachineConstructeur" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Fournisseur</span>
|
||||||
|
</label>
|
||||||
|
<ConstructeurSelect
|
||||||
|
v-if="isEditMode"
|
||||||
|
class="w-full"
|
||||||
|
:model-value="machineConstructeurIds"
|
||||||
|
:initial-options="machineConstructeursDisplay"
|
||||||
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
|
@update:modelValue="$emit('update:constructeur-ids', $event)"
|
||||||
|
/>
|
||||||
|
<div v-else class="input input-bordered bg-base-200">
|
||||||
|
<div v-if="machineConstructeursDisplay.length" class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="constructeur in machineConstructeursDisplay"
|
||||||
|
:key="constructeur.id"
|
||||||
|
class="flex flex-col"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ constructeur.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="formatConstructeurContactSummary(constructeur)"
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
{{ formatConstructeurContactSummary(constructeur) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="font-medium">Non défini</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Champs personnalisés -->
|
||||||
|
<div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<h4 class="font-semibold text-gray-700 mb-3">Champs personnalisés de la machine</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="field in visibleCustomFields"
|
||||||
|
:key="field.customFieldValueId || field.id || field.name"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-sm">{{ field.name }}</span>
|
||||||
|
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<template v-if="isEditMode">
|
||||||
|
<input
|
||||||
|
v-if="field.type === 'text'"
|
||||||
|
:value="field.value ?? ''"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
:required="field.required"
|
||||||
|
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||||
|
@blur="$emit('update-custom-field', field)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else-if="field.type === 'number'"
|
||||||
|
:value="field.value ?? ''"
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
:required="field.required"
|
||||||
|
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||||
|
@blur="$emit('update-custom-field', field)"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-else-if="field.type === 'select'"
|
||||||
|
:value="field.value ?? ''"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
:required="field.required"
|
||||||
|
@change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)"
|
||||||
|
@blur="$emit('update-custom-field', field)"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
<option
|
||||||
|
v-for="option in field.options"
|
||||||
|
:key="option"
|
||||||
|
:value="option"
|
||||||
|
>
|
||||||
|
{{ option }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
:value="field.value ?? ''"
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
:checked="String(field.value).toLowerCase() === 'true'"
|
||||||
|
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
||||||
|
@blur="$emit('update-custom-field', field)"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-else-if="field.type === 'date'"
|
||||||
|
:value="field.value ?? ''"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
:required="field.required"
|
||||||
|
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||||
|
@blur="$emit('update-custom-field', field)"
|
||||||
|
/>
|
||||||
|
<div v-else class="text-xs text-error">
|
||||||
|
Type de champ non pris en charge
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="input input-bordered input-sm bg-base-200">
|
||||||
|
{{ formatCustomFieldValue(field) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
|
import {
|
||||||
|
formatConstructeurContact as formatConstructeurContactSummary,
|
||||||
|
} from '~/shared/constructeurUtils'
|
||||||
|
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
isEditMode: boolean
|
||||||
|
machineName: string
|
||||||
|
machineReference: string
|
||||||
|
machineConstructeurIds: string[]
|
||||||
|
machineConstructeursDisplay: any[]
|
||||||
|
hasMachineConstructeur: boolean
|
||||||
|
visibleCustomFields: any[]
|
||||||
|
getMachineFieldId: (fieldName: string) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:machine-name': [value: string]
|
||||||
|
'update:machine-reference': [value: string]
|
||||||
|
'update:constructeur-ids': [ids: unknown]
|
||||||
|
'blur-field': []
|
||||||
|
'set-custom-field-value': [field: any, value: unknown]
|
||||||
|
'update-custom-field': [field: any]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
34
app/components/machine/MachinePiecesCard.vue
Normal file
34
app/components/machine/MachinePiecesCard.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="card-title">Pièces de la machine</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<PieceItem
|
||||||
|
v-for="piece in pieces"
|
||||||
|
:key="piece.id"
|
||||||
|
:piece="piece"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
@update="$emit('update-piece', $event)"
|
||||||
|
@edit="$emit('edit-piece', $event)"
|
||||||
|
@custom-field-update="$emit('custom-field-update', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
pieces: any[]
|
||||||
|
isEditMode: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update-piece': [piece: any]
|
||||||
|
'edit-piece': [piece: any]
|
||||||
|
'custom-field-update': [fieldUpdate: any]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
62
app/components/machine/MachineProductsCard.vue
Normal file
62
app/components/machine/MachineProductsCard.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title">Produits associés</h2>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Produits sélectionnés directement pour cette machine selon le squelette.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-outline" v-if="products.length">
|
||||||
|
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<p class="font-semibold text-base-content">
|
||||||
|
{{ product.name }}
|
||||||
|
</p>
|
||||||
|
<span class="badge badge-ghost badge-sm">
|
||||||
|
{{ product.groupLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="product.reference" class="text-xs text-base-content/70">
|
||||||
|
<span class="font-medium">Référence :</span>
|
||||||
|
<span class="ml-1">{{ product.reference }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="product.supplierLabel" class="text-xs text-base-content/70">
|
||||||
|
<span class="font-medium">Fournisseurs :</span>
|
||||||
|
<span class="ml-1">{{ product.supplierLabel }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="product.priceLabel" class="text-xs text-base-content/70">
|
||||||
|
<span class="font-medium">Prix indicatif :</span>
|
||||||
|
<span class="ml-1">{{ product.priceLabel }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-gray-500">
|
||||||
|
Aucun produit n'a été associé directement à cette machine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
products: Array<{
|
||||||
|
id?: string | null
|
||||||
|
name?: string
|
||||||
|
reference?: string | null
|
||||||
|
supplierLabel?: string | null
|
||||||
|
priceLabel?: string | null
|
||||||
|
groupLabel?: string
|
||||||
|
}>
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
193
app/components/machine/MachineSkeletonSummary.vue
Normal file
193
app/components/machine/MachineSkeletonSummary.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="componentRequirementGroups.length || pieceRequirementGroups.length || productRequirementGroups.length"
|
||||||
|
class="card bg-base-100 shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="card-body space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title">Structure sélectionnée</h2>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Synthèse des familles définies dans le type et des modèles utilisés pour cette machine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Component requirement groups -->
|
||||||
|
<div v-if="componentRequirementGroups.length" class="space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700">Composants</h3>
|
||||||
|
<div
|
||||||
|
v-for="group in componentRequirementGroups"
|
||||||
|
:key="group.requirement.id"
|
||||||
|
class="rounded-lg border border-base-200 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-sm">
|
||||||
|
{{ group.requirement.label || group.requirement.typeComposant?.name || 'Famille de composants' }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Type : {{ group.requirement.typeComposant?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-outline badge-sm">{{ group.components.length }} composant(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="group.components.length" class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="component in group.components"
|
||||||
|
:key="component.id"
|
||||||
|
class="flex flex-wrap items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ component.name }}</span>
|
||||||
|
<span v-if="component.parentComposantId" class="text-xs text-gray-500">
|
||||||
|
(Sous-composant)
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="summarizeCustomFields(component.customFields || []).length"
|
||||||
|
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="field in summarizeCustomFields(component.customFields || [])"
|
||||||
|
:key="field.key"
|
||||||
|
class="badge badge-ghost badge-sm whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ field.label }} :</span>
|
||||||
|
<span class="ml-1">{{ field.value }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SkeletonProductDisplay :product-display="component.__productDisplay" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-gray-500">Aucun composant rattaché à ce groupe.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Piece requirement groups -->
|
||||||
|
<div v-if="pieceRequirementGroups.length" class="space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700">Pièces principales</h3>
|
||||||
|
<div
|
||||||
|
v-for="group in pieceRequirementGroups"
|
||||||
|
:key="group.requirement.id"
|
||||||
|
class="rounded-lg border border-base-200 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-sm">
|
||||||
|
{{ group.requirement.label || group.requirement.typePiece?.name || 'Groupe de pièces' }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Type : {{ group.requirement.typePiece?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-outline badge-sm">{{ group.pieces.length }} pièce(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="group.pieces.length" class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="piece in group.pieces"
|
||||||
|
:key="piece.id"
|
||||||
|
class="flex flex-wrap items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ piece.name }}</span>
|
||||||
|
<span v-if="piece.parentComponentName" class="text-xs text-gray-500">
|
||||||
|
(Rattachée à {{ piece.parentComponentName }})
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="summarizeCustomFields(piece.customFields || []).length"
|
||||||
|
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="field in summarizeCustomFields(piece.customFields || [])"
|
||||||
|
:key="field.key"
|
||||||
|
class="badge badge-ghost badge-sm whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ field.label }} :</span>
|
||||||
|
<span class="ml-1">{{ field.value }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SkeletonProductDisplay :product-display="piece.__productDisplay" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-gray-500">Aucune pièce rattachée à ce groupe.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product requirement groups -->
|
||||||
|
<div v-if="productRequirementGroups.length" class="space-y-4">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700">Produits requis</h3>
|
||||||
|
<div
|
||||||
|
v-for="group in productRequirementGroups"
|
||||||
|
:key="group.requirement.id"
|
||||||
|
class="rounded-lg border border-base-200 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-sm">
|
||||||
|
{{ group.requirement.label || group.requirement.typeProduct?.name || 'Groupe de produits' }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Catégorie : {{ group.requirement.typeProduct?.name || 'Non définie' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="badge badge-outline badge-sm">Total {{ group.totalCount }}</span>
|
||||||
|
<span class="badge badge-ghost badge-sm">Direct {{ group.directProducts.length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 mb-3">
|
||||||
|
Via composants : {{ group.componentCount }} • Via pièces : {{ group.pieceCount }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="group.directProducts.length" class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="product in group.directProducts"
|
||||||
|
:key="product.id || product.name"
|
||||||
|
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ product.name }}</div>
|
||||||
|
<div v-if="product.reference" class="text-xs text-gray-500">
|
||||||
|
Référence : {{ product.reference }}
|
||||||
|
</div>
|
||||||
|
<div v-if="product.supplierLabel" class="text-xs text-gray-500">
|
||||||
|
Fournisseurs : {{ product.supplierLabel }}
|
||||||
|
</div>
|
||||||
|
<div v-if="product.priceLabel" class="text-xs text-gray-500">
|
||||||
|
Prix indicatif : {{ product.priceLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-gray-500">
|
||||||
|
Aucune sélection directe. Couverture assurée via composants ou pièces associés.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { summarizeCustomFields } from '~/shared/utils/customFieldUtils'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
componentRequirementGroups: any[]
|
||||||
|
pieceRequirementGroups: any[]
|
||||||
|
productRequirementGroups: any[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const SkeletonProductDisplay = defineComponent({
|
||||||
|
name: 'SkeletonProductDisplay',
|
||||||
|
props: {
|
||||||
|
productDisplay: { type: Object, default: null },
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div v-if="productDisplay" class="w-full text-xs text-gray-600 space-y-1">
|
||||||
|
<div><span class="font-medium">Produit :</span> <span>{{ productDisplay.name }}</span></div>
|
||||||
|
<div v-if="productDisplay.category"><span class="font-medium">Catégorie :</span> <span>{{ productDisplay.category }}</span></div>
|
||||||
|
<div v-if="productDisplay.reference"><span class="font-medium">Référence :</span> <span>{{ productDisplay.reference }}</span></div>
|
||||||
|
<div v-if="productDisplay.suppliers"><span class="font-medium">Fournisseurs :</span> <span>{{ productDisplay.suppliers }}</span></div>
|
||||||
|
<div v-if="productDisplay.price"><span class="font-medium">Prix indicatif :</span> <span>{{ productDisplay.price }}</span></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
1404
app/composables/useMachineDetailData.ts
Normal file
1404
app/composables/useMachineDetailData.ts
Normal file
File diff suppressed because it is too large
Load Diff
838
app/composables/useMachineSkeletonEditor.ts
Normal file
838
app/composables/useMachineSkeletonEditor.ts
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
/**
|
||||||
|
* Machine skeleton editor — selection state, validation & save logic.
|
||||||
|
*
|
||||||
|
* Extracted from pages/machine/[id].vue (F1.1).
|
||||||
|
* Manages the reactive selection state for component / piece / product
|
||||||
|
* skeleton requirements, validation, and reconfiguration API calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
||||||
|
import {
|
||||||
|
resolveIdentifier,
|
||||||
|
extractParentLinkIdentifiers,
|
||||||
|
} from '~/shared/utils/productDisplayUtils'
|
||||||
|
import {
|
||||||
|
uniqueConstructeurIds,
|
||||||
|
} from '~/shared/constructeurUtils'
|
||||||
|
import { resolveLinkArray } from '~/composables/useMachineHierarchy'
|
||||||
|
import type { Ref, ComputedRef } from 'vue'
|
||||||
|
|
||||||
|
type AnyRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
export interface MachineSkeletonEditorDeps {
|
||||||
|
machine: Ref<AnyRecord | null>
|
||||||
|
components: Ref<AnyRecord[]>
|
||||||
|
pieces: Ref<AnyRecord[]>
|
||||||
|
machineComponentLinks: Ref<AnyRecord[]>
|
||||||
|
machinePieceLinks: Ref<AnyRecord[]>
|
||||||
|
machineProductLinks: Ref<AnyRecord[]>
|
||||||
|
machineType: ComputedRef<AnyRecord | null>
|
||||||
|
machineHasSkeletonRequirements: ComputedRef<boolean>
|
||||||
|
componentRequirements: ComputedRef<AnyRecord[]>
|
||||||
|
pieceRequirements: ComputedRef<AnyRecord[]>
|
||||||
|
productRequirements: ComputedRef<AnyRecord[]>
|
||||||
|
componentTypeLabelMap: ComputedRef<Map<string, string>>
|
||||||
|
pieceTypeLabelMap: ComputedRef<Map<string, string>>
|
||||||
|
productInventory: ComputedRef<AnyRecord[]>
|
||||||
|
flattenedComponents: ComputedRef<AnyRecord[]>
|
||||||
|
machinePieces: ComputedRef<AnyRecord[]>
|
||||||
|
machineDocumentsLoaded: Ref<boolean>
|
||||||
|
findProductById: (id: string | null | undefined) => AnyRecord | null
|
||||||
|
findComponentById: (items: AnyRecord[] | undefined, id: string) => AnyRecord | null
|
||||||
|
findPieceById: (id: string) => AnyRecord | null
|
||||||
|
transformCustomFields: (pieces: AnyRecord[]) => AnyRecord[]
|
||||||
|
transformComponentCustomFields: (components: AnyRecord[]) => AnyRecord[]
|
||||||
|
applyMachineLinks: (source: AnyRecord) => boolean
|
||||||
|
collapseAllComponents: () => void
|
||||||
|
initMachineFields: () => void
|
||||||
|
collectPiecesForSkeleton: () => AnyRecord[]
|
||||||
|
constructeurs: Ref<AnyRecord[]>
|
||||||
|
loadProducts: () => Promise<void>
|
||||||
|
reconfigureMachineSkeleton: (id: string, payload: AnyRecord) => Promise<AnyRecord>
|
||||||
|
toast: { showError: (msg: string) => void; showInfo: (msg: string) => void }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMachineSkeletonEditor(deps: MachineSkeletonEditorDeps) {
|
||||||
|
const {
|
||||||
|
machine,
|
||||||
|
components,
|
||||||
|
pieces,
|
||||||
|
machineComponentLinks,
|
||||||
|
machinePieceLinks,
|
||||||
|
machineProductLinks,
|
||||||
|
machineType,
|
||||||
|
machineHasSkeletonRequirements,
|
||||||
|
productRequirements,
|
||||||
|
componentTypeLabelMap,
|
||||||
|
pieceTypeLabelMap,
|
||||||
|
productInventory,
|
||||||
|
flattenedComponents,
|
||||||
|
machineDocumentsLoaded,
|
||||||
|
findProductById,
|
||||||
|
findComponentById,
|
||||||
|
findPieceById,
|
||||||
|
transformCustomFields,
|
||||||
|
transformComponentCustomFields,
|
||||||
|
applyMachineLinks,
|
||||||
|
collapseAllComponents,
|
||||||
|
initMachineFields,
|
||||||
|
collectPiecesForSkeleton,
|
||||||
|
loadProducts,
|
||||||
|
reconfigureMachineSkeleton,
|
||||||
|
toast,
|
||||||
|
} = deps
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// View state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const activeMachineView = ref<'details' | 'skeleton'>('details')
|
||||||
|
const isDetailsView = computed(() => activeMachineView.value === 'details')
|
||||||
|
const isSkeletonView = computed(() => activeMachineView.value === 'skeleton')
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Editor state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const skeletonEditor = reactive({
|
||||||
|
open: false,
|
||||||
|
loading: false,
|
||||||
|
submitting: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||||
|
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||||
|
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): boolean =>
|
||||||
|
Object.prototype.toString.call(value) === '[object Object]'
|
||||||
|
|
||||||
|
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||||
|
componentRequirementSelections[requirementId] || []
|
||||||
|
|
||||||
|
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||||
|
pieceRequirementSelections[requirementId] || []
|
||||||
|
|
||||||
|
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
|
||||||
|
productRequirementSelections[requirementId] || []
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Label resolvers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const resolveComponentRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||||
|
const typeId = (entry?.typeComposantId || requirement?.typeComposantId || null) as string | null
|
||||||
|
if (!typeId) return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
||||||
|
return componentTypeLabelMap.value.get(typeId) || ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePieceRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||||
|
const typeId = (entry?.typePieceId || requirement?.typePieceId || null) as string | null
|
||||||
|
if (!typeId) return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
||||||
|
return pieceTypeLabelMap.value.get(typeId) || ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveProductRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
|
||||||
|
const typeId =
|
||||||
|
(entry?.typeProductId as string) ||
|
||||||
|
(requirement?.typeProductId as string) ||
|
||||||
|
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||||
|
null
|
||||||
|
if (typeId) {
|
||||||
|
const typeMatch = productRequirements.value.find(
|
||||||
|
(req: AnyRecord) =>
|
||||||
|
req.typeProductId === typeId || (req.typeProduct as AnyRecord)?.id === typeId,
|
||||||
|
)
|
||||||
|
if (typeMatch && (typeMatch.typeProduct as AnyRecord)?.name) {
|
||||||
|
return (typeMatch.typeProduct as AnyRecord).name as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ((requirement?.typeProduct as AnyRecord)?.name as string) || 'Catégorie non définie'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProductOptionsForRequirement = (requirement: AnyRecord): AnyRecord[] => {
|
||||||
|
const requirementTypeId =
|
||||||
|
(requirement?.typeProductId as string) ||
|
||||||
|
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||||
|
null
|
||||||
|
return (productInventory.value as AnyRecord[]).filter((product) => {
|
||||||
|
if (!product?.id) return false
|
||||||
|
if (!requirementTypeId) return true
|
||||||
|
const productTypeId =
|
||||||
|
(product.typeProductId as string) ||
|
||||||
|
((product.typeProduct as AnyRecord)?.id as string) ||
|
||||||
|
null
|
||||||
|
return productTypeId === requirementTypeId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Selection entry factories
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||||
|
const link = (source?.machineComponentLink as AnyRecord) || null
|
||||||
|
|
||||||
|
const entry: AnyRecord = {
|
||||||
|
linkId: resolveIdentifier(link?.id, source?.machineComponentLinkId, source?.linkId),
|
||||||
|
composantId: resolveIdentifier(source?.composantId, source?.componentId, source?.id),
|
||||||
|
parentLinkId: resolveIdentifier(link?.parentLinkId, link?.parentComponentLinkId, source?.parentComponentLinkId, source?.parentLinkId),
|
||||||
|
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||||
|
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||||
|
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||||
|
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId),
|
||||||
|
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
|
||||||
|
typeComposantId:
|
||||||
|
(source?.typeMachineComponentRequirement as AnyRecord)?.typeComposantId ||
|
||||||
|
source?.typeComposantId ||
|
||||||
|
(source?.typeComposant as AnyRecord)?.id ||
|
||||||
|
requirement?.typeComposantId ||
|
||||||
|
null,
|
||||||
|
definition: {
|
||||||
|
name: source?.name || source?.nom || (requirement?.typeComposant as AnyRecord)?.name || '',
|
||||||
|
reference: source?.reference || '',
|
||||||
|
constructeurIds: [] as string[],
|
||||||
|
constructeurId: null as string | null,
|
||||||
|
prix: source?.prix ?? source?.price ?? null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const defConstructeurIds = uniqueConstructeurIds(
|
||||||
|
(link?.overrides as AnyRecord)?.constructeurIds,
|
||||||
|
(link?.overrides as AnyRecord)?.constructeurId,
|
||||||
|
source?.constructeurIds,
|
||||||
|
source?.constructeurId,
|
||||||
|
source?.constructeur,
|
||||||
|
)
|
||||||
|
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
|
||||||
|
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
|
||||||
|
|
||||||
|
if (link?.overrides && isPlainObject(link.overrides)) {
|
||||||
|
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalConstructeurIds = uniqueConstructeurIds(
|
||||||
|
(entry.definition as AnyRecord).constructeurIds,
|
||||||
|
(entry.definition as AnyRecord).constructeurId,
|
||||||
|
(entry.definition as AnyRecord).constructeur,
|
||||||
|
)
|
||||||
|
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
|
||||||
|
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||||
|
const link = (source?.machinePieceLink as AnyRecord) || null
|
||||||
|
|
||||||
|
const entry: AnyRecord = {
|
||||||
|
linkId: resolveIdentifier(link?.id, source?.machinePieceLinkId, source?.linkId),
|
||||||
|
pieceId: resolveIdentifier(source?.pieceId, source?.id),
|
||||||
|
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
|
||||||
|
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId, source?.machineComponentLinkId),
|
||||||
|
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||||
|
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||||
|
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||||
|
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId, source?.composantId),
|
||||||
|
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
|
||||||
|
composantId: resolveIdentifier(source?.composantId, link?.composantId, link?.componentId),
|
||||||
|
typePieceId:
|
||||||
|
(source?.typeMachinePieceRequirement as AnyRecord)?.typePieceId ||
|
||||||
|
source?.typePieceId ||
|
||||||
|
(source?.typePiece as AnyRecord)?.id ||
|
||||||
|
requirement?.typePieceId ||
|
||||||
|
null,
|
||||||
|
definition: {
|
||||||
|
name: source?.name || source?.nom || (requirement?.typePiece as AnyRecord)?.name || '',
|
||||||
|
reference: source?.reference || '',
|
||||||
|
constructeurIds: [] as string[],
|
||||||
|
constructeurId: null as string | null,
|
||||||
|
prix: source?.prix ?? source?.price ?? null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const defConstructeurIds = uniqueConstructeurIds(
|
||||||
|
(link?.overrides as AnyRecord)?.constructeurIds,
|
||||||
|
(link?.overrides as AnyRecord)?.constructeurId,
|
||||||
|
source?.constructeurIds,
|
||||||
|
source?.constructeurId,
|
||||||
|
source?.constructeur,
|
||||||
|
)
|
||||||
|
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
|
||||||
|
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
|
||||||
|
|
||||||
|
if (link?.overrides && isPlainObject(link.overrides)) {
|
||||||
|
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalConstructeurIds = uniqueConstructeurIds(
|
||||||
|
(entry.definition as AnyRecord).constructeurIds,
|
||||||
|
(entry.definition as AnyRecord).constructeurId,
|
||||||
|
(entry.definition as AnyRecord).constructeur,
|
||||||
|
)
|
||||||
|
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
|
||||||
|
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
|
||||||
|
const link = (source?.machineProductLink as AnyRecord) || source || null
|
||||||
|
|
||||||
|
return {
|
||||||
|
linkId: resolveIdentifier(link?.id, source?.machineProductLinkId, source?.linkId),
|
||||||
|
productId: resolveIdentifier(source?.productId, link?.productId),
|
||||||
|
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
|
||||||
|
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId),
|
||||||
|
parentPieceLinkId: resolveIdentifier(link?.parentPieceLinkId, source?.parentPieceLinkId),
|
||||||
|
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
|
||||||
|
parentComponentRequirementId: resolveIdentifier(link?.parentComponentRequirementId, source?.parentComponentRequirementId, requirement?.parentComponentRequirementId),
|
||||||
|
parentPieceRequirementId: resolveIdentifier(link?.parentPieceRequirementId, source?.parentPieceRequirementId, requirement?.parentPieceRequirementId),
|
||||||
|
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
|
||||||
|
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
|
||||||
|
typeProductId: resolveIdentifier(link?.typeProductId, source?.typeProductId, requirement?.typeProductId, (requirement?.typeProduct as AnyRecord)?.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Selection CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const resetSkeletonRequirementSelections = () => {
|
||||||
|
Object.keys(componentRequirementSelections).forEach((k) => delete componentRequirementSelections[k])
|
||||||
|
Object.keys(pieceRequirementSelections).forEach((k) => delete pieceRequirementSelections[k])
|
||||||
|
Object.keys(productRequirementSelections).forEach((k) => delete productRequirementSelections[k])
|
||||||
|
}
|
||||||
|
|
||||||
|
const addComponentSelectionEntry = (requirement: AnyRecord) => {
|
||||||
|
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||||
|
const max = (requirement.maxCount as number | null) ?? null
|
||||||
|
if (max !== null && entries.length >= max) {
|
||||||
|
toast.showError(
|
||||||
|
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
componentRequirementSelections[requirement.id as string] = [
|
||||||
|
...entries,
|
||||||
|
createComponentSelectionEntry(requirement),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeComponentSelectionEntry = (requirementId: string, index: number) => {
|
||||||
|
const entries = getComponentRequirementEntries(requirementId)
|
||||||
|
componentRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setComponentRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||||
|
const entry = getComponentRequirementEntries(requirementId)[index]
|
||||||
|
if (!entry) return
|
||||||
|
entry.typeComposantId = value || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const setComponentRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
|
||||||
|
const entry = getComponentRequirementEntries(requirementId)[index]
|
||||||
|
if (!entry) return
|
||||||
|
const ids = uniqueConstructeurIds(value)
|
||||||
|
;(entry.definition as AnyRecord).constructeurIds = ids
|
||||||
|
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPieceSelectionEntry = (requirement: AnyRecord) => {
|
||||||
|
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||||
|
const max = (requirement.maxCount as number | null) ?? null
|
||||||
|
if (max !== null && entries.length >= max) {
|
||||||
|
toast.showError(
|
||||||
|
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pieceRequirementSelections[requirement.id as string] = [
|
||||||
|
...entries,
|
||||||
|
createPieceSelectionEntry(requirement),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePieceSelectionEntry = (requirementId: string, index: number) => {
|
||||||
|
const entries = getPieceRequirementEntries(requirementId)
|
||||||
|
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPieceRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||||
|
const entry = getPieceRequirementEntries(requirementId)[index]
|
||||||
|
if (!entry) return
|
||||||
|
entry.typePieceId = value || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPieceRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
|
||||||
|
const entry = getPieceRequirementEntries(requirementId)[index]
|
||||||
|
if (!entry) return
|
||||||
|
const ids = uniqueConstructeurIds(value)
|
||||||
|
;(entry.definition as AnyRecord).constructeurIds = ids
|
||||||
|
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const addProductSelectionEntry = (requirement: AnyRecord) => {
|
||||||
|
const entries = getProductRequirementEntries(requirement.id as string)
|
||||||
|
const max = (requirement.maxCount as number | null) ?? null
|
||||||
|
if (max !== null && entries.length >= max) {
|
||||||
|
toast.showError(
|
||||||
|
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
productRequirementSelections[requirement.id as string] = [
|
||||||
|
...entries,
|
||||||
|
createProductSelectionEntry(requirement),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeProductSelectionEntry = (requirementId: string, index: number) => {
|
||||||
|
const entries = getProductRequirementEntries(requirementId)
|
||||||
|
productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setProductRequirementProduct = (requirementId: string, index: number, productId: string | null) => {
|
||||||
|
const entry = getProductRequirementEntries(requirementId)[index]
|
||||||
|
if (!entry) return
|
||||||
|
const normalizedProductId = productId || null
|
||||||
|
entry.productId = normalizedProductId
|
||||||
|
if (normalizedProductId) {
|
||||||
|
const product = findProductById(normalizedProductId)
|
||||||
|
entry.typeProductId =
|
||||||
|
(product?.typeProductId as string) ||
|
||||||
|
((product?.typeProduct as AnyRecord)?.id as string) ||
|
||||||
|
(entry.typeProductId as string) ||
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setProductRequirementType = (requirementId: string, index: number, value: string | null) => {
|
||||||
|
const entry = getProductRequirementEntries(requirementId)[index]
|
||||||
|
if (!entry) return
|
||||||
|
entry.typeProductId = value || entry.typeProductId || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skeleton initialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const initializeSkeletonRequirementSelections = async () => {
|
||||||
|
skeletonEditor.loading = true
|
||||||
|
try {
|
||||||
|
resetSkeletonRequirementSelections()
|
||||||
|
const type = machineType.value as AnyRecord
|
||||||
|
if (!type) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadProducts()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des produits pour le squelette:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
;((type.componentRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||||
|
const existing = flattenedComponents.value.filter(
|
||||||
|
(c) => c.typeMachineComponentRequirementId === requirement.id,
|
||||||
|
)
|
||||||
|
const entries = existing.map((c) => createComponentSelectionEntry(requirement, c))
|
||||||
|
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||||
|
while (entries.length < min) entries.push(createComponentSelectionEntry(requirement))
|
||||||
|
if (entries.length) componentRequirementSelections[requirement.id as string] = entries
|
||||||
|
})
|
||||||
|
|
||||||
|
const allPieces = collectPiecesForSkeleton()
|
||||||
|
;((type.pieceRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||||
|
const existing = allPieces.filter(
|
||||||
|
(p) => p.typeMachinePieceRequirementId === requirement.id,
|
||||||
|
)
|
||||||
|
const entries = existing.map((p) => createPieceSelectionEntry(requirement, p))
|
||||||
|
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||||
|
while (entries.length < min) entries.push(createPieceSelectionEntry(requirement))
|
||||||
|
if (entries.length) pieceRequirementSelections[requirement.id as string] = entries
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingProductLinks = Array.isArray(machineProductLinks.value)
|
||||||
|
? machineProductLinks.value
|
||||||
|
: Array.isArray(machine.value?.productLinks)
|
||||||
|
? (machine.value.productLinks as AnyRecord[])
|
||||||
|
: []
|
||||||
|
|
||||||
|
;((type.productRequirements as AnyRecord[]) || []).forEach((requirement) => {
|
||||||
|
const matches = existingProductLinks.filter((link) => {
|
||||||
|
const reqId = resolveIdentifier(link?.typeMachineProductRequirementId, link?.requirementId)
|
||||||
|
return reqId === requirement.id
|
||||||
|
})
|
||||||
|
const entries = matches.map((link) => createProductSelectionEntry(requirement, link))
|
||||||
|
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||||
|
while (entries.length < min) entries.push(createProductSelectionEntry(requirement))
|
||||||
|
if (entries.length) productRequirementSelections[requirement.id as string] = entries
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
skeletonEditor.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Editor open/close
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const openSkeletonEditor = async () => {
|
||||||
|
if (skeletonEditor.open) return
|
||||||
|
skeletonEditor.open = true
|
||||||
|
await initializeSkeletonRequirementSelections()
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSkeletonEditor = () => {
|
||||||
|
if (!skeletonEditor.open) return
|
||||||
|
if (skeletonEditor.submitting) return
|
||||||
|
skeletonEditor.open = false
|
||||||
|
skeletonEditor.loading = false
|
||||||
|
skeletonEditor.submitting = false
|
||||||
|
resetSkeletonRequirementSelections()
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeMachineView = async (view: 'details' | 'skeleton') => {
|
||||||
|
if (view === activeMachineView.value) return
|
||||||
|
|
||||||
|
if (view === 'skeleton') {
|
||||||
|
if (!machineHasSkeletonRequirements.value) {
|
||||||
|
toast.showInfo('Aucun squelette configuré pour cette machine.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeMachineView.value = 'skeleton'
|
||||||
|
if (!skeletonEditor.open) {
|
||||||
|
try {
|
||||||
|
await openSkeletonEditor()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Impossible d'ouvrir l'éditeur de squelette:", error)
|
||||||
|
toast.showError('Impossible de charger les éléments du squelette.')
|
||||||
|
activeMachineView.value = 'details'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSkeletonEditor()
|
||||||
|
activeMachineView.value = 'details'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Validation & save
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const computeSkeletonProductUsage = (type: AnyRecord): Map<string, number> => {
|
||||||
|
const usage = new Map<string, number>()
|
||||||
|
|
||||||
|
const increment = (typeProductId: string | null) => {
|
||||||
|
if (!typeProductId) return
|
||||||
|
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
|
||||||
|
getComponentRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||||
|
if (!entry?.composantId) return
|
||||||
|
const component = findComponentById(components.value, entry.composantId as string)
|
||||||
|
const typeProductId =
|
||||||
|
((component?.product as AnyRecord)?.typeProductId as string) ||
|
||||||
|
(((component?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
|
||||||
|
null
|
||||||
|
increment(typeProductId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
|
||||||
|
getPieceRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||||
|
if (!entry?.pieceId) return
|
||||||
|
const piece = findPieceById(entry.pieceId as string)
|
||||||
|
const typeProductId =
|
||||||
|
((piece?.product as AnyRecord)?.typeProductId as string) ||
|
||||||
|
(((piece?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
|
||||||
|
null
|
||||||
|
increment(typeProductId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
|
||||||
|
getProductRequirementEntries(requirement.id as string).forEach((entry) => {
|
||||||
|
if (!entry?.productId) return
|
||||||
|
const product = findProductById(entry.productId as string)
|
||||||
|
const typeProductId =
|
||||||
|
((product?.typeProductId as string) ||
|
||||||
|
((product?.typeProduct as AnyRecord)?.id as string) ||
|
||||||
|
(entry?.typeProductId as string) ||
|
||||||
|
(requirement?.typeProductId as string) ||
|
||||||
|
((requirement?.typeProduct as AnyRecord)?.id as string) ||
|
||||||
|
null)
|
||||||
|
increment(typeProductId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateSkeletonSelections = (type: AnyRecord) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
const componentLinksPayload: AnyRecord[] = []
|
||||||
|
const pieceLinksPayload: AnyRecord[] = []
|
||||||
|
const productLinksPayload: AnyRecord[] = []
|
||||||
|
|
||||||
|
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
|
||||||
|
const entries = getComponentRequirementEntries(requirement.id as string)
|
||||||
|
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||||
|
const max = (requirement.maxCount as number | null) ?? null
|
||||||
|
|
||||||
|
if (entries.length < min) {
|
||||||
|
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`)
|
||||||
|
}
|
||||||
|
if (max !== null && entries.length > max) {
|
||||||
|
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const resolvedTypeId = (entry.typeComposantId || requirement.typeComposantId || null) as string | null
|
||||||
|
if (!resolvedTypeId) {
|
||||||
|
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite un type de composant.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload: AnyRecord = { requirementId: requirement.id, typeComposantId: resolvedTypeId }
|
||||||
|
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||||
|
if (entry.composantId) payload.composantId = entry.composantId
|
||||||
|
const overrides = sanitizeDefinitionOverrides(entry.definition)
|
||||||
|
if (overrides) payload.overrides = overrides
|
||||||
|
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||||
|
componentLinksPayload.push(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
|
||||||
|
const entries = getPieceRequirementEntries(requirement.id as string)
|
||||||
|
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||||
|
const max = (requirement.maxCount as number | null) ?? null
|
||||||
|
|
||||||
|
if (entries.length < min) {
|
||||||
|
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`)
|
||||||
|
}
|
||||||
|
if (max !== null && entries.length > max) {
|
||||||
|
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const resolvedTypeId = (entry.typePieceId || requirement.typePieceId || null) as string | null
|
||||||
|
if (!resolvedTypeId) {
|
||||||
|
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite un type de pièce.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload: AnyRecord = { requirementId: requirement.id, typePieceId: resolvedTypeId }
|
||||||
|
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||||
|
if (entry.pieceId) payload.pieceId = entry.pieceId
|
||||||
|
if (entry.composantId) payload.composantId = entry.composantId
|
||||||
|
const overrides = sanitizeDefinitionOverrides(entry.definition)
|
||||||
|
if (overrides) payload.overrides = overrides
|
||||||
|
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||||
|
pieceLinksPayload.push(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const productUsage = computeSkeletonProductUsage(type)
|
||||||
|
|
||||||
|
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
|
||||||
|
const entries = getProductRequirementEntries(requirement.id as string)
|
||||||
|
const max = (requirement.maxCount as number | null) ?? null
|
||||||
|
if (max !== null && entries.length > max) {
|
||||||
|
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s) directe(s).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeProductId = (requirement.typeProductId as string) || ((requirement.typeProduct as AnyRecord)?.id as string) || null
|
||||||
|
const count = typeProductId ? productUsage.get(typeProductId) ?? 0 : 0
|
||||||
|
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
|
||||||
|
|
||||||
|
if (count < min) {
|
||||||
|
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" nécessite au moins ${min} sélection(s).`)
|
||||||
|
}
|
||||||
|
if (max !== null && count > max) {
|
||||||
|
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.productId) {
|
||||||
|
errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const product = findProductById(entry.productId as string)
|
||||||
|
if (!product) {
|
||||||
|
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const productTypeId =
|
||||||
|
(product.typeProductId as string) ||
|
||||||
|
((product.typeProduct as AnyRecord)?.id as string) ||
|
||||||
|
(entry.typeProductId as string) ||
|
||||||
|
null
|
||||||
|
if (typeProductId && productTypeId && productTypeId !== typeProductId) {
|
||||||
|
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId }
|
||||||
|
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
|
||||||
|
if (entry.typeProductId) payload.typeProductId = entry.typeProductId
|
||||||
|
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
|
||||||
|
productLinksPayload.push(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) return { valid: false as const, error: errors[0] }
|
||||||
|
return {
|
||||||
|
valid: true as const,
|
||||||
|
componentLinks: componentLinksPayload,
|
||||||
|
pieceLinks: pieceLinksPayload,
|
||||||
|
productLinks: productLinksPayload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Apply reconfiguration result
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const applySkeletonReconfigurationResult = async (data: AnyRecord) => {
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
const updatedMachine = (data.machine as AnyRecord) || data
|
||||||
|
if (updatedMachine) {
|
||||||
|
machine.value = {
|
||||||
|
...machine.value,
|
||||||
|
...updatedMachine,
|
||||||
|
documents: (updatedMachine.documents as AnyRecord[]) || (machine.value?.documents as AnyRecord[]) || [],
|
||||||
|
}
|
||||||
|
initMachineFields()
|
||||||
|
machineDocumentsLoaded.value = !!((machine.value!.documents as AnyRecord[])?.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const linksApplied = applyMachineLinks(data) || applyMachineLinks(updatedMachine)
|
||||||
|
if (linksApplied) {
|
||||||
|
if (machine.value) {
|
||||||
|
machine.value.componentLinks = machineComponentLinks.value
|
||||||
|
machine.value.pieceLinks = machinePieceLinks.value
|
||||||
|
machine.value.productLinks = machineProductLinks.value
|
||||||
|
}
|
||||||
|
collapseAllComponents()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newComponents = (data.components ?? updatedMachine?.components ?? null) as AnyRecord[] | null
|
||||||
|
if (Array.isArray(newComponents)) {
|
||||||
|
components.value = transformComponentCustomFields(newComponents)
|
||||||
|
collapseAllComponents()
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPieces = (data.pieces ?? updatedMachine?.pieces ?? null) as AnyRecord[] | null
|
||||||
|
if (Array.isArray(newPieces)) {
|
||||||
|
pieces.value = transformCustomFields(newPieces)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prodLinks =
|
||||||
|
resolveLinkArray(data, ['productLinks', 'machineProductLinks']) ??
|
||||||
|
resolveLinkArray(updatedMachine, ['productLinks', 'machineProductLinks'])
|
||||||
|
if (Array.isArray(prodLinks)) {
|
||||||
|
machineProductLinks.value = prodLinks as AnyRecord[]
|
||||||
|
if (machine.value) machine.value.productLinks = prodLinks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Save
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const saveSkeletonConfiguration = async () => {
|
||||||
|
if (!machine.value?.id) return
|
||||||
|
|
||||||
|
const type = machineType.value as AnyRecord
|
||||||
|
let payload: AnyRecord = { componentLinks: [], pieceLinks: [], productLinks: [] }
|
||||||
|
|
||||||
|
if (type && machineHasSkeletonRequirements.value) {
|
||||||
|
const validation = validateSkeletonSelections(type)
|
||||||
|
if (!validation.valid) {
|
||||||
|
toast.showError((validation as AnyRecord).error as string)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
componentLinks: (validation as AnyRecord).componentLinks,
|
||||||
|
pieceLinks: (validation as AnyRecord).pieceLinks,
|
||||||
|
productLinks: (validation as AnyRecord).productLinks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skeletonEditor.submitting = true
|
||||||
|
try {
|
||||||
|
const result = await reconfigureMachineSkeleton(machine.value.id as string, payload)
|
||||||
|
if ((result as AnyRecord).success) {
|
||||||
|
await applySkeletonReconfigurationResult((result as AnyRecord).data as AnyRecord)
|
||||||
|
await changeMachineView('details')
|
||||||
|
} else if ((result as AnyRecord).error) {
|
||||||
|
toast.showError((result as AnyRecord).error as string)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error)
|
||||||
|
toast.showError('Erreur lors de la mise à jour des éléments du squelette')
|
||||||
|
} finally {
|
||||||
|
skeletonEditor.submitting = false
|
||||||
|
skeletonEditor.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// View state
|
||||||
|
activeMachineView,
|
||||||
|
isDetailsView,
|
||||||
|
isSkeletonView,
|
||||||
|
|
||||||
|
// Editor state
|
||||||
|
skeletonEditor,
|
||||||
|
componentRequirementSelections,
|
||||||
|
pieceRequirementSelections,
|
||||||
|
productRequirementSelections,
|
||||||
|
|
||||||
|
// Entry getters
|
||||||
|
getComponentRequirementEntries,
|
||||||
|
getPieceRequirementEntries,
|
||||||
|
getProductRequirementEntries,
|
||||||
|
|
||||||
|
// Label resolvers
|
||||||
|
resolveComponentRequirementTypeLabel,
|
||||||
|
resolvePieceRequirementTypeLabel,
|
||||||
|
resolveProductRequirementTypeLabel,
|
||||||
|
getProductOptionsForRequirement,
|
||||||
|
|
||||||
|
// Selection CRUD
|
||||||
|
addComponentSelectionEntry,
|
||||||
|
removeComponentSelectionEntry,
|
||||||
|
setComponentRequirementType,
|
||||||
|
setComponentRequirementConstructeur,
|
||||||
|
addPieceSelectionEntry,
|
||||||
|
removePieceSelectionEntry,
|
||||||
|
setPieceRequirementType,
|
||||||
|
setPieceRequirementConstructeur,
|
||||||
|
addProductSelectionEntry,
|
||||||
|
removeProductSelectionEntry,
|
||||||
|
setProductRequirementProduct,
|
||||||
|
setProductRequirementType,
|
||||||
|
|
||||||
|
// Editor lifecycle
|
||||||
|
openSkeletonEditor,
|
||||||
|
closeSkeletonEditor,
|
||||||
|
changeMachineView,
|
||||||
|
initializeSkeletonRequirementSelections,
|
||||||
|
|
||||||
|
// Validation & save
|
||||||
|
validateSkeletonSelections,
|
||||||
|
saveSkeletonConfiguration,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user