refactor(machines) : remove TypeMachine skeleton system, simplify machine creation

- Remove TypeEdit*, TypeInfoDisplay, MachineSkeletonSummary, MachineCreatePreview components
- Remove machine-skeleton pages and type pages
- Remove useMachineTypesApi, useMachineSkeletonEditor, useMachineCreateSelections composables
- Add AddEntityToMachineModal for direct entity linking
- Update machine detail/create pages for direct custom fields
- Fix SearchSelect, category display, and ipartial search filters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-05 17:25:23 +01:00
parent 6f1bac381d
commit 32d03b480d
49 changed files with 1058 additions and 6093 deletions

View File

@@ -26,12 +26,6 @@
{{ component.name }}
</h3>
<div class="flex flex-wrap gap-2 mt-2">
<span
v-if="component.skeletonOnly"
class="badge badge-warning badge-sm"
>
Défini dans le catalogue
</span>
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span>
<template v-if="componentConstructeursDisplay.length">
<span
@@ -49,12 +43,6 @@
>
Produit&nbsp;: {{ displayProductName }}
</span>
<span
v-if="component.typeMachineComponentRequirement"
class="badge badge-outline badge-sm"
>
Groupe : {{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Non défini' }}
</span>
</div>
</div>
</div>
@@ -401,8 +389,7 @@
v-for="piece in component.pieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode && !piece.skeletonOnly"
:is-edit-mode="isEditMode"
@update="updatePiece"
@edit="editPiece"
@custom-field-update="updatePieceCustomField"
@@ -420,7 +407,7 @@
v-for="subComponent in childComponents"
:key="subComponent.id"
:component="subComponent"
:is-edit-mode="isEditMode && !subComponent.skeletonOnly"
:is-edit-mode="isEditMode"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
@update="$emit('update', $event)"

View File

@@ -1,5 +1,5 @@
<template>
<div class="border border-gray-200 rounded-lg p-4">
<div class="space-y-4">
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
@@ -7,58 +7,53 @@
@close="closePreview"
/>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<IconLucidePackage class="w-4 h-4 text-purple-500" aria-hidden="true" />
<input
v-if="isEditMode"
:id="`piece-name-${piece.id}`"
v-model="pieceData.name"
type="text"
class="font-semibold text-lg input input-sm input-bordered"
@blur="updatePiece"
/>
<div
v-else
class="font-semibold text-lg input input-sm input-bordered bg-base-200"
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
<div class="flex items-start gap-3 w-full">
<button
type="button"
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
:class="{ 'rotate-90': !isCollapsed }"
:aria-expanded="!isCollapsed"
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
@click="toggleCollapse"
>
{{ pieceData.name }}
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
</button>
<div class="flex-1">
<h3 class="text-lg font-semibold">
{{ pieceData.name }}
</h3>
<div class="flex flex-wrap gap-2 mt-2">
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
Rattachée à {{ piece.parentComponentName }}
</span>
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
<template v-if="pieceConstructeursDisplay.length">
<span
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="badge badge-outline badge-sm"
>
{{ constructeur.name }}
</span>
</template>
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span
v-if="piece.skeletonOnly"
class="badge badge-warning badge-sm"
>
Défini dans le catalogue
</span>
<span
v-if="piece.typeMachinePieceRequirement"
class="badge badge-outline badge-sm"
>
Groupe :
{{
piece.typeMachinePieceRequirement.label ||
piece.typeMachinePieceRequirement.typePiece?.name ||
"Non défini"
}}
</span>
<span
v-if="piece.parentComponentName"
class="badge badge-ghost badge-sm"
>
Rattachée à {{ piece.parentComponentName }}
</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div>
</div>
<div class="space-y-2 text-sm">
<div v-show="!isCollapsed" class="space-y-4">
<div class="p-4 bg-base-100 border border-gray-200 rounded-lg">
<div class="space-y-2 text-sm">
<div>
<span class="font-medium">Référence:</span>
<input
@@ -236,6 +231,7 @@
</div>
</div>
</div>
</div>
<!-- Champs personnalisés de la pièce -->
<div
@@ -480,16 +476,17 @@
Aucun document lié à cette pièce.
</p>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted, watch, computed } from 'vue'
import { reactive, ref, onMounted, watch, computed } from 'vue'
import ConstructeurSelect from './ConstructeurSelect.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucidePackage from '~icons/lucide/package'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProducts } from '~/composables/useProducts'
@@ -523,6 +520,8 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({
piece: { type: Object, required: true },
isEditMode: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit', 'custom-field-update'])
@@ -576,6 +575,23 @@ const {
updateCustomField,
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
// --- Collapse state ---
const isCollapsed = ref(true)
watch(
() => props.toggleToken,
() => {
isCollapsed.value = props.collapseAll
if (!isCollapsed.value) refreshDocuments()
},
{ immediate: true },
)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
if (!isCollapsed.value) refreshDocuments()
}
// --- Constructeurs ---
const { constructeurs } = useConstructeurs()

View File

@@ -1,34 +0,0 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="card-actions justify-end">
<button type="button" class="btn btn-outline" @click="$emit('reset')">
Réinitialiser
</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
<IconLucideRefreshCw
v-if="saving"
class="w-5 h-5 mr-2 animate-spin"
aria-hidden="true"
/>
<IconLucideCheck v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ saving ? 'Sauvegarde...' : 'Sauvegarder les modifications' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import IconLucideRefreshCw from '~icons/lucide/refresh-cw'
import IconLucideCheck from '~icons/lucide/check'
defineProps({
saving: {
type: Boolean,
default: false
}
})
defineEmits(['reset'])
</script>

View File

@@ -1,105 +0,0 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h3 class="card-title text-lg mb-4">
Informations de base
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du type</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
v-model="nameModel"
type="text"
placeholder="Nom du type de machine"
class="input input-bordered"
required
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie</span>
</label>
<input
v-model="categoryModel"
type="text"
placeholder="Catégorie du type"
class="input input-bordered"
>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="descriptionModel"
placeholder="Description du type de machine"
class="textarea textarea-bordered h-24"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fréquence de maintenance</span>
</label>
<input
v-model="maintenanceModel"
type="text"
placeholder="ex: Mensuelle, Trimestrielle"
class="input input-bordered"
>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
name: {
type: String,
default: ''
},
category: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
maintenanceFrequency: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:name', 'update:category', 'update:description', 'update:maintenanceFrequency'])
const nameModel = computed({
get: () => props.name,
set: value => emit('update:name', value)
})
const categoryModel = computed({
get: () => props.category,
set: value => emit('update:category', value)
})
const descriptionModel = computed({
get: () => props.description,
set: value => emit('update:description', value)
})
const maintenanceModel = computed({
get: () => props.maintenanceFrequency,
set: value => emit('update:maintenanceFrequency', value)
})
</script>

View File

@@ -1,95 +0,0 @@
<template>
<RequirementListEditor
v-model="requirements"
:type-options="componentTypes"
type-field="typeComposantId"
:labels="labels"
:default-requirement="createDefaultRequirement"
:required-fallback="true"
:min-fallback="1"
:type-loading="loadingComponentTypes"
/>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
type Requirement = Record<string, unknown> & {
id?: string | number
typeComposantId?: string | number | null
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
}
type Labels = {
headerTitle: string
addButton: string
description: string
emptyState: string
typeSelectLabel: string
typePlaceholder: string
labelFieldLabel: string
labelFieldHelper: string
labelPlaceholder: string
minLabel: string
maxLabel: string
maxHelper: string
requiredLabel: string
allowNewModelsLabel: string
}
const props = defineProps({
modelValue: {
type: Array as () => Requirement[],
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
const requirements = computed({
get: () => props.modelValue,
set: (value: Requirement[]) => emit('update:modelValue', value),
})
const createDefaultRequirement = (): Requirement => ({
id: undefined,
typeComposantId: null,
label: '',
minCount: 1,
maxCount: null,
required: true,
allowNewModels: true,
})
const labels: Labels = {
headerTitle: 'Familles de composants',
addButton: 'Ajouter une famille',
description:
"Chaque ligne correspond à un groupe de composants attendus pour le type de machine. Sélectionnez le type de composant (famille), puis définissez le nombre minimal/maximal et si l'utilisateur peut créer un nouveau modèle lors de l'instanciation d'une machine.",
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de composants.',
typeSelectLabel: 'Type de composant',
typePlaceholder: 'Sélectionner un type',
labelFieldLabel: 'Libellé',
labelFieldHelper: 'Optionnel',
labelPlaceholder: 'Ex: Sangles principales',
minLabel: 'Minimum requis',
maxLabel: 'Maximum autorisé',
maxHelper: 'Laisser vide pour illimité',
requiredLabel: 'Requis',
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
}
onMounted(async () => {
if (!componentTypes.value.length) {
await loadComponentTypes()
}
})
</script>

View File

@@ -1,356 +0,0 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-sm p-1"
@click="toggleSection"
>
<IconLucideChevronRight
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-90': expanded }"
aria-hidden="true"
/>
</button>
<h3 class="card-title text-lg">
Champs personnalisés du type
</h3>
<span class="badge badge-primary">{{ fields.length }}</span>
</div>
</div>
<div v-if="expanded" class="space-y-4">
<div
v-for="(field, fieldIndex) in fields"
:key="field.id || field.customFieldId || field.__key || `field-${fieldIndex}`"
class="border border-gray-200 rounded-lg p-4 bg-gray-50 transition-colors"
:class="fieldReorderClass(fieldIndex)"
draggable="true"
@dragstart="onFieldDragStart(fieldIndex, $event)"
@dragenter="onFieldDragEnter(fieldIndex)"
@dragover.prevent
@drop="onFieldDrop(fieldIndex)"
@dragend="onFieldDragEnd"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
title="Réorganiser"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<button
type="button"
class="btn btn-ghost btn-xs p-1"
title="Plier / déplier le champ"
@click="toggleField(fieldIndex)"
>
<IconLucideChevronRight
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-90': isFieldExpanded(fieldIndex) }"
aria-hidden="true"
/>
</button>
<IconLucideListChecks class="w-4 h-4 text-blue-500" aria-hidden="true" />
<h5 class="text-sm font-medium">
Champ personnalisé {{ fieldIndex + 1 }}
</h5>
<span v-if="!isFieldExpanded(fieldIndex)" class="text-xs text-gray-500 truncate max-w-[160px]">
{{ field.name || 'Sans nom' }}
</span>
</div>
<button
type="button"
class="btn btn-square btn-error btn-sm"
title="Supprimer ce champ"
@click="removeField(fieldIndex)"
>
<IconLucideX class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div v-if="isFieldExpanded(fieldIndex)" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du champ</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
:value="field.name"
type="text"
placeholder="Nom du champ"
class="input input-bordered input-sm"
required
@input="updateField(fieldIndex, { name: $event.target.value })"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Type de champ</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
class="select select-bordered select-sm"
required
:value="field.type"
@change="updateField(fieldIndex, { type: $event.target.value })"
>
<option value="">
Sélectionner un type
</option>
<option value="text">
Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste déroulante
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
</div>
<div v-if="isFieldExpanded(fieldIndex)" class="mt-3">
<div class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="field.required"
@change="updateField(fieldIndex, { required: $event.target.checked })"
>
<span class="text-sm">Champ obligatoire</span>
</div>
</div>
<div
v-if="isFieldExpanded(fieldIndex) && field.type === 'select'"
class="mt-3"
>
<label class="label">
<span class="label-text">Options de la liste</span>
<span class="label-text-alt">Une option par ligne</span>
</label>
<textarea
:value="field.optionsText || ''"
placeholder="Option 1&#10;Option 2&#10;Option 3"
class="textarea textarea-bordered textarea-sm w-full h-20"
@input="updateOptions(fieldIndex, $event.target.value)"
/>
</div>
</div>
<div class="flex justify-end">
<button type="button" class="btn btn-primary btn-sm" @click="addField">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter un champ
</button>
</div>
</div>
<div v-else class="flex justify-end">
<button type="button" class="btn btn-primary btn-sm" @click="addField">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter un champ
</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideListChecks from '~icons/lucide/list-checks'
import IconLucideX from '~icons/lucide/x'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
allExpanded: {
type: Boolean,
default: false
},
expandAllTrigger: {
type: Number,
default: 0
}
})
const emit = defineEmits(['update:modelValue'])
const fields = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
const expanded = ref(false)
const expandedFields = ref([])
const draggingFieldIndex = ref(null)
const fieldDropTargetIndex = ref(null)
const applyOrderIndex = (list = []) => {
if (!Array.isArray(list)) { return [] }
list.forEach((field, index) => {
if (field && typeof field === 'object') {
field.orderIndex = index
if (typeof field.__key !== 'string' || !field.__key) {
field.__key = createFieldKey()
}
}
})
return list
}
const createEmptyField = () => ({
name: '',
type: '',
required: false,
optionsText: '',
orderIndex: fields.value.length,
__key: createFieldKey()
})
const resetDragState = () => {
draggingFieldIndex.value = null
fieldDropTargetIndex.value = null
}
const reorderFields = (from, to) => {
const list = Array.isArray(fields.value) ? fields.value.slice() : []
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
list.splice(to, 0, moved)
if (Array.isArray(expandedFields.value)) {
const expandedCopy = expandedFields.value.slice()
const [expandedState] = expandedCopy.splice(from, 1)
expandedCopy.splice(to, 0, expandedState)
expandedFields.value = expandedCopy
}
fields.value = applyOrderIndex(list)
resetDragState()
}
watch(
() => props.expandAllTrigger,
() => {
expanded.value = props.allExpanded
expandedFields.value = fields.value.map(() => props.allExpanded)
},
{ immediate: true }
)
watch(
() => fields.value.length,
(length) => {
expandedFields.value = Array.from({ length }, (_, index) => expandedFields.value[index] ?? props.allExpanded)
}
)
const toggleSection = () => {
expanded.value = !expanded.value
}
const ensureFieldState = (index) => {
if (expandedFields.value[index] === undefined) {
expandedFields.value[index] = props.allExpanded
}
}
const isFieldExpanded = (index) => {
ensureFieldState(index)
return expandedFields.value[index]
}
const toggleField = (index) => {
ensureFieldState(index)
expandedFields.value[index] = !expandedFields.value[index]
}
const addField = () => {
const next = Array.isArray(fields.value) ? fields.value.slice() : []
next.push(createEmptyField())
fields.value = applyOrderIndex(next)
expandedFields.value.push(true)
expanded.value = true
}
const removeField = (index) => {
const next = Array.isArray(fields.value)
? fields.value.filter((_, i) => i !== index)
: []
fields.value = applyOrderIndex(next)
expandedFields.value.splice(index, 1)
}
const updateField = (index, patch) => {
const next = Array.isArray(fields.value) ? fields.value.slice() : []
next[index] = { ...next[index], ...patch }
fields.value = applyOrderIndex(next)
}
const updateOptions = (index, value) => {
updateField(index, {
optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
})
}
const onFieldDragStart = (index, event) => {
draggingFieldIndex.value = index
fieldDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onFieldDragEnter = (index) => {
if (draggingFieldIndex.value === null) { return }
fieldDropTargetIndex.value = index
}
const onFieldDrop = (index) => {
if (draggingFieldIndex.value === null) {
resetDragState()
return
}
reorderFields(draggingFieldIndex.value, index)
}
const onFieldDragEnd = () => {
resetDragState()
}
const fieldReorderClass = (index) => {
if (draggingFieldIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingFieldIndex.value !== null &&
fieldDropTargetIndex.value === index &&
draggingFieldIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
</script>

View File

@@ -1,161 +0,0 @@
<template>
<form class="space-y-6" @submit.prevent="handleSubmit">
<TypeEditToolbar :all-expanded="allExpanded" @toggle="toggleAllSections" />
<TypeEditBaseInfoSection
v-model:name="formData.name"
v-model:category="formData.category"
v-model:description="formData.description"
v-model:maintenance-frequency="formData.maintenanceFrequency"
/>
<TypeEditCustomFieldsSection
:model-value="formData.customFields"
:all-expanded="allExpanded"
:expand-all-trigger="expandAllTrigger"
@update:model-value="(value) => (formData.customFields = value)"
/>
<TypeEditComponentRequirementsSection
:model-value="formData.componentRequirements"
@update:model-value="(value) => (formData.componentRequirements = value)"
/>
<TypeEditPieceRequirementsSection
:model-value="formData.pieceRequirements"
@update:model-value="(value) => (formData.pieceRequirements = value)"
/>
<TypeEditProductRequirementsSection
:model-value="formData.productRequirements"
@update:model-value="(value) => (formData.productRequirements = value)"
/>
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
</form>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import TypeEditActionsBar from '~/components/TypeEditActionsBar.vue'
import TypeEditBaseInfoSection from '~/components/TypeEditBaseInfoSection.vue'
import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSection.vue'
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
import TypeEditProductRequirementsSection from '~/components/TypeEditProductRequirementsSection.vue'
const props = defineProps({
modelValue: {
type: Object,
required: true
},
saving: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'submit'])
const deepClone = value => JSON.parse(JSON.stringify(value))
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
const normalizeCustomField = (field = {}, index = 0) => {
const clone = deepClone(field)
if (clone.type === 'select') {
if (typeof clone.optionsText !== 'string' || !clone.optionsText.length) {
if (Array.isArray(clone.options)) {
clone.optionsText = clone.options.map(option => String(option).trim()).filter(Boolean).join('\n')
} else {
clone.optionsText = ''
}
}
}
const currentOrder =
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
clone.orderIndex = currentOrder
if (typeof clone?.__key !== 'string' || !clone.__key) {
clone.__key = createFieldKey()
}
return clone
}
const withNormalizedOrder = (items = []) => {
if (!Array.isArray(items)) { return [] }
return items
.map((item, index) => normalizeCustomField(item, index))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((item, index) => ({ ...item, orderIndex: index }))
}
const createDefaultForm = (source = {}) => ({
name: source.name || '',
description: source.description || '',
category: source.category || '',
maintenanceFrequency: source.maintenanceFrequency || '',
customFields: withNormalizedOrder(source.customFields || []),
componentRequirements: withNormalizedOrder(source.componentRequirements || []),
pieceRequirements: withNormalizedOrder(source.pieceRequirements || []),
productRequirements: withNormalizedOrder(source.productRequirements || []),
})
const formData = reactive(createDefaultForm(props.modelValue))
const allExpanded = ref(false)
const expandAllTrigger = ref(0)
let syncingFromParent = false
const toPlainObject = value => JSON.parse(JSON.stringify(value))
const lastSnapshot = ref(toPlainObject(createDefaultForm(props.modelValue)))
watch(
() => props.modelValue,
(value) => {
const normalized = createDefaultForm(value)
if (JSON.stringify(normalized) === JSON.stringify(lastSnapshot.value)) {
return
}
syncingFromParent = true
Object.assign(formData, normalized)
lastSnapshot.value = toPlainObject(normalized)
syncingFromParent = false
},
{ deep: true }
)
watch(
formData,
(value) => {
if (syncingFromParent) { return }
const normalized = createDefaultForm(value)
if (JSON.stringify(normalized) === JSON.stringify(lastSnapshot.value)) {
return
}
lastSnapshot.value = toPlainObject(normalized)
emit('update:modelValue', normalized)
},
{ deep: true }
)
const toggleAllSections = () => {
allExpanded.value = !allExpanded.value
expandAllTrigger.value += 1
}
const resetForm = () => {
const normalized = createDefaultForm(props.modelValue)
syncingFromParent = true
Object.assign(formData, normalized)
lastSnapshot.value = toPlainObject(normalized)
syncingFromParent = false
allExpanded.value = false
expandAllTrigger.value += 1
}
const handleSubmit = () => {
emit('submit')
}
</script>

View File

@@ -1,95 +0,0 @@
<template>
<RequirementListEditor
v-model="requirements"
:type-options="pieceTypes"
type-field="typePieceId"
:labels="labels"
:default-requirement="createDefaultRequirement"
:required-fallback="false"
:min-fallback="0"
:type-loading="loadingPieceTypes"
/>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
type Requirement = Record<string, unknown> & {
id?: string | number
typePieceId?: string | number | null
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
}
type Labels = {
headerTitle: string
addButton: string
description: string
emptyState: string
typeSelectLabel: string
typePlaceholder: string
labelFieldLabel: string
labelFieldHelper: string
labelPlaceholder: string
minLabel: string
maxLabel: string
maxHelper: string
requiredLabel: string
allowNewModelsLabel: string
}
const props = defineProps({
modelValue: {
type: Array as () => Requirement[],
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
const requirements = computed({
get: () => props.modelValue,
set: (value: Requirement[]) => emit('update:modelValue', value),
})
const createDefaultRequirement = (): Requirement => ({
id: undefined,
typePieceId: null,
label: '',
minCount: 0,
maxCount: null,
required: false,
allowNewModels: true,
})
const labels: Labels = {
headerTitle: 'Pièces principales',
addButton: 'Ajouter un groupe',
description:
"Configurez ici les familles de pièces principales attendues pour ce type de machine. Le nombre minimal/maximal est utilisé pour guider la création d'une machine.",
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de pièces.',
typeSelectLabel: 'Type de pièce',
typePlaceholder: 'Sélectionner un type',
labelFieldLabel: 'Libellé',
labelFieldHelper: 'Optionnel',
labelPlaceholder: 'Ex: Vis principale',
minLabel: 'Minimum requis',
maxLabel: 'Maximum autorisé',
maxHelper: 'Laisser vide pour illimité',
requiredLabel: 'Requis',
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
}
onMounted(async () => {
if (!pieceTypes.value.length) {
await loadPieceTypes()
}
})
</script>

View File

@@ -1,95 +0,0 @@
<template>
<RequirementListEditor
v-model="requirements"
:type-options="productTypes"
type-field="typeProductId"
:labels="labels"
:default-requirement="createDefaultRequirement"
:required-fallback="false"
:min-fallback="0"
:type-loading="loadingProductTypes"
/>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
import { useProductTypes } from '~/composables/useProductTypes'
type Requirement = Record<string, unknown> & {
id?: string | number
typeProductId?: string | number | null
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
}
type Labels = {
headerTitle: string
addButton: string
description: string
emptyState: string
typeSelectLabel: string
typePlaceholder: string
labelFieldLabel: string
labelFieldHelper: string
labelPlaceholder: string
minLabel: string
maxLabel: string
maxHelper: string
requiredLabel: string
allowNewModelsLabel: string
}
const props = defineProps({
modelValue: {
type: Array as () => Requirement[],
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
const requirements = computed({
get: () => props.modelValue,
set: (value: Requirement[]) => emit('update:modelValue', value),
})
const createDefaultRequirement = (): Requirement => ({
id: undefined,
typeProductId: null,
label: '',
minCount: 0,
maxCount: null,
required: false,
allowNewModels: true,
})
const labels: Labels = {
headerTitle: 'Produits requis',
addButton: 'Ajouter un produit',
description:
"Définissez les produits catalogue attendus pour ce type de machine. Sélectionnez la catégorie de produit, précisez les quantités minimales et maximales, puis indiquez si de nouveaux produits peuvent être créés à l'usage.",
emptyState: 'Aucun produit requis configuré pour le moment.',
typeSelectLabel: 'Catégorie de produit',
typePlaceholder: 'Sélectionner une catégorie',
labelFieldLabel: 'Libellé',
labelFieldHelper: 'Optionnel',
labelPlaceholder: 'Ex : Lubrifiant recommandé',
minLabel: 'Minimum requis',
maxLabel: 'Maximum autorisé',
maxHelper: 'Laisser vide pour illimité',
requiredLabel: 'Requis',
allowNewModelsLabel: "Autoriser la création de nouveaux produits lors de l'instanciation",
}
onMounted(async () => {
if (!productTypes.value.length) {
await loadProductTypes()
}
})
</script>

View File

@@ -1,23 +0,0 @@
<template>
<div class="flex justify-end">
<button type="button" class="btn btn-outline btn-sm" @click="$emit('toggle')">
<IconLucideMinus v-if="allExpanded" class="w-4 h-4 mr-2" aria-hidden="true" />
<IconLucidePlus v-else class="w-4 h-4 mr-2" aria-hidden="true" />
{{ allExpanded ? 'Tout plier' : 'Tout déplier' }}
</button>
</div>
</template>
<script setup>
import IconLucideMinus from '~icons/lucide/minus'
import IconLucidePlus from '~icons/lucide/plus'
defineProps({
allExpanded: {
type: Boolean,
default: false
}
})
defineEmits(['toggle'])
</script>

View File

@@ -1,28 +0,0 @@
<template>
<div class="alert alert-info mb-6">
<div>
<h3 class="font-bold">
Type existant
</h3>
<div class="text-sm">
<p><strong>Catégorie:</strong> {{ type.category || 'Non définie' }}</p>
<p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p>
<p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p>
<p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p>
<p><strong>Produits requis:</strong> {{ type.productRequirements?.length || 0 }}</p>
<p v-if="type.description">
<strong>Description:</strong> {{ type.description }}
</p>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
type: {
type: Object,
required: true
}
})
</script>

View File

@@ -18,6 +18,15 @@
@keydown.enter.prevent="selectHighlighted"
@input="handleInput"
>
<button
v-if="clearable && modelValue"
type="button"
class="absolute top-1/2 -translate-y-1/2 right-8 btn btn-ghost btn-xs"
aria-label="Effacer la sélection"
@click.stop="clearSelection"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
<button
type="button"
:class="toggleButtonClasses"
@@ -77,6 +86,7 @@
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({
modelValue: {
@@ -111,6 +121,10 @@ const props = defineProps({
type: [String, Function],
default: null
},
clearable: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'md',
@@ -155,7 +169,8 @@ const displayedOptions = computed(() => {
})
const inputClasses = computed(() => {
const base = ['input', 'input-bordered', 'w-full', 'pr-10']
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
const base = ['input', 'input-bordered', 'w-full', pr]
if (props.size === 'xs') base.push('input-xs')
if (props.size === 'sm') base.push('input-sm')
if (props.size === 'lg') base.push('input-lg')
@@ -269,9 +284,17 @@ function handleInput () {
emit('search', searchTerm.value)
}
function clearSelection () {
emit('update:modelValue', '')
searchTerm.value = ''
openDropdown.value = false
}
function closeDropdown () {
openDropdown.value = false
if (selectedOption.value) {
if (searchTerm.value.trim() === '' && selectedOption.value) {
emit('update:modelValue', '')
} else if (selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
}

View File

@@ -251,7 +251,7 @@ import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideLogOut from '~icons/lucide/log-out'
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
import IconLucideCpu from '~icons/lucide/cpu'
import IconLucidePuzzle from '~icons/lucide/puzzle'
import IconLucidePackage from '~icons/lucide/package'
@@ -280,7 +280,6 @@ interface NavGroup {
const simpleLinks: NavLink[] = [
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
{ to: '/machine-skeleton', label: 'Squelettes', icon: IconLucideClipboardList },
]
const navGroups: NavGroup[] = [

View File

@@ -0,0 +1,207 @@
<template>
<div v-if="open" class="modal modal-open">
<div class="modal-box max-w-xl w-full" style="overflow: visible">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"
@click="handleClose"
>
&times;
</button>
<h3 class="font-bold text-lg mb-6">
{{ title }}
</h3>
<!-- Step 1: Choose category -->
<div class="form-control mb-5" style="position: relative; z-index: 20">
<label class="label pb-1">
<span class="label-text font-medium">Catégorie</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="types"
:loading="loadingTypes"
:max-visible="8"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="(t: any) => t.name"
:option-description="(t: any) => t.code"
/>
</div>
<!-- Step 2: Choose entity (visible only after category selected) -->
<div v-if="selectedTypeName" class="form-control mb-5" style="position: relative; z-index: 10">
<label class="label pb-1">
<span class="label-text font-medium">{{ entityLabel }}</span>
</label>
<SearchSelect
v-model="selectedEntityId"
:options="entities"
:loading="loadingEntities"
:max-visible="8"
:placeholder="`Rechercher ${entityLabelLower}...`"
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
:option-label="entityOptionLabel"
:option-description="entityOptionDescription"
/>
</div>
<!-- Summary of selection -->
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
<p v-if="selectedEntitySummary.reference" class="text-xs text-base-content/60">
Réf : {{ selectedEntitySummary.reference }}
</p>
</div>
<div class="modal-action mt-4 pt-4 border-t border-base-200" style="position: relative; z-index: 0">
<button type="button" class="btn btn-ghost" @click="handleClose">
Annuler
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedEntityId"
@click="handleConfirm"
>
Ajouter
</button>
</div>
</div>
<div class="modal-backdrop" @click="handleClose" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
type EntityKind = 'component' | 'piece' | 'product'
const props = defineProps<{
open: boolean
entityKind: EntityKind
}>()
const emit = defineEmits<{
close: []
confirm: [entityId: string]
}>()
const selectedTypeId = ref('')
const selectedEntityId = ref('')
const loadingEntities = ref(false)
const entities = ref<any[]>([])
const { componentTypes, loadingComponentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadingPieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadingProductTypes, loadProductTypes } = useProductTypes()
const { loadComposants } = useComposants()
const { loadPieces } = usePieces()
const { loadProducts } = useProducts()
const title = computed(() => {
const labels: Record<EntityKind, string> = {
component: 'Ajouter un composant',
piece: 'Ajouter une pièce',
product: 'Ajouter un produit',
}
return labels[props.entityKind]
})
const entityLabel = computed(() => {
const labels: Record<EntityKind, string> = {
component: 'Composant',
piece: 'Pièce',
product: 'Produit',
}
return labels[props.entityKind]
})
const entityLabelLower = computed(() => entityLabel.value.toLowerCase())
const types = computed(() => {
if (props.entityKind === 'component') return componentTypes.value
if (props.entityKind === 'piece') return pieceTypes.value
return productTypes.value
})
const loadingTypes = computed(() => {
if (props.entityKind === 'component') return loadingComponentTypes.value
if (props.entityKind === 'piece') return loadingPieceTypes.value
return loadingProductTypes.value
})
const selectedTypeName = computed(() => {
if (!selectedTypeId.value) return ''
const found = types.value.find((t: any) => t.id === selectedTypeId.value)
return found?.name || ''
})
const entityOptionLabel = (e: any) => e.name || '(sans nom)'
const entityOptionDescription = (e: any) => e.reference || ''
const selectedEntitySummary = computed(() => {
if (!selectedEntityId.value || !entities.value.length) return null
const found = entities.value.find((e: any) => e.id === selectedEntityId.value)
if (!found) return null
return { name: found.name || '(sans nom)', reference: found.reference || null }
})
// Load types when modal opens
watch(() => props.open, async (isOpen) => {
if (!isOpen) return
if (props.entityKind === 'component') await loadComponentTypes()
else if (props.entityKind === 'piece') await loadPieceTypes()
else await loadProductTypes()
})
// Load entities when type changes
watch(selectedTypeId, async () => {
selectedEntityId.value = ''
entities.value = []
if (!selectedTypeName.value) return
loadingEntities.value = true
try {
if (props.entityKind === 'component') {
const result = await loadComposants({ typeName: selectedTypeName.value, itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else if (props.entityKind === 'piece') {
const result = await loadPieces({ typeName: selectedTypeName.value, itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else {
const result = await loadProducts({ typeName: selectedTypeName.value, itemsPerPage: 200 })
entities.value = result?.data?.items || []
}
} finally {
loadingEntities.value = false
}
})
const handleClose = () => {
resetState()
emit('close')
}
const handleConfirm = () => {
if (!selectedEntityId.value) return
emit('confirm', selectedEntityId.value)
resetState()
emit('close')
}
const resetState = () => {
selectedTypeId.value = ''
selectedEntityId.value = ''
entities.value = []
}
</script>

View File

@@ -3,32 +3,57 @@
<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 class="flex items-center gap-2">
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-component')"
>
Ajouter un composant
</button>
<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>
</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 v-if="components.length === 0" class="text-sm text-gray-500 py-4">
Aucun composant associé à cette machine.
</div>
<div v-else class="space-y-2">
<div v-for="component in components" :key="component.id" class="relative">
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
title="Supprimer ce composant"
@click="$emit('remove-component', component.linkId || component.id)"
>
Supprimer
</button>
<ComponentHierarchy
:components="[component]"
:is-edit-mode="false"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@edit-piece="$emit('edit-piece', $event)"
/>
</div>
</div>
</div>
</div>
</template>
@@ -49,5 +74,7 @@ defineEmits<{
'update-component': [component: any]
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
'add-component': []
'remove-component': [linkId: string]
}>()
</script>

View File

@@ -4,25 +4,6 @@
<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
@@ -43,7 +24,7 @@
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button>
<button
v-if="isDetailsView && !isEditMode"
v-if="!isEditMode"
@click="$emit('open-print')"
type="button"
class="btn btn-outline btn-secondary"
@@ -60,18 +41,12 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye'
import IconLucidePrinter from '~icons/lucide/printer'
const { canEdit } = usePermissions()
defineProps<{
title: string
isDetailsView: boolean
isSkeletonView: boolean
isEditMode: boolean
hasSkeletonRequirements: boolean
}>()
defineEmits<{
'change-view': [view: 'details' | 'skeleton']
'toggle-edit': []
'open-print': []
}>()

View File

@@ -3,32 +3,75 @@
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Pièces de la machine</h2>
<div class="flex items-center gap-2">
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-piece')"
>
Ajouter une pièce
</button>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
>
<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>
</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 v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
Aucune pièce associée à cette machine.
</div>
<div v-else class="space-y-2">
<div v-for="piece in pieces" :key="piece.id" class="relative">
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
title="Supprimer cette pièce"
@click="$emit('remove-piece', piece.linkId || piece.id)"
>
Supprimer
</button>
<PieceItem
:piece="piece"
:is-edit-mode="false"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@edit="$emit('edit-piece', $event)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucideChevronRight from '~icons/lucide/chevron-right'
defineProps<{
pieces: any[]
isEditMode: boolean
collapsed: boolean
collapseToggleToken: number
}>()
defineEmits<{
'update-piece': [piece: any]
'toggle-collapse': []
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
'add-piece': []
'remove-piece': [linkId: string]
}>()
</script>

View File

@@ -1,29 +1,55 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="previewDocumentList"
@close="closePreview"
/>
<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.
Produits sélectionnés directement pour cette machine.
</p>
</div>
<span class="badge badge-outline" v-if="products.length">
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
</span>
<div class="flex items-center gap-2">
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-product')"
>
Ajouter un produit
</button>
<span class="badge badge-outline" v-if="products.length">
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
</span>
</div>
</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"
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2 relative"
>
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs absolute top-2 right-2"
title="Supprimer ce produit"
@click="$emit('remove-product', (product.linkId || product.id) as string)"
>
Supprimer
</button>
<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">
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
{{ product.groupLabel }}
</span>
</div>
@@ -39,6 +65,53 @@
<span class="font-medium">Prix indicatif :</span>
<span class="ml-1">{{ product.priceLabel }}</span>
</p>
<!-- Documents liés au produit -->
<div v-if="product.documents?.length" class="mt-2 space-y-1">
<p class="text-xs font-medium text-base-content/70">Documents :</p>
<div
v-for="doc in product.documents"
:key="doc.id || doc.name"
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-xs">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-6"
>
<component
:is="documentIcon(doc).component"
class="h-4 w-4"
:class="documentIcon(doc).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">{{ doc.name }}</div>
<div class="text-xs text-base-content/60">
{{ doc.mimeType || 'Inconnu' }} {{ formatSize(doc.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<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="openPreview(doc, product.documents || [])"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(doc)"
>
Télécharger
</button>
</div>
</div>
</div>
</div>
</div>
<p v-else class="text-xs text-gray-500">
@@ -49,14 +122,53 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
formatSize,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
defineProps<{
products: Array<{
id?: string | null
linkId?: string | null
name?: string
reference?: string | null
supplierLabel?: string | null
priceLabel?: string | null
groupLabel?: string
documents?: Array<{
id?: string
name?: string
mimeType?: string
size?: number
fileUrl?: string
downloadUrl?: string
}>
}>
isEditMode: boolean
}>()
defineEmits<{
'add-product': []
'remove-product': [linkId: string]
}>()
const previewDocument = ref<any>(null)
const previewVisible = ref(false)
const previewDocumentList = ref<any[]>([])
const openPreview = (doc: any, docs: any[]) => {
previewDocument.value = doc
previewDocumentList.value = docs
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
</script>

View File

@@ -1,193 +0,0 @@
<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 }} &bull; 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>

View File

@@ -1,205 +0,0 @@
<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">&bull;</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">&bull;</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>

View File

@@ -1,59 +0,0 @@
<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>

View File

@@ -1,126 +0,0 @@
<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>

View File

@@ -1,130 +0,0 @@
<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>

View File

@@ -1,142 +0,0 @@
<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>

View File

@@ -1,47 +1,23 @@
/**
* 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.
* Simplified: no more TypeMachine / skeleton system.
* Supports direct creation or cloning from an existing machine.
*/
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ref, reactive, 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 { humanizeError } from '~/shared/utils/errorMessages'
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, addMissingCustomFields, deleteMachine } = useMachines()
const { machines, loadMachines, createMachine, cloneMachine } = 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()
// ---------------------------------------------------------------------------
@@ -49,322 +25,60 @@ export function useMachineCreatePage() {
// ---------------------------------------------------------------------------
const submitting = ref(false)
const loading = ref(true)
const newMachine = reactive({
name: '',
siteId: '',
typeMachineId: '',
reference: '',
cloneFromMachineId: '',
})
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')
if (!newMachine.name?.trim()) {
toast.showError('Merci de renseigner un nom pour la machine')
return
}
submitting.value = true
try {
const baseMachineData = {
name: newMachine.name,
siteId: newMachine.siteId,
reference: newMachine.reference,
typeMachineId: type.id,
let result: any
if (newMachine.cloneFromMachineId) {
result = await cloneMachine(newMachine.cloneFromMachineId, {
name: newMachine.name,
siteId: newMachine.siteId,
...(newMachine.reference ? { reference: newMachine.reference } : {}),
})
} else {
result = await createMachine({
name: newMachine.name,
siteId: newMachine.siteId || undefined,
reference: newMachine.reference || undefined,
} as any)
}
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) {
const machineId = result.data?.id
if (hasRequirements && machineId) {
const skeletonResult: any = await reconfigureSkeleton(machineId, {
componentLinks,
pieceLinks,
productLinks,
} as any)
if (!skeletonResult.success) {
// Rollback: delete the orphaned machine
await deleteMachine(machineId).catch(() => {})
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants. La machine n\'a pas été créée.')
return
}
}
// Initialize custom fields for the machine type
if (machineId) {
await addMissingCustomFields(machineId, { showToast: false }).catch(() => {})
}
|| (result.data?.machine as any)?.id
|| null
newMachine.name = ''
newMachine.siteId = ''
newMachine.typeMachineId = ''
newMachine.reference = ''
clearRequirementSelections()
await navigateTo('/machines')
newMachine.cloneFromMachineId = ''
if (machineId) {
await navigateTo(`/machine/${machineId}`)
} else {
await navigateTo('/machines')
}
} else if (result.error) {
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
}
@@ -376,28 +90,19 @@ export function useMachineCreatePage() {
}
// ---------------------------------------------------------------------------
// Watchers & lifecycle
// 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({ itemsPerPage: 200, force: true }),
loadPieces({ itemsPerPage: 200, force: true }),
loadProducts({ itemsPerPage: 200, force: true }),
])
loading.value = true
try {
await Promise.all([
loadSites(),
loadMachines(),
])
} finally {
loading.value = false
}
})
// ---------------------------------------------------------------------------
@@ -406,59 +111,11 @@ export function useMachineCreatePage() {
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,
machines,
submitting,
loading,
// Actions
finalizeMachineCreation,

View File

@@ -1,572 +0,0 @@
/**
* Machine creation preview computation and validation.
*
* Extracted from pages/machines/new.vue. Builds the live preview model
* and validates requirement selections before machine creation.
*/
import { computed, type Ref, type ComputedRef } from 'vue'
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
import { extractParentLinkIdentifiers } from '~/shared/utils/productDisplayUtils'
import {
getComponentMachineAssignments,
getPieceMachineAssignments,
getPieceComponentAssignments,
formatAssignmentList,
} from '~/shared/utils/assignmentUtils'
type AnyRecord = Record<string, unknown>
export interface MachineCreatePreviewDeps {
newMachine: { name: string; siteId: string; typeMachineId: string; reference: string }
sites: Ref<AnyRecord[]>
selectedMachineType: ComputedRef<AnyRecord | null>
findComponentById: (id: string) => AnyRecord | null
findPieceById: (id: string) => AnyRecord | null
findProductById: (id: string) => AnyRecord | null
getComponentRequirementEntries: (requirementId: string) => AnyRecord[]
getPieceRequirementEntries: (requirementId: string) => AnyRecord[]
getProductRequirementEntries: (requirementId: string) => AnyRecord[]
}
// ---------------------------------------------------------------------------
// Product type ID extractors
// ---------------------------------------------------------------------------
const getProductTypeIdFromComponent = (component: AnyRecord | null): string | null => {
if (!component || typeof component !== 'object') return null
return (
(component.product as AnyRecord)?.typeProductId ||
((component.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
component.productTypeId ||
null
) as string | null
}
const getProductTypeIdFromPiece = (piece: AnyRecord | null): string | null => {
if (!piece || typeof piece !== 'object') return null
return (
(piece.product as AnyRecord)?.typeProductId ||
((piece.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
piece.productTypeId ||
null
) as string | null
}
// ---------------------------------------------------------------------------
// Status badge helper
// ---------------------------------------------------------------------------
export const getStatusBadgeClass = (status: string): string => {
if (status === 'ready') return 'badge-success'
if (status === 'warning') return 'badge-warning'
return 'badge-error'
}
// ---------------------------------------------------------------------------
// Scroll / issue click helpers
// ---------------------------------------------------------------------------
const highlightClasses = ['ring', 'ring-primary', 'ring-offset-2']
export const scrollToAnchor = (anchor: string): void => {
if (!anchor || typeof window === 'undefined' || typeof document === 'undefined') return
const target = document.getElementById(anchor)
if (!target) return
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
highlightClasses.forEach((cls) => target.classList.add(cls))
window.setTimeout(() => {
highlightClasses.forEach((cls) => target.classList.remove(cls))
}, 1500)
}
export const handleIssueClick = (issue: AnyRecord): void => {
if (!issue?.anchor) return
scrollToAnchor(issue.anchor as string)
}
// ---------------------------------------------------------------------------
// Type label resolvers
// ---------------------------------------------------------------------------
export const resolveComponentRequirementTypeLabel = (
requirement: AnyRecord,
entry: AnyRecord,
findComponentById: (id: string) => AnyRecord | null,
): string => {
if (entry?.composantId) {
const component = findComponentById(entry.composantId as string)
if ((component?.typeComposant as AnyRecord)?.name) {
return (component!.typeComposant as AnyRecord).name as string
}
}
return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
}
export const resolvePieceRequirementTypeLabel = (
requirement: AnyRecord,
entry: AnyRecord,
findPieceById: (id: string) => AnyRecord | null,
): string => {
if (entry?.pieceId) {
const piece = findPieceById(entry.pieceId as string)
if ((piece?.typePiece as AnyRecord)?.name) {
return (piece!.typePiece as AnyRecord).name as string
}
}
return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
}
// ---------------------------------------------------------------------------
// Product requirement stats
// ---------------------------------------------------------------------------
const computeProductUsageFromSelections = (
type: AnyRecord,
deps: MachineCreatePreviewDeps,
): 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[]) {
const entries = deps.getComponentRequirementEntries(requirement.id as string)
entries.forEach((entry) => {
if (!entry?.composantId) return
const component = deps.findComponentById(entry.composantId as string)
increment(getProductTypeIdFromComponent(component))
})
}
for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) {
const entries = deps.getPieceRequirementEntries(requirement.id as string)
entries.forEach((entry) => {
if (!entry?.pieceId) return
const piece = deps.findPieceById(entry.pieceId as string)
increment(getProductTypeIdFromPiece(piece))
})
}
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
const entries = deps.getProductRequirementEntries(requirement.id as string)
entries.forEach((entry) => {
if (!entry?.productId) return
const product = deps.findProductById(entry.productId as string)
const typeProductId = (
product?.typeProductId ||
(product?.typeProduct as AnyRecord)?.id ||
entry?.typeProductId ||
requirement?.typeProductId ||
(requirement?.typeProduct as AnyRecord)?.id ||
null
) as string | null
increment(typeProductId)
})
}
return usage
}
const buildProductRequirementStats = (
type: AnyRecord,
deps: MachineCreatePreviewDeps,
): { stats: AnyRecord[]; usage: Map<string, number> } => {
const usage = computeProductUsageFromSelections(type, deps)
const stats = ((type.productRequirements || []) as AnyRecord[]).map((requirement) => {
const typeProductId = (
requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null
) as string | null
const label = (
(requirement.label as string)?.trim() ||
(requirement.typeProduct as AnyRecord)?.name ||
(requirement.typeProduct as AnyRecord)?.code ||
'Produit requis'
) as string
const typeName = ((requirement.typeProduct as AnyRecord)?.name || 'Non défini') as string
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const max = (requirement.maxCount ?? null) as number | null
const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0
const rawEntries = deps.getProductRequirementEntries(requirement.id as string)
const normalizedEntries = rawEntries.map((entry, index) => {
const product = entry?.productId ? deps.findProductById(entry.productId as string) : null
const subtitleParts: string[] = []
if (product?.reference) subtitleParts.push(`Réf. ${product.reference}`)
if (product?.supplierPrice !== undefined && product?.supplierPrice !== null) {
const price = Number(product.supplierPrice)
if (!Number.isNaN(price)) subtitleParts.push(`${price.toFixed(2)}`)
}
if (Array.isArray(product?.constructeurs) && (product!.constructeurs as AnyRecord[]).length) {
const cLabel = (product!.constructeurs as AnyRecord[])
.map((c) => c?.name)
.filter(Boolean)
.join(', ')
if (cLabel) subtitleParts.push(`Fournisseurs: ${cLabel}`)
}
return {
key: `${requirement.id}-${index}`,
status: product ? 'complete' : 'pending',
title: (product?.name || product?.reference || `Sélection #${index + 1}`) as string,
subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null,
}
})
const issues: AnyRecord[] = []
if (count < min) {
issues.push({
message: `Le produit "${label}" nécessite au moins ${min} sélection(s). Actuellement ${count}.`,
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
if (max !== null && count > max) {
issues.push({
message: `Le produit "${label}" ne peut pas dépasser ${max} sélection(s). Actuellement ${count}.`,
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
if (normalizedEntries.length > 0 && normalizedEntries.some((e) => e.status !== 'complete')) {
issues.push({
message: 'Sélectionner un produit pour chaque entrée ajoutée.',
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
const total = normalizedEntries.length
const status = issues.some((i) => i.kind === 'error')
? 'error'
: issues.some((i) => i.kind === 'warning')
? 'warning'
: 'ready'
return {
id: requirement.id,
requirement,
label,
typeName,
count,
min,
max,
completed,
total,
entries: normalizedEntries,
issues,
allowNewModels: requirement.allowNewModels ?? true,
status,
}
})
return { stats, usage }
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
export const validateRequirementSelections = (
type: AnyRecord,
deps: MachineCreatePreviewDeps,
): AnyRecord => {
const errors: string[] = []
const componentLinksPayload: AnyRecord[] = []
const pieceLinksPayload: AnyRecord[] = []
const productLinksPayload: AnyRecord[] = []
for (const requirement of (type.componentRequirements || []) as AnyRecord[]) {
const entries = deps.getComponentRequirementEntries(requirement.id as string)
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const max = (requirement.maxCount ?? null) as number | 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) => {
if (!entry.composantId) {
errors.push(`Sélectionner un composant existant pour "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}".`)
return
}
const component = deps.findComponentById(entry.composantId as string)
if (!component) {
errors.push(`Le composant sélectionné est introuvable (ID: ${entry.composantId}).`)
return
}
const requiredTypeId = (requirement.typeComposantId || (requirement.typeComposant as AnyRecord)?.id || null) as string | null
if (requiredTypeId && component.typeComposantId && component.typeComposantId !== requiredTypeId) {
errors.push(`Le composant "${component.name || component.id}" n'appartient pas à la famille attendue.`)
return
}
const payload: AnyRecord = { requirementId: requirement.id, composantId: entry.composantId }
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
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 = deps.getPieceRequirementEntries(requirement.id as string)
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const max = (requirement.maxCount ?? null) as number | 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) => {
if (!entry.pieceId) {
errors.push(`Sélectionner une pièce existante pour "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}".`)
return
}
const piece = deps.findPieceById(entry.pieceId as string)
if (!piece) {
errors.push(`La pièce sélectionnée est introuvable (ID: ${entry.pieceId}).`)
return
}
const requiredTypeId = (requirement.typePieceId || (requirement.typePiece as AnyRecord)?.id || null) as string | null
if (requiredTypeId && piece.typePieceId && piece.typePieceId !== requiredTypeId) {
errors.push(`La pièce "${piece.name || piece.id}" n'appartient pas à la famille attendue.`)
return
}
const payload: AnyRecord = { requirementId: requirement.id, pieceId: entry.pieceId }
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
if (overrides) payload.overrides = overrides
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
pieceLinksPayload.push(payload)
})
}
const { stats: productStats } = buildProductRequirementStats(type, deps)
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
const entries = deps.getProductRequirementEntries(requirement.id as string)
const max = (requirement.maxCount ?? null) as number | null
if (max !== null && entries.length > max) {
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(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 = deps.findProductById(entry.productId as string)
if (!product) {
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
return
}
const requiredTypeId = (requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null) as string | null
const productTypeId = (product.typeProductId || (product.typeProduct as AnyRecord)?.id || entry.typeProductId || null) as string | null
if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) {
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 }
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
productLinksPayload.push(payload)
})
}
productStats.forEach((stat) => {
((stat.issues || []) as AnyRecord[])
.filter((issue) => issue.kind === 'error')
.forEach((issue) => errors.push(issue.message as string))
})
if (errors.length > 0) return { valid: false, error: errors[0] }
return {
valid: true,
componentLinks: componentLinksPayload,
pieceLinks: pieceLinksPayload,
productLinks: productLinksPayload,
}
}
// ---------------------------------------------------------------------------
// Main preview composable
// ---------------------------------------------------------------------------
export function useMachineCreatePreview(deps: MachineCreatePreviewDeps) {
const machinePreview = computed(() => {
const type = deps.selectedMachineType.value
if (!type) return null
const trimmedName = (deps.newMachine.name || '').trim()
const currentSite = deps.newMachine.siteId
? deps.sites.value.find((site) => site.id === deps.newMachine.siteId) || null
: null
const trimmedReference = (deps.newMachine.reference || '').trim()
const baseFields = [
{ key: 'name', label: 'Nom', display: trimmedName || 'À renseigner', status: trimmedName ? 'complete' : 'missing' },
{ key: 'site', label: 'Site', display: (currentSite?.name || 'Sélectionner un site') as string, status: currentSite ? 'complete' : 'missing' },
{ key: 'type', label: 'Type sélectionné', display: type.name as string, status: 'complete' },
{ key: 'reference', label: 'Référence', display: trimmedReference || 'Non renseignée', status: trimmedReference ? 'complete' : 'optional' },
]
const baseIssues: AnyRecord[] = []
if (!trimmedName) baseIssues.push({ message: 'Renseigner un nom de machine.', kind: 'error', anchor: 'machine-field-name' })
if (!currentSite) baseIssues.push({ message: "Sélectionner un site d'affectation.", kind: 'error', anchor: 'machine-field-site' })
const baseStatus = baseIssues.some((issue) => issue.kind === 'error') ? 'error' : 'ready'
// Component groups
const componentGroups = ((type.componentRequirements || []) as AnyRecord[]).map((requirement) => {
const entries = deps.getComponentRequirementEntries(requirement.id as string)
const normalizedEntries = entries.map((entry, index) => {
const selectedComponent = entry.composantId ? deps.findComponentById(entry.composantId as string) : null
const displayName = (selectedComponent?.name || (requirement.typeComposant as AnyRecord)?.name || 'Composant') as string
const subtitleParts: string[] = []
if (selectedComponent?.reference) subtitleParts.push(`Réf. ${selectedComponent.reference}`)
const constructeurName = (selectedComponent?.constructeur as AnyRecord)?.name || selectedComponent?.constructeurName
if (constructeurName) subtitleParts.push(constructeurName as string)
const machineAssignments = selectedComponent ? getComponentMachineAssignments(selectedComponent) : []
const assignmentLabel = formatAssignmentList(machineAssignments)
if (assignmentLabel) subtitleParts.push(`Liée à ${assignmentLabel}`)
return {
key: `${requirement.id}-${index}`,
status: entry.composantId ? 'complete' : 'pending',
title: displayName,
subtitle: subtitleParts.join(' • ') || null,
assignmentLabel,
assignments: machineAssignments,
}
})
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const max = (requirement.maxCount ?? null) as number | null
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
const issues: AnyRecord[] = []
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `component-group-${requirement.id}` })
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `component-group-${requirement.id}` })
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner un composant pour chaque entrée.', kind: 'error', anchor: `component-group-${requirement.id}` })
const hasErrors = issues.some((i) => i.kind === 'error')
const hasWarnings = completed < entries.length
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
return {
id: requirement.id,
label: (requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Famille de composants') as string,
typeName: ((requirement.typeComposant as AnyRecord)?.name || 'Non défini') as string,
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
}
})
// Piece groups
const pieceGroups = ((type.pieceRequirements || []) as AnyRecord[]).map((requirement) => {
const entries = deps.getPieceRequirementEntries(requirement.id as string)
const normalizedEntries = entries.map((entry, index) => {
const selectedPiece = entry.pieceId ? deps.findPieceById(entry.pieceId as string) : null
const displayName = (selectedPiece?.name || (requirement.typePiece as AnyRecord)?.name || 'Pièce') as string
const subtitleParts: string[] = []
if (selectedPiece?.reference) subtitleParts.push(`Réf. ${selectedPiece.reference}`)
const constructeurName = (selectedPiece?.constructeur as AnyRecord)?.name || selectedPiece?.constructeurName
if (constructeurName) subtitleParts.push(constructeurName as string)
const machineAssignments = selectedPiece ? getPieceMachineAssignments(selectedPiece) : []
const machineAssignmentLabel = formatAssignmentList(machineAssignments)
if (machineAssignmentLabel) subtitleParts.push(`Machines: ${machineAssignmentLabel}`)
const componentAssignments = selectedPiece ? getPieceComponentAssignments(selectedPiece) : []
const componentAssignmentLabel = formatAssignmentList(componentAssignments)
if (componentAssignmentLabel) subtitleParts.push(`Composants: ${componentAssignmentLabel}`)
return {
key: `${requirement.id}-${index}`,
status: entry.pieceId ? 'complete' : 'pending',
title: displayName,
subtitle: subtitleParts.join(' • ') || null,
machineAssignmentLabel, componentAssignmentLabel,
machineAssignments, componentAssignments,
}
})
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const max = (requirement.maxCount ?? null) as number | null
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
const issues: AnyRecord[] = []
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `piece-group-${requirement.id}` })
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `piece-group-${requirement.id}` })
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner une pièce pour chaque entrée.', kind: 'error', anchor: `piece-group-${requirement.id}` })
const hasErrors = issues.some((i) => i.kind === 'error')
const hasWarnings = completed < entries.length
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
return {
id: requirement.id,
label: (requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Groupe de pièces') as string,
typeName: ((requirement.typePiece as AnyRecord)?.name || 'Non défini') as string,
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
}
})
// Product groups
const { stats: productGroups } = buildProductRequirementStats(type, deps)
// Aggregate
const aggregatedIssues = [
...baseIssues.map((issue) => ({ ...issue, scope: 'Informations générales' })),
...componentGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
...pieceGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
...productGroups.flatMap((group: AnyRecord) => ((group.issues || []) as AnyRecord[]).map((issue) => ({ ...issue, scope: group.label }))),
]
const statuses = [
baseStatus,
...componentGroups.map((g) => g.status),
...pieceGroups.map((g) => g.status),
...productGroups.map((g: AnyRecord) => g.status as string),
]
const overallStatus = statuses.includes('error') ? 'error' : statuses.includes('warning') ? 'warning' : 'ready'
return {
base: { fields: baseFields, issues: baseIssues, status: baseStatus },
componentGroups,
pieceGroups,
productGroups,
type: {
name: type.name,
category: type.category || null,
hasStructuredDefinition:
((type.componentRequirements as unknown[])?.length || 0) > 0 ||
((type.pieceRequirements as unknown[])?.length || 0) > 0 ||
((type.productRequirements as unknown[])?.length || 0) > 0,
},
status: overallStatus,
ready: overallStatus === 'ready',
issues: aggregatedIssues,
}
})
const blockingPreviewIssues = computed(() => {
if (!machinePreview.value) return []
return (machinePreview.value.issues as AnyRecord[]).filter((issue) => issue.kind === 'error')
})
const canCreateMachine = computed(() => {
if (!machinePreview.value) return false
return blockingPreviewIssues.value.length === 0
})
return {
machinePreview,
blockingPreviewIssues,
canCreateMachine,
}
}

View File

@@ -1,365 +0,0 @@
/**
* Machine creation requirement selection state management.
*
* Extracted from pages/machines/new.vue. Manages the reactive selection state
* for component / piece / product requirements when creating a new machine.
*/
import { ref, reactive, computed } from 'vue'
import { extractCollection } from '~/shared/utils/apiHelpers'
type AnyRecord = Record<string, unknown>
export interface MachineCreateSelectionsDeps {
findComponentById: (id: string) => AnyRecord | null
findPieceById: (id: string) => AnyRecord | null
pieces: { value: AnyRecord[] }
get: (url: string) => Promise<AnyRecord>
toast: { showError: (msg: string) => void }
}
export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
const { findComponentById, findPieceById, pieces, get, toast } = deps
// ---------------------------------------------------------------------------
// Reactive state
// ---------------------------------------------------------------------------
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
const pieceOptionsByKey = ref<Record<string, AnyRecord[]>>({})
const pieceLoadingByKey = ref<Record<string, boolean>>({})
// ---------------------------------------------------------------------------
// Piece option caching
// ---------------------------------------------------------------------------
const getPieceKey = (requirement: AnyRecord, entryIndex: number): string =>
`${requirement?.id || 'req'}:${entryIndex}`
const findPieceInCachedOptions = (id: string): AnyRecord | null => {
if (!id) return null
const buckets = Object.values(pieceOptionsByKey.value || {})
for (const bucket of buckets) {
if (!Array.isArray(bucket)) continue
const found = bucket.find((piece) => piece?.id === id)
if (found) return found
}
return null
}
const cachePieceIfMissing = (piece: AnyRecord): void => {
if (!piece?.id) return
const current = Array.isArray(pieces.value) ? pieces.value : []
if (current.some((p: AnyRecord) => p?.id === piece.id)) return
pieces.value = [...current, piece]
}
const fetchPieceOptions = async (
requirement: AnyRecord,
entryIndex: number,
term = '',
): Promise<void> => {
const key = getPieceKey(requirement, entryIndex)
if (pieceLoadingByKey.value[key]) return
const requirementTypeId =
(requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null) as string | null
const params = new URLSearchParams()
params.set('itemsPerPage', '50')
if (term && term.trim()) params.set('name', term.trim())
if (requirementTypeId) params.set('typePiece', `/api/model_types/${requirementTypeId}`)
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: true }
try {
const result = await get(`/pieces?${params.toString()}`)
if (result.success) {
pieceOptionsByKey.value = {
...pieceOptionsByKey.value,
[key]: extractCollection(result.data) as AnyRecord[],
}
}
} finally {
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false }
}
}
// ---------------------------------------------------------------------------
// Entry getters
// ---------------------------------------------------------------------------
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
componentRequirementSelections[requirementId] || []
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
pieceRequirementSelections[requirementId] || []
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
productRequirementSelections[requirementId] || []
// ---------------------------------------------------------------------------
// Entry factories
// ---------------------------------------------------------------------------
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
typeComposantId: requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
composantId: source?.composantId || null,
definition: {},
})
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
typePieceId: requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
pieceId: source?.pieceId || null,
definition: {},
})
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
typeProductId:
source?.typeProductId ||
requirement?.typeProductId ||
(requirement?.typeProduct as AnyRecord)?.id ||
null,
productId: source?.productId || null,
})
// ---------------------------------------------------------------------------
// Selected piece IDs (for dedup)
// ---------------------------------------------------------------------------
const selectedPieceIds = computed(() => {
const ids: string[] = []
Object.values(pieceRequirementSelections).forEach((entries) => {
;(entries || []).forEach((entry) => {
if (entry?.pieceId) ids.push(entry.pieceId as string)
})
})
return ids
})
// ---------------------------------------------------------------------------
// CRUD operations
// ---------------------------------------------------------------------------
const addComponentSelectionEntry = (requirement: AnyRecord): void => {
const entries = getComponentRequirementEntries(requirement.id as string)
const max = (requirement.maxCount ?? null) as number | 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): void => {
const entries = getComponentRequirementEntries(requirementId)
componentRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
}
const addPieceSelectionEntry = (requirement: AnyRecord): void => {
const entries = getPieceRequirementEntries(requirement.id as string)
const max = (requirement.maxCount ?? null) as number | 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),
]
fetchPieceOptions(requirement, entries.length).catch(() => {})
}
const removePieceSelectionEntry = (requirementId: string, index: number): void => {
const entries = getPieceRequirementEntries(requirementId)
pieceRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
}
const addProductSelectionEntry = (requirement: AnyRecord): void => {
const entries = getProductRequirementEntries(requirement.id as string)
const max = (requirement.maxCount ?? null) as number | 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): void => {
const entries = getProductRequirementEntries(requirementId)
productRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
}
// ---------------------------------------------------------------------------
// Selection setters
// ---------------------------------------------------------------------------
const setComponentRequirementComponent = (
requirement: AnyRecord,
index: number,
componentId: string,
): void => {
const entries = getComponentRequirementEntries(requirement.id as string)
const entry = entries[index]
if (!entry) return
entry.composantId = componentId || null
if (componentId) {
const component = findComponentById(componentId)
entry.typeComposantId = component?.typeComposantId || requirement?.typeComposantId || null
} else {
entry.typeComposantId = requirement?.typeComposantId || null
}
}
const setPieceRequirementPiece = (
requirement: AnyRecord,
index: number,
pieceId: string,
): void => {
const entries = getPieceRequirementEntries(requirement.id as string)
const entry = entries[index]
if (!entry) return
entry.pieceId = pieceId || null
if (pieceId) {
const piece = findPieceById(pieceId) || findPieceInCachedOptions(pieceId)
entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null
if (piece) cachePieceIfMissing(piece as AnyRecord)
} else {
entry.typePieceId = requirement?.typePieceId || null
}
}
const setProductRequirementProduct = (
requirement: AnyRecord,
index: number,
productId: string,
findProductById: (id: string) => AnyRecord | null,
): void => {
const entries = getProductRequirementEntries(requirement.id as string)
const entry = entries[index]
if (!entry) return
const normalizedProductId = productId || null
entry.productId = normalizedProductId
if (normalizedProductId) {
const product = findProductById(normalizedProductId)
entry.typeProductId =
product?.typeProductId ||
(product?.typeProduct as AnyRecord)?.id ||
entry.typeProductId ||
requirement?.typeProductId ||
(requirement?.typeProduct as AnyRecord)?.id ||
null
} else {
entry.typeProductId =
requirement?.typeProductId ||
(requirement?.typeProduct as AnyRecord)?.id ||
null
}
}
// ---------------------------------------------------------------------------
// Bulk operations
// ---------------------------------------------------------------------------
const clearRequirementSelections = (): void => {
Object.keys(componentRequirementSelections).forEach((key) => {
delete componentRequirementSelections[key]
})
Object.keys(pieceRequirementSelections).forEach((key) => {
delete pieceRequirementSelections[key]
})
Object.keys(productRequirementSelections).forEach((key) => {
delete productRequirementSelections[key]
})
}
const initializeRequirementSelections = (type: AnyRecord): void => {
const componentRequirements = (type.componentRequirements || []) as AnyRecord[]
const pieceRequirements = (type.pieceRequirements || []) as AnyRecord[]
const productRequirements = (type.productRequirements || []) as AnyRecord[]
componentRequirements.forEach((requirement) => {
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const initialCount = Math.max(min, requirement.required ? 1 : 0)
if (initialCount > 0) {
componentRequirementSelections[requirement.id as string] = Array.from(
{ length: initialCount },
() => createComponentSelectionEntry(requirement),
)
} else {
componentRequirementSelections[requirement.id as string] = []
}
})
pieceRequirements.forEach((requirement) => {
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const initialCount = Math.max(min, requirement.required ? 1 : 0)
if (initialCount > 0) {
const entries = Array.from(
{ length: initialCount },
() => createPieceSelectionEntry(requirement),
)
pieceRequirementSelections[requirement.id as string] = entries
entries.forEach((_: unknown, index: number) => {
fetchPieceOptions(requirement, index).catch(() => {})
})
} else {
pieceRequirementSelections[requirement.id as string] = []
}
})
productRequirements.forEach((requirement) => {
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const initialCount = Math.max(min, requirement.required ? 1 : 0)
if (initialCount > 0) {
productRequirementSelections[requirement.id as string] = Array.from(
{ length: initialCount },
() => createProductSelectionEntry(requirement),
)
} else {
productRequirementSelections[requirement.id as string] = []
}
})
}
return {
componentRequirementSelections,
pieceRequirementSelections,
productRequirementSelections,
pieceOptionsByKey,
pieceLoadingByKey,
selectedPieceIds,
getPieceKey,
findPieceInCachedOptions,
fetchPieceOptions,
getComponentRequirementEntries,
getPieceRequirementEntries,
getProductRequirementEntries,
addComponentSelectionEntry,
removeComponentSelectionEntry,
addPieceSelectionEntry,
removePieceSelectionEntry,
addProductSelectionEntry,
removeProductSelectionEntry,
setComponentRequirementComponent,
setPieceRequirementPiece,
setProductRequirementProduct,
clearRequirementSelections,
initializeRequirementSelections,
}
}

View File

@@ -39,6 +39,8 @@ import {
resolveIdentifier,
resolveProductReference as _resolveProductReference,
getProductDisplay as _getProductDisplay,
getProductSuppliersLabel,
getProductPriceLabel,
extractParentLinkIdentifiers,
} from '~/shared/utils/productDisplayUtils'
import {
@@ -64,7 +66,7 @@ export function useMachineDetailData(machineId: string) {
const {
updateMachine: updateMachineApi,
reconfigureSkeleton: reconfigureMachineSkeleton,
updateStructure: updateMachineStructure,
} = useMachines()
const { updateComposant: updateComposantApi } = useComposants()
const { updatePiece: updatePieceApi } = usePieces()
@@ -75,11 +77,12 @@ export function useMachineDetailData(machineId: string) {
upsertCustomFieldValue,
updateCustomFieldValue: updateCustomFieldValueApi,
} = useCustomFields()
const { get } = useApi()
const { get, post: apiPost, delete: apiDel } = useApi()
const {
uploadDocuments,
deleteDocument,
loadDocumentsByMachine,
loadDocumentsByProduct,
} = useDocuments()
const toast = useToast()
const { constructeurs, loadConstructeurs } = useConstructeurs()
@@ -105,6 +108,7 @@ export function useMachineDetailData(machineId: string) {
const machineComponentLinks = ref<AnyRecord[]>([])
const machinePieceLinks = ref<AnyRecord[]>([])
const machineProductLinks = ref<AnyRecord[]>([])
const productDocumentsMap = ref<Map<string, AnyRecord[]>>(new Map())
const printAreaRef = ref<HTMLElement | null>(null)
// ---------------------------------------------------------------------------
@@ -169,39 +173,21 @@ export function useMachineDetailData(machineId: string) {
const componentsCollapsed = ref(true)
const collapseToggleToken = ref(0)
const piecesCollapsed = ref(true)
const pieceCollapseToggleToken = ref(0)
// ---------------------------------------------------------------------------
// Product helpers
// ---------------------------------------------------------------------------
const machineType = computed(() => (machine.value as AnyRecord)?.typeMachine || null)
const componentTypeOptions = computed(() => componentTypes.value || [])
const pieceTypeOptions = computed(() => pieceTypes.value || [])
const componentRequirements = computed(
() => ((machineType.value as AnyRecord)?.componentRequirements as AnyRecord[]) || [],
)
const pieceRequirements = computed(
() => ((machineType.value as AnyRecord)?.pieceRequirements as AnyRecord[]) || [],
)
const productRequirements = computed(
() => ((machineType.value as AnyRecord)?.productRequirements as AnyRecord[]) || [],
)
const machineHasSkeletonRequirements = computed(() =>
componentRequirements.value.length > 0 ||
pieceRequirements.value.length > 0 ||
productRequirements.value.length > 0,
)
const componentTypeLabelMap = computed(() => {
const map = new Map<string, string>()
componentTypeOptions.value.forEach((type) => {
if (type?.id) map.set(type.id as string, (type.name as string) || '')
})
componentRequirements.value.forEach((req: AnyRecord) => {
const type = req.typeComposant as AnyRecord | undefined
if (type?.id) map.set(type.id as string, (type.name as string) || '')
})
return map
})
@@ -210,10 +196,6 @@ export function useMachineDetailData(machineId: string) {
pieceTypeOptions.value.forEach((type) => {
if (type?.id) map.set(type.id as string, (type.name as string) || '')
})
pieceRequirements.value.forEach((req: AnyRecord) => {
const type = req.typePiece as AnyRecord | undefined
if (type?.id) map.set(type.id as string, (type.name as string) || '')
})
return map
})
@@ -310,8 +292,7 @@ export function useMachineDetailData(machineId: string) {
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
return (piecesData || []).map((piece) => {
const requirement = (piece.typeMachinePieceRequirement as AnyRecord) || {}
const typePiece = (requirement.typePiece as AnyRecord) || (piece.typePiece as AnyRecord) || {}
const typePiece = (piece.typePiece as AnyRecord) || {}
const normalizeStructureDefs = (structure: unknown) =>
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
@@ -320,10 +301,6 @@ export function useMachineDetailData(machineId: string) {
normalizeStructureDefs((piece.definition as AnyRecord)?.structure),
normalizeStructureDefs((piece.typePiece as AnyRecord)?.structure),
normalizeStructureDefs(typePiece.structure),
normalizeStructureDefs(typePiece.pieceSkeleton),
normalizeStructureDefs((piece.typePiece as AnyRecord)?.pieceSkeleton),
normalizeStructureDefs(requirement.structure),
normalizeStructureDefs(requirement.pieceSkeleton),
]
const valueEntries = [
@@ -347,17 +324,16 @@ export function useMachineDetailData(machineId: string) {
normalizeExistingCustomFieldDefinitions((piece.definition as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions((piece.typePiece as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(typePiece.customFields),
normalizeExistingCustomFieldDefinitions((requirement.typePiece as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(requirement.customFields),
normalizeExistingCustomFieldDefinitions((requirement.definition as AnyRecord)?.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
),
)
const constructeurIds = uniqueConstructeurIds(
piece.constructeurs,
piece.constructeurIds,
piece.constructeurId,
piece.constructeur,
(piece.originalPiece as AnyRecord)?.constructeurs,
(piece.originalPiece as AnyRecord)?.constructeurIds,
(piece.originalPiece as AnyRecord)?.constructeurId,
(piece.originalPiece as AnyRecord)?.constructeur,
@@ -396,7 +372,6 @@ export function useMachineDetailData(machineId: string) {
constructeurId: constructeurIds[0] || null,
typePieceId:
piece.typePieceId ||
(piece.typeMachinePieceRequirement as AnyRecord)?.typePieceId ||
(piece.typePiece as AnyRecord)?.id ||
null,
__productDisplay: productDisplay,
@@ -409,16 +384,12 @@ export function useMachineDetailData(machineId: string) {
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
return (componentsData || []).map((component) => {
const requirement = (component.typeMachineComponentRequirement as AnyRecord) || {}
const type = (requirement.typeComposant as AnyRecord) || (component.typeComposant as AnyRecord) || {}
const type = (component.typeComposant as AnyRecord) || {}
const normalizedStructureDefs = [
normalizeStructureDefs((component.definition as AnyRecord)?.structure),
normalizeStructureDefs((component.typeComposant as AnyRecord)?.structure),
normalizeStructureDefs(type.structure),
normalizeStructureDefs(type.componentSkeleton),
normalizeStructureDefs(requirement.structure),
normalizeStructureDefs(requirement.componentSkeleton),
]
const actualComponent = (component.originalComposant as AnyRecord) || component
@@ -445,9 +416,6 @@ export function useMachineDetailData(machineId: string) {
normalizeExistingCustomFieldDefinitions((component.typeComposant as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(type.customFields),
normalizeExistingCustomFieldDefinitions(actualComponent?.customFields),
normalizeExistingCustomFieldDefinitions((requirement.typeComposant as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(requirement.customFields),
normalizeExistingCustomFieldDefinitions((requirement.definition as AnyRecord)?.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
),
)
@@ -464,9 +432,11 @@ export function useMachineDetailData(machineId: string) {
: []
const constructeurIds = uniqueConstructeurIds(
component.constructeurs,
component.constructeurIds,
component.constructeurId,
component.constructeur,
actualComponent?.constructeurs,
actualComponent?.constructeurIds,
actualComponent?.constructeurId,
actualComponent?.constructeur,
@@ -505,7 +475,6 @@ export function useMachineDetailData(machineId: string) {
constructeurId: constructeurIds[0] || null,
typeComposantId:
component.typeComposantId ||
(component.typeMachineComponentRequirement as AnyRecord)?.typeComposantId ||
(component.typeComposant as AnyRecord)?.id ||
null,
__productDisplay: productDisplay,
@@ -573,6 +542,87 @@ export function useMachineDetailData(machineId: string) {
})
})
const machineDirectProducts = computed(() => {
return machineProductLinks.value.map((link) => {
const productObj = link.product as AnyRecord | string | null
let resolved: AnyRecord | null = null
let productId: string | null = null
if (typeof productObj === 'string') {
productId = productObj.split('/').pop() || null
resolved = productId ? findProductById(productId) : null
} else if (productObj && typeof productObj === 'object') {
productId = (productObj as AnyRecord)?.id as string | null
// Prefer the embedded product from the structure endpoint — it has richer
// data (typeProduct as object, supplierPrice, constructeurs) than the
// global products cache which may store typeProduct as an IRI string.
const cached = productId ? findProductById(productId) : null
resolved = productObj as AnyRecord
if (cached) {
// Merge: use embedded as base, overlay any non-null cached fields
resolved = { ...resolved, ...Object.fromEntries(
Object.entries(cached as AnyRecord).filter(([, v]) => v != null && v !== ''),
) }
// But always prefer the embedded typeProduct when it's an object
if (productObj.typeProduct && typeof productObj.typeProduct === 'object') {
resolved.typeProduct = productObj.typeProduct
}
}
}
const constructeurIds = uniqueConstructeurIds(
resolved?.constructeurs,
resolved?.constructeurIds,
)
const resolvedConstructeurs = resolveConstructeurs(
constructeurIds,
resolved?.constructeurs as any[] || [],
constructeurs.value,
)
return {
id: (resolved?.id as string) || productId || null,
linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null,
name: (resolved?.name as string) || 'Produit inconnu',
reference: (resolved?.reference as string) || null,
supplierLabel: resolvedConstructeurs.length
? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null
: getProductSuppliersLabel(resolved),
priceLabel: resolved ? getProductPriceLabel(resolved) : null,
groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '',
documents: productId ? (productDocumentsMap.value.get(productId) || []) : [],
}
})
})
const loadProductDocuments = async () => {
const productIds = machineProductLinks.value
.map((link) => {
const p = link.product as AnyRecord | string | null
if (typeof p === 'string') return p.split('/').pop() || null
return (p as AnyRecord)?.id as string | null
})
.filter((id): id is string => !!id)
const results = await Promise.allSettled(
productIds.map(async (id) => {
const result: any = await loadDocumentsByProduct(id, { updateStore: false })
if (result.success && Array.isArray(result.data)) {
return { id, docs: result.data as AnyRecord[] }
}
return { id, docs: [] }
}),
)
const map = new Map<string, AnyRecord[]>()
results.forEach((r) => {
if (r.status === 'fulfilled' && r.value.docs.length) {
map.set(r.value.id, r.value.docs)
}
})
productDocumentsMap.value = map
}
const machineDocumentsList = computed(
() => ((machine.value as AnyRecord)?.documents as AnyRecord[]) || [],
)
@@ -583,166 +633,6 @@ export function useMachineDetailData(machineId: string) {
return fields.filter((field) => shouldDisplayCustomField(field))
})
const componentRequirementGroups = computed(() => {
const reqs = ((machine.value as AnyRecord)?.typeMachine as AnyRecord)?.componentRequirements as AnyRecord[] || []
if (!reqs.length) return []
const groups = reqs.map((requirement: AnyRecord) => ({
requirement,
components: [] as AnyRecord[],
}))
const map = new Map(groups.map((g) => [g.requirement.id, g]))
flattenedComponents.value.forEach((component) => {
const reqId = component.typeMachineComponentRequirementId as string
if (reqId && map.has(reqId)) {
map.get(reqId)!.components.push({
...component,
__productDisplay: getProductDisplay(component),
})
}
})
return groups
})
const pieceRequirementGroups = computed(() => {
const reqs = ((machine.value as AnyRecord)?.typeMachine as AnyRecord)?.pieceRequirements as AnyRecord[] || []
if (!reqs.length) return []
const groups = reqs.map((requirement: AnyRecord) => ({
requirement,
pieces: [] as AnyRecord[],
}))
const map = new Map(groups.map((g) => [g.requirement.id, g]))
const collectPieces = (): AnyRecord[] => {
const collected: AnyRecord[] = []
machinePieces.value.forEach((piece) => {
collected.push({
...piece,
constructeurs: piece.constructeurs || [],
parentComponentName: null,
__productDisplay: getProductDisplay(piece),
})
})
flattenedComponents.value.forEach((component) => {
if (Array.isArray(component.pieces) && (component.pieces as AnyRecord[]).length) {
;(component.pieces as AnyRecord[]).forEach((piece) => {
collected.push({
...piece,
constructeurs: piece.constructeurs || [],
parentComponentName: component.name,
__productDisplay: getProductDisplay(piece),
})
})
}
})
return collected
}
collectPieces().forEach((piece) => {
const reqId = piece.typeMachinePieceRequirementId as string
if (reqId && map.has(reqId)) {
map.get(reqId)!.pieces.push(piece)
}
})
return groups
})
const productRequirementGroups = computed(() => {
const reqs = ((machine.value as AnyRecord)?.typeMachine as AnyRecord)?.productRequirements as AnyRecord[] || []
if (!reqs.length) return []
const componentAggregates = flattenedComponents.value || []
const pieceAggregates = collectPiecesForSkeleton()
const links = Array.isArray(machineProductLinks.value) ? machineProductLinks.value : []
return reqs.map((requirement: AnyRecord) => {
const typeProductId =
(requirement.typeProductId as string) ||
(requirement.typeProduct as AnyRecord)?.id as string ||
null
const directProducts = links
.filter((link) => {
const requirementId = resolveIdentifier(
link?.typeMachineProductRequirementId,
link?.requirementId,
)
return requirementId === requirement.id
})
.map((link) => {
const productId = resolveIdentifier(link?.productId, (link?.product as AnyRecord)?.id)
const product =
productId ? findProductById(productId as string) : (link?.product as AnyRecord) ?? null
const supplierLabel = Array.isArray((product as AnyRecord)?.constructeurs)
? ((product as AnyRecord).constructeurs as AnyRecord[])
.map((c) => c?.name)
.filter(Boolean)
.join(', ')
: (link?.constructeursLabel as string) || null
const priceValue =
(product as AnyRecord)?.supplierPrice ?? link?.supplierPrice ?? null
let priceLabel: string | null = null
if (priceValue !== undefined && priceValue !== null) {
const numericPrice = Number(priceValue)
if (!Number.isNaN(numericPrice)) {
priceLabel = `${numericPrice.toFixed(2)}`
}
}
return {
id: productId || link?.id || null,
name: (product as AnyRecord)?.name || link?.productName || productId || 'Produit',
reference: (product as AnyRecord)?.reference || link?.reference || null,
supplierLabel,
priceLabel,
}
})
let componentCount = 0
componentAggregates.forEach((component) => {
const componentTypeProductId =
(component?.product as AnyRecord)?.typeProductId ||
((component?.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
null
if (typeProductId && componentTypeProductId === typeProductId) componentCount += 1
})
let pieceCount = 0
pieceAggregates.forEach((piece) => {
const pieceTypeProductId =
(piece?.product as AnyRecord)?.typeProductId ||
((piece?.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
null
if (typeProductId && pieceTypeProductId === typeProductId) pieceCount += 1
})
return {
requirement,
directProducts,
componentCount,
pieceCount,
totalCount: directProducts.length + componentCount + pieceCount,
}
})
})
const machineDirectProducts = computed(() => {
return productRequirementGroups.value.flatMap((group: AnyRecord) =>
((group.directProducts as AnyRecord[]) || []).map((product) => ({
...product,
groupLabel:
(group.requirement as AnyRecord).label ||
((group.requirement as AnyRecord).typeProduct as AnyRecord)?.name ||
'Produit requis',
})),
)
})
// ---------------------------------------------------------------------------
// Machine field methods
// ---------------------------------------------------------------------------
@@ -784,9 +674,6 @@ export function useMachineDetailData(machineId: string) {
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
normalizeExistingCustomFieldDefinitions(
(machine.value.typeMachine as AnyRecord)?.customFields,
),
),
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
machineCustomFields.value = merged
@@ -977,6 +864,7 @@ export function useMachineDetailData(machineId: string) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
}
loadProductDocuments().catch(() => {})
}
}
} catch (error) {
@@ -1126,6 +1014,11 @@ export function useMachineDetailData(machineId: string) {
collapseToggleToken.value += 1
}
const toggleAllPieces = () => {
piecesCollapsed.value = !piecesCollapsed.value
pieceCollapseToggleToken.value += 1
}
// ---------------------------------------------------------------------------
// Print wrappers
// ---------------------------------------------------------------------------
@@ -1146,16 +1039,118 @@ export function useMachineDetailData(machineId: string) {
)
// ---------------------------------------------------------------------------
// Piece aggregation (used by skeleton & product requirement groups)
// Structure link management
// ---------------------------------------------------------------------------
const collectPiecesForSkeleton = (): AnyRecord[] => {
const aggregated: AnyRecord[] = []
machinePieces.value.forEach((piece) => aggregated.push(piece))
flattenedComponents.value.forEach((component) => {
;((component.pieces as AnyRecord[]) || []).forEach((piece) => aggregated.push(piece))
const reloadMachineStructure = async () => {
const result: any = await get(`/machines/${machineId}/structure`)
if (result.success) {
const machinePayload =
result.data?.machine && typeof result.data.machine === 'object'
? result.data.machine
: result.data
if (machinePayload && typeof machinePayload === 'object') {
machine.value = {
...machine.value,
...machinePayload,
documents: machinePayload.documents || (machine.value as AnyRecord)?.documents || [],
customFieldValues: machinePayload.customFieldValues || (machine.value as AnyRecord)?.customFieldValues || [],
}
const linksApplied = applyMachineLinks(result.data)
if (linksApplied && machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
machine.value.productLinks = machineProductLinks.value
}
syncMachineCustomFields()
}
}
}
const addComponentLink = async (composantId: string) => {
const result: any = await apiPost('/machine_component_links', {
machine: `/api/machines/${machineId}`,
composant: `/api/composants/${composantId}`,
})
return aggregated
if (result.success) {
toast.showSuccess('Composant ajouté à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout du composant')
}
return result
}
const removeComponentLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_component_links/${linkId}`)
if (result.success) {
toast.showSuccess('Composant retiré de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression du composant')
}
return result
}
const addPieceLink = async (pieceId: string, parentComponentLinkId?: string) => {
const payload: any = {
machine: `/api/machines/${machineId}`,
piece: `/api/pieces/${pieceId}`,
}
if (parentComponentLinkId) {
payload.parentLink = `/api/machine_component_links/${parentComponentLinkId}`
}
const result: any = await apiPost('/machine_piece_links', payload)
if (result.success) {
toast.showSuccess('Pièce ajoutée à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout de la pièce')
}
return result
}
const removePieceLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_piece_links/${linkId}`)
if (result.success) {
toast.showSuccess('Pièce retirée de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression de la pièce')
}
return result
}
const addProductLink = async (productId: string, parentComponentLinkId?: string, parentPieceLinkId?: string) => {
const payload: any = {
machine: `/api/machines/${machineId}`,
product: `/api/products/${productId}`,
}
if (parentComponentLinkId) {
payload.parentComponentLink = `/api/machine_component_links/${parentComponentLinkId}`
}
if (parentPieceLinkId) {
payload.parentPieceLink = `/api/machine_piece_links/${parentPieceLinkId}`
}
const result: any = await apiPost('/machine_product_links', payload)
if (result.success) {
toast.showSuccess('Produit ajouté à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout du produit')
}
return result
}
const removeProductLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_product_links/${linkId}`)
if (result.success) {
toast.showSuccess('Produit retiré de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression du produit')
}
return result
}
// ---------------------------------------------------------------------------
@@ -1165,7 +1160,7 @@ export function useMachineDetailData(machineId: string) {
const loadMachineData = async () => {
loading.value = true
try {
const machineResult: any = await get(`/machines/${machineId}/skeleton`)
const machineResult: any = await get(`/machines/${machineId}/structure`)
if (!machineResult.success) {
console.error('Machine non trouvée:', machineId, machineResult.error)
@@ -1233,6 +1228,9 @@ export function useMachineDetailData(machineId: string) {
collapseAllComponents()
// Load product documents in background
loadProductDocuments().catch(() => {})
// Wait for documents if still loading
await documentPromise
} catch (error) {
@@ -1256,7 +1254,6 @@ export function useMachineDetailData(machineId: string) {
watch(() => (machine.value as AnyRecord)?.customFieldValues, () => syncMachineCustomFields(), { deep: true })
watch(() => (machine.value as AnyRecord)?.customFields, () => syncMachineCustomFields(), { deep: true })
watch(() => ((machine.value as AnyRecord)?.typeMachine as AnyRecord)?.customFields, () => syncMachineCustomFields(), { deep: true })
watch(
() => [components.value.length, machinePieces.value.length],
() => ensurePrintSelectionEntries(),
@@ -1298,13 +1295,10 @@ export function useMachineDetailData(machineId: string) {
debug,
componentsCollapsed,
collapseToggleToken,
piecesCollapsed,
pieceCollapseToggleToken,
// Computed
machineType,
componentRequirements,
pieceRequirements,
productRequirements,
machineHasSkeletonRequirements,
componentTypeOptions,
pieceTypeOptions,
componentTypeLabelMap,
@@ -1313,12 +1307,9 @@ export function useMachineDetailData(machineId: string) {
productById,
flattenedComponents,
machinePieces,
machineDirectProducts,
machineDocumentsList,
visibleMachineCustomFields,
componentRequirementGroups,
pieceRequirementGroups,
productRequirementGroups,
machineDirectProducts,
// Product helpers
findProductById,
@@ -1331,7 +1322,6 @@ export function useMachineDetailData(machineId: string) {
findComponentById,
findPieceById,
collectConstructeurs,
collectPiecesForSkeleton,
// Transform
transformCustomFields,
@@ -1370,6 +1360,7 @@ export function useMachineDetailData(machineId: string) {
toggleEditMode,
toggleAllComponents,
collapseAllComponents,
toggleAllPieces,
// Print
printModalOpen,
@@ -1384,10 +1375,19 @@ export function useMachineDetailData(machineId: string) {
loadMachineData,
loadInitialData,
// External (needed by skeleton editor)
// Structure link management
addComponentLink,
removeComponentLink,
addPieceLink,
removePieceLink,
addProductLink,
removeProductLink,
reloadMachineStructure,
// External
constructeurs,
loadProducts,
reconfigureMachineSkeleton,
updateMachineStructure,
toast,
// Re-exports for template

View File

@@ -153,8 +153,6 @@ export const buildMachineHierarchyFromLinks = (
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
const requirement = (link.typeMachinePieceRequirement || appliedPiece.typeMachinePieceRequirement || originalPiece?.typeMachinePieceRequirement || null) as AnyRecord | null
const machinePieceLinkId = normalizePieceLinkId(link)
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
@@ -170,11 +168,8 @@ export const buildMachineHierarchyFromLinks = (
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null,
constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || null,
documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [],
typePiece: appliedPiece.typePiece || requirement?.typePiece || null,
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
typeMachinePieceRequirement: requirement,
typeMachinePieceRequirementId: requirement?.id || null,
requirementId: requirement?.id || null,
typePiece: appliedPiece.typePiece || null,
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || null,
overrides,
originalPiece,
machinePieceLink: link,
@@ -186,11 +181,8 @@ export const buildMachineHierarchyFromLinks = (
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
parentMachineComponentRequirementId: resolveIdentifier(appliedPiece.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
parentMachinePieceRequirementId: resolveIdentifier(appliedPiece.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
definition: appliedPiece.definition || originalPiece?.definition || {},
customFields: appliedPiece.customFields || [],
skeletonOnly: !pieceId,
}
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
@@ -215,8 +207,6 @@ export const buildMachineHierarchyFromLinks = (
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
const requirement = (link.typeMachineComponentRequirement || appliedComponent.typeMachineComponentRequirement || originalComponent?.typeMachineComponentRequirement || null) as AnyRecord | null
const machineComponentLinkId = normalizeComponentLinkId(link)
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
@@ -245,11 +235,8 @@ export const buildMachineHierarchyFromLinks = (
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null,
constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || null,
documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [],
typeComposant: appliedComponent.typeComposant || requirement?.typeComposant || null,
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
typeMachineComponentRequirement: requirement,
typeMachineComponentRequirementId: requirement?.id || null,
requirementId: requirement?.id || null,
typeComposant: appliedComponent.typeComposant || null,
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || null,
overrides: compOverrides,
machineComponentLinkOverrides: compOverrides,
definitionOverrides: compOverrides,
@@ -259,16 +246,12 @@ export const buildMachineHierarchyFromLinks = (
componentLinkId: machineComponentLinkId,
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
parentRequirementId: resolveIdentifier(appliedComponent.parentRequirementId, link.parentRequirementId),
parentMachineComponentRequirementId: resolveIdentifier(appliedComponent.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
parentMachinePieceRequirementId: resolveIdentifier(appliedComponent.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
definition: appliedComponent.definition || originalComponent?.definition || {},
customFields: appliedComponent.customFields || [],
pieces,
subComponents,
subcomponents: subComponents,
sousComposants: subComponents,
skeletonOnly: !composantId,
}
const constructeurs = collectConstructeurs(allConstructeurs, appliedComponent.constructeurs, appliedComponent.constructeur, appliedComponent.constructeurIds, appliedComponent.constructeurId, originalComponent?.constructeurs, originalComponent?.constructeur, originalComponent?.constructeurIds, originalComponent?.constructeurId)

View File

@@ -1,838 +0,0 @@
/**
* 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,
}
}

View File

@@ -1,186 +0,0 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi, type ApiResponse } from './useApi'
import { extractRelationId } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface MachineTypeRequirement {
id?: string
label?: string
minCount?: number
maxCount?: number
required?: boolean
[key: string]: unknown
}
export interface MachineType {
id: string
name: string
componentRequirements: MachineTypeRequirement[]
pieceRequirements: MachineTypeRequirement[]
productRequirements: MachineTypeRequirement[]
[key: string]: unknown
}
const machineTypes = ref<MachineType[]>([])
const loading = ref(false)
const loaded = ref(false)
const normalizeRequirementList = (value: unknown, relationKey: string): MachineTypeRequirement[] => {
if (!Array.isArray(value)) {
return []
}
return value.map((entry: Record<string, unknown>, _index: number) => {
if (!entry || typeof entry !== 'object') {
return entry
}
const normalized = { ...entry }
const relationField = relationKey.replace('Id', '')
const relationValue = normalized[relationField]
if (relationKey && !normalized[relationKey]) {
const relationId = extractRelationId(relationValue)
if (relationId) {
normalized[relationKey] = relationId
}
}
return normalized as MachineTypeRequirement
})
}
const normalizeMachineType = (type: Record<string, unknown>): MachineType | null => {
if (!type || typeof type !== 'object') {
return null
}
return {
...type,
componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'),
pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'),
productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'),
} as MachineType
}
export function useMachineTypesApi() {
const { showSuccess } = useToast()
const { get, post, put, delete: del } = useApi()
const loadMachineTypes = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true
try {
const result = await get('/type_machines')
if (result.success) {
const items = extractCollection(result.data)
machineTypes.value = items
.map((item) => normalizeMachineType(item as Record<string, unknown>))
.filter((item): item is MachineType => item !== null)
loaded.value = true
}
} catch (error) {
console.error('Erreur lors du chargement des types de machines:', error)
} finally {
loading.value = false
}
}
const createMachineType = async (typeData: Partial<MachineType>): Promise<ApiResponse> => {
loading.value = true
try {
const result = await post('/type_machines', typeData)
if (result.success) {
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
if (normalized) machineTypes.value.push(normalized)
showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
}
return result
} catch (error) {
console.error('Erreur lors de la création du type de machine:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const updateMachineType = async (id: string, typeData: Partial<MachineType>): Promise<ApiResponse> => {
loading.value = true
try {
const result = await put(`/type_machines/${id}`, typeData)
if (result.success) {
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
const index = machineTypes.value.findIndex((type) => type.id === id)
if (index !== -1 && normalized) {
machineTypes.value[index] = normalized
}
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
}
return result
} catch (error) {
console.error('Erreur lors de la mise à jour du type de machine:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const deleteMachineType = async (id: string): Promise<ApiResponse> => {
loading.value = true
try {
const result = await del(`/type_machines/${id}`)
if (result.success) {
const deletedType = machineTypes.value.find((type) => type.id === id)
machineTypes.value = machineTypes.value.filter((type) => type.id !== id)
showSuccess(`Type de machine "${deletedType?.name || 'inconnu'}" supprimé avec succès`)
}
return result
} catch (error) {
console.error('Erreur lors de la suppression du type de machine:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const getMachineTypeById = async (id: string, forceRefresh = false): Promise<ApiResponse> => {
// D'abord chercher dans le cache local (sauf si forceRefresh)
if (!forceRefresh) {
const localType = machineTypes.value.find((type) => type.id === id)
if (localType) {
return { success: true, data: localType }
}
}
// Récupérer depuis l'API
try {
const result = await get(`/type_machines/${id}`)
if (result.success) {
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
// Mettre à jour le cache local
const index = machineTypes.value.findIndex((type) => type.id === id)
if (index !== -1 && normalized) {
machineTypes.value[index] = normalized
} else if (normalized) {
machineTypes.value.push(normalized)
}
return { success: true, data: normalized }
}
return result
} catch (error) {
console.error('Erreur lors de la récupération du type de machine:', error)
return { success: false, error: (error as Error).message }
}
}
const getMachineTypes = (): MachineType[] => machineTypes.value
const isLoading = (): boolean => loading.value
return {
machineTypes,
loading,
loadMachineTypes,
createMachineType,
updateMachineType,
deleteMachineType,
getMachineTypeById,
getMachineTypes,
isLoading,
}
}

View File

@@ -9,7 +9,6 @@ export interface Machine {
id: string
name?: string
siteId?: string | null
typeMachineId?: string | null
componentLinks?: unknown[]
pieceLinks?: unknown[]
[key: string]: unknown
@@ -53,13 +52,6 @@ const normalizeMachineResponse = (payload: unknown): Machine | null => {
}
}
if (!normalized.typeMachineId) {
const typeMachineId = extractRelationId(container.typeMachine)
if (typeMachineId) {
normalized.typeMachineId = typeMachineId
}
}
const componentLinks = resolveLinkCollection(raw, ['componentLinks', 'machineComponentLinks']) ??
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
[]
@@ -121,15 +113,6 @@ export function useMachines() {
}
}
const createMachineFromType = async (machineData: Partial<Machine>, typeMachine: { id: string }): Promise<ApiResponse> => {
const machineWithStructure = {
...machineData,
typeMachineId: typeMachine.id,
}
return await createMachine(machineWithStructure)
}
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
loading.value = true
try {
@@ -157,14 +140,14 @@ export function useMachines() {
}
}
const reconfigureSkeleton = async (machineId: string, payload: unknown): Promise<ApiResponse> => {
const updateStructure = async (machineId: string, payload: unknown): Promise<ApiResponse> => {
if (!machineId) {
return { success: false, error: 'Identifiant de machine manquant' }
}
loading.value = true
try {
const result = await patch(`/machines/${machineId}/skeleton`, payload)
const result = await patch(`/machines/${machineId}/structure`, payload)
if (result.success) {
const index = machines.value.findIndex((machine) => machine.id === machineId)
if (index !== -1) {
@@ -180,7 +163,29 @@ export function useMachines() {
}
return result
} catch (error) {
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error)
console.error('Erreur lors de la mise à jour de la structure de la machine:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string }): Promise<ApiResponse> => {
loading.value = true
try {
const result = await post(`/machines/${sourceId}/clone`, data)
if (result.success) {
const clonedMachine = normalizeMachineResponse(result.data) ||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
null
if (clonedMachine) {
machines.value.push(clonedMachine)
}
showSuccess(`Machine "${clonedMachine?.name || data.name}" clonée avec succès`)
}
return result
} catch (error) {
console.error('Erreur lors du clonage de la machine:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
@@ -241,10 +246,6 @@ export function useMachines() {
return machines.value.filter((machine) => machine.siteId === siteId)
}
const getMachinesByType = (typeMachineId: string): Machine[] => {
return machines.value.filter((machine) => machine.typeMachineId === typeMachineId)
}
const getMachines = (): Machine[] => machines.value
const isLoading = (): boolean => loading.value
@@ -253,13 +254,12 @@ export function useMachines() {
loading,
loadMachines,
createMachine,
createMachineFromType,
updateMachine: updateMachineData,
reconfigureSkeleton,
updateStructure,
cloneMachine,
deleteMachine,
getMachineById,
getMachinesBySite,
getMachinesByType,
getMachines,
isLoading,
addMissingCustomFields,

View File

@@ -887,7 +887,6 @@ const submitEdition = async () => {
updatedComponent.id,
[
updatedComponent?.typeComposant?.customFields,
updatedComponent?.typeMachineComponentRequirement?.typeComposant?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)

View File

@@ -1219,7 +1219,6 @@ const saveCustomFieldValues = async (createdComponent: any) => {
}
registerDefinitions(createdComponent?.typeComposant?.customFields)
registerDefinitions(createdComponent?.typeMachineComponentRequirement?.typeComposant?.customFields)
const resolveDefinitionId = (field: CustomFieldInput) => {
if (field.customFieldId) {

View File

@@ -49,35 +49,18 @@
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
<span class="label-text">Site</span>
</label>
<select v-model="selectedType" class="select select-bordered">
<select v-model="selectedSiteFilter" class="select select-bordered">
<option value="">
Tous les types
Tous les sites
</option>
<option
v-for="type in machineTypes"
:key="type.id"
:value="type.id"
v-for="site in sites"
:key="site.id"
:value="site.id"
>
{{ type.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie</span>
</label>
<select v-model="selectedCategory" class="select select-bordered">
<option value="">
Toutes les catégories
</option>
<option
v-for="category in categories"
:key="category"
:value="category"
>
{{ category }}
{{ site.name }}
</option>
</select>
</div>
@@ -208,27 +191,9 @@
<h4 class="font-semibold text-sm">
{{ machine.name }}
</h4>
<div
class="badge badge-sm"
:class="
getCategoryBadgeClass(machine.typeMachine?.category)
"
>
{{ machine.typeMachine?.category || "N/A" }}
</div>
</div>
<div class="space-y-1 text-xs text-gray-600">
<div class="flex items-center gap-1">
<IconLucideSettings2
class="w-3 h-3"
aria-hidden="true"
/>
<span>{{
machine.typeMachine?.name || "Type inconnu"
}}</span>
</div>
<div
v-if="machine.reference"
class="flex items-center gap-1"
@@ -373,78 +338,17 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
</label>
<select
v-model="newMachine.typeMachineId"
class="select select-bordered"
:disabled="!canEdit"
required
>
<option value="">
Sélectionner un type
</option>
<option
v-for="type in machineTypes"
:key="type.id"
:value="type.id"
>
{{ type.name }} ({{ type.category }})
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
:disabled="!canEdit"
>
</div>
</div>
<!-- Type Preview -->
<div
v-if="selectedMachineType"
class="mb-4 p-4 bg-gray-50 rounded-lg"
>
<h4 class="font-semibold text-sm mb-2">
Structure du type sélectionné :
</h4>
<div class="text-xs space-y-1">
<div class="flex items-center gap-2">
<span class="font-medium">Familles de composants :</span>
<span class="badge badge-sm">{{
selectedMachineType.componentRequirements?.length || 0
}}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">Groupes de pièces :</span>
<span class="badge badge-sm">{{
selectedMachineType.pieceRequirements?.length || 0
}}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">Produits requis :</span>
<span class="badge badge-sm">{{
selectedMachineType.productRequirements?.length || 0
}}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">Catégorie :</span>
<span class="badge badge-outline badge-sm">{{
selectedMachineType.category
}}</span>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
:disabled="!canEdit"
>
</div>
<div class="modal-action">
@@ -469,7 +373,6 @@
import { ref, reactive, onMounted, computed } from 'vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useMachines } from '~/composables/useMachines'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
@@ -479,22 +382,19 @@ import IconLucideUser from '~icons/lucide/user'
import IconLucidePhone from '~icons/lucide/phone'
import IconLucideMapPinned from '~icons/lucide/map-pinned'
import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideTag from '~icons/lucide/tag'
import { formatPhone } from '~/utils/formatters/phone'
import { extractRelationId } from '~/shared/apiRelations'
const { canEdit } = usePermissions()
const { sites, loading, loadSites, createSite } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
// Data
const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false)
const searchTerm = ref('')
const selectedType = ref('')
const selectedCategory = ref('')
const selectedSiteFilter = ref('')
const collapsedSites = ref([])
const newSite = reactive({
@@ -509,48 +409,14 @@ const newSite = reactive({
const newMachine = reactive({
name: '',
siteId: '',
typeMachineId: '',
reference: ''
})
// Computed
const selectedMachineType = computed(() => {
if (!newMachine.typeMachineId) { return null }
return machineTypes.value.find(
type => type.id === newMachine.typeMachineId
)
})
const categories = computed(() => {
const cats = new Set()
machineTypes.value.forEach((type) => {
if (type.category) { cats.add(type.category) }
})
return Array.from(cats)
})
const machinesWithType = computed(() => {
return machines.value.map((machine) => {
const resolvedTypeMachineId = machine.typeMachineId || extractRelationId(machine.typeMachine)
const resolvedTypeMachine = resolvedTypeMachineId
? machineTypes.value.find(type => type.id === resolvedTypeMachineId) || null
: null
return {
...machine,
typeMachineId: resolvedTypeMachineId || machine.typeMachineId,
typeMachine:
machine.typeMachine && typeof machine.typeMachine === 'object'
? machine.typeMachine
: resolvedTypeMachine
}
})
})
const machinesBySiteId = computed(() => {
const map = new Map()
machinesWithType.value.forEach((machine) => {
machines.value.forEach((machine) => {
const siteId = machine.siteId || extractRelationId(machine.site)
if (!siteId) { return }
@@ -588,6 +454,11 @@ const formatPhoneDisplay = (value) => {
const filteredSites = computed(() => {
let filtered = sitesWithMachines.value
// Filtrer par site
if (selectedSiteFilter.value) {
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
}
// Filtrer par terme de recherche
if (searchTerm.value) {
filtered = filtered.filter((site) => {
@@ -616,33 +487,6 @@ const filteredSites = computed(() => {
})
}
// Filtrer par type de machine
if (selectedType.value) {
filtered = filtered
.map(site => ({
...site,
machines:
site.machines?.filter(
machine => machine.typeMachineId === selectedType.value
) || []
}))
.filter(site => site.machines.length > 0)
}
// Filtrer par catégorie
if (selectedCategory.value) {
filtered = filtered
.map(site => ({
...site,
machines:
site.machines?.filter(
machine =>
machine.typeMachine?.category === selectedCategory.value
) || []
}))
.filter(site => site.machines.length > 0)
}
return filtered
})
@@ -670,27 +514,15 @@ const handleCreateSite = async () => {
}
const handleCreateMachine = async () => {
if (!selectedMachineType.value) {
console.error('Aucun type de machine sélectionné')
return
}
const machineData = {
const result = await createMachine({
name: newMachine.name,
siteId: newMachine.siteId,
reference: newMachine.reference
}
const result = await createMachineFromType(
machineData,
selectedMachineType.value
)
})
if (result.success) {
// Reset form
newMachine.name = ''
newMachine.siteId = ''
newMachine.typeMachineId = ''
newMachine.reference = ''
showAddMachineModal.value = false
await loadMachines()
@@ -745,19 +577,8 @@ const addMachineToSite = (site) => {
showAddMachineModal.value = true
}
const getCategoryBadgeClass = (category) => {
const classes = {
Production: 'badge-primary',
Transformation: 'badge-secondary',
Manutention: 'badge-accent',
Traitement: 'badge-info',
Contrôle: 'badge-warning'
}
return classes[category] || 'badge-neutral'
}
// Lifecycle
onMounted(async () => {
await Promise.all([loadSites(), loadMachineTypes(), loadMachines()])
await Promise.all([loadSites(), loadMachines()])
})
</script>

View File

@@ -1,164 +0,0 @@
<template>
<main class="container mx-auto px-6 py-8">
<!-- Machine Types List -->
<div class="my-8">
<!-- Header with Add Button -->
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-800">Squelettes de machine</h2>
<NuxtLink to="/machine-skeleton/new" class="btn btn-primary">
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
Créer un type
</NuxtLink>
</div>
<!-- Categories Tabs -->
<div class="tabs tabs-boxed mb-6">
<a
v-for="category in categories"
:key="category"
class="tab"
:class="{ 'tab-active': selectedCategory === category }"
@click="selectedCategory = category"
>
{{ category }}
</a>
</div>
<!-- Machine Types Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="type in filteredTypes"
:key="type.id"
class="card bg-base-100 shadow-lg hover:shadow-xl transition-all duration-300 cursor-pointer"
>
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="card-title text-lg">
{{ type.name }}
</h3>
<div class="badge badge-primary">
{{ type.category }}
</div>
</div>
<p class="text-gray-600 mb-4">
{{ type.description }}
</p>
<div class="space-y-2 text-sm text-gray-500">
<div class="flex items-center gap-2">
<IconLucidePackage class="w-4 h-4" aria-hidden="true" />
<span
>{{ type.componentRequirements?.length || 0 }} famille(s) de
composants</span
>
</div>
<div class="flex items-center gap-2">
<IconLucideLayoutGrid class="w-4 h-4" aria-hidden="true" />
<span
>{{ type.pieceRequirements?.length || 0 }} groupe(s) de
pièces</span
>
</div>
<div class="flex items-center gap-2">
<IconLucideBox class="w-4 h-4" aria-hidden="true" />
<span
>{{ type.productRequirements?.length || 0 }} produit(s)
requis</span
>
</div>
</div>
<div class="card-actions justify-end mt-4">
<button
v-if="canEdit"
class="btn btn-sm btn-error"
@click.stop="confirmDeleteType(type)"
>
Supprimer
</button>
<NuxtLink :to="`/type/${type.id}`" class="btn btn-sm btn-outline">
Voir détails
</NuxtLink>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="filteredTypes.length === 0" class="text-center py-12">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-16">
<IconLucideLayoutGrid class="w-8 h-8" aria-hidden="true" />
</div>
</div>
<h3 class="text-lg font-semibold text-gray-600 mt-4">
Aucun type trouvé
</h3>
<p class="text-gray-500">
Aucun type de machine ne correspond à cette catégorie.
</p>
</div>
</div>
</main>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { useMachineTypesApi } from "~/composables/useMachineTypesApi";
import { useToast } from "~/composables/useToast";
import { humanizeError } from "~/shared/utils/errorMessages";
import IconLucidePlus from "~icons/lucide/plus";
import IconLucidePackage from "~icons/lucide/package";
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
import IconLucideBox from "~icons/lucide/box";
const { canEdit } = usePermissions();
const { machineTypes, loadMachineTypes, deleteMachineType } =
useMachineTypesApi();
const categories = ref([
"Toutes",
"Production",
"Transformation",
"Manutention",
"Traitement",
"Contrôle",
]);
const selectedCategory = ref("Toutes");
const filteredTypes = computed(() => {
if (selectedCategory.value === "Toutes") {
return machineTypes.value;
}
return machineTypes.value.filter(
(type) => type.category === selectedCategory.value
);
});
const { confirm: confirmDialog } = useConfirm();
const confirmDeleteType = async (type) => {
const { showError, showSuccess } = useToast();
if (
await confirmDialog({
message: `Êtes-vous sûr de vouloir supprimer le type "${type.name}" ? Cette action est irréversible.`,
})
) {
try {
const result = await deleteMachineType(type.id);
if (result.success) {
showSuccess(`Type "${type.name}" supprimé avec succès`);
} else {
showError(`Impossible de supprimer le type : ${result.error}`);
}
} catch (error) {
showError(`Impossible de supprimer le type : ${humanizeError(error.message)}`);
}
}
};
// Load machine types on mount
onMounted(async () => {
await loadMachineTypes();
});
</script>

View File

@@ -1,265 +0,0 @@
<template>
<main class="container mx-auto px-6 py-8 space-y-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body space-y-6">
<div class="flex items-center gap-3">
<span class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<IconLucidePlus
class="h-5 w-5"
aria-hidden="true"
/>
</span>
<div>
<h2 class="card-title text-2xl">
Nouveau type de machine
</h2>
<p class="text-sm text-gray-500">
Complétez les informations puis enregistrez pour générer le nouveau type.
</p>
</div>
</div>
<div :class="{ 'pointer-events-none opacity-60': !canEdit }">
<TypeEditForm
:key="formKey"
v-model="draftType"
:saving="!canEdit || creating"
:resettable="false"
submit-label="Créer le type"
submit-loading-label="Création..."
@submit="handleSubmit"
/>
</div>
</div>
</div>
<section class="space-y-4">
<div v-if="initialLoading" class="text-center py-12 text-sm text-gray-500">
Chargement des types existants...
</div>
<template v-else>
<div v-if="recentTypes.length" class="space-y-4">
<h3 class="text-xl font-semibold">
Types générés récemment
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<article
v-for="type in recentTypes"
:key="type.id"
class="card bg-base-100 shadow-md border border-base-200"
>
<div class="card-body space-y-2">
<div class="flex items-center justify-between">
<h4 class="card-title text-base">
{{ type.name }}
</h4>
<span v-if="type.category" class="badge badge-outline badge-sm">{{ type.category }}</span>
</div>
<p class="text-sm text-gray-600 line-clamp-3">
{{ type.description || 'Aucune description' }}
</p>
<div class="text-xs text-gray-500 flex items-center gap-2">
<span class="inline-flex items-center gap-1">
<IconLucideClipboardList class="h-4 w-4" aria-hidden="true" />
{{ type.componentRequirements?.length || 0 }} famille(s)
</span>
<span class="inline-flex items-center gap-1">
<IconLucideList class="h-4 w-4" aria-hidden="true" />
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
</span>
<span class="inline-flex items-center gap-1">
<IconLucideBox class="h-4 w-4" aria-hidden="true" />
{{ type.productRequirements?.length || 0 }} produit(s)
</span>
</div>
</div>
</article>
</div>
</div>
<div v-else class="text-center py-12 text-sm text-gray-500">
Aucun type généré récemment.
</div>
</template>
</section>
</main>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
import IconLucideList from '~icons/lucide/list'
import IconLucideBox from '~icons/lucide/box'
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
const { showError } = useToast()
const { canEdit } = usePermissions()
const formKey = ref(0)
const creating = ref(false)
const initialLoading = ref(true)
const createEmptyType = () => ({
name: '',
description: '',
category: '',
maintenanceFrequency: '',
customFields: [],
componentRequirements: [],
pieceRequirements: [],
productRequirements: []
})
const draftType = ref(createEmptyType())
const recentTypes = computed(() => machineTypes.value.slice(-3).reverse())
onMounted(async () => {
if (!machineTypes.value.length) {
try {
initialLoading.value = true
await loadMachineTypes()
} finally {
initialLoading.value = false
}
} else {
initialLoading.value = false
}
})
const parseOptions = (field = {}) => {
if (field.type !== 'select') { return [] }
if (field.optionsText && typeof field.optionsText === 'string') {
return field.optionsText
.split('\n')
.map(option => option.trim())
.filter(Boolean)
}
if (Array.isArray(field.options)) {
return field.options
.map(option => String(option).trim())
.filter(Boolean)
}
return []
}
const toModelTypeIri = (value) => {
if (!value) {
return undefined
}
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
return value
}
const relationId = extractRelationId(value)
if (relationId) {
return `/api/model_types/${relationId}`
}
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
}
const normalizeCustomFields = (fields = []) =>
fields
.filter(field => field?.name && field.name.trim() !== '')
.map((field, index) => ({
name: field.name,
type: field.type || '',
required: !!field.required,
options: parseOptions(field),
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
const toIntegerOrNull = (value, fallback = null) => {
if (value === '' || value === undefined || value === null) {
return fallback
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
const normalizeComponentRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeComposantId || req?.typeComposant)
.map((req, index) => ({
typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 1),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? true,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizePieceRequirements = (requirements = []) =>
requirements
.filter(req => req?.typePieceId || req?.typePiece)
.map((req, index) => ({
typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId || req?.typeProduct)
.map((req, index) => ({
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const buildPayload = typeData => ({
name: typeData.name,
description: typeData.description,
category: typeData.category,
maintenanceFrequency: typeData.maintenanceFrequency,
customFields: normalizeCustomFields(typeData.customFields),
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements),
productRequirements: normalizeProductRequirements(typeData.productRequirements)
})
const resetForm = () => {
draftType.value = createEmptyType()
formKey.value += 1
}
const handleSubmit = async () => {
if (!draftType.value.name?.trim()) {
showError('Le nom du type est requis.')
return
}
const payload = buildPayload(draftType.value)
creating.value = true
const result = await createMachineType(payload)
creating.value = false
if (result?.success) {
resetForm()
} else if (result?.error) {
showError(result.error)
} else {
showError('Impossible de créer le type.')
}
}
</script>

View File

@@ -17,117 +17,120 @@
<!-- Header with actions -->
<MachineDetailHeader
:title="machineViewTitle"
:is-details-view="s.isDetailsView.value"
:is-skeleton-view="s.isSkeletonView.value"
:is-edit-mode="d.isEditMode.value"
:has-skeleton-requirements="d.machineHasSkeletonRequirements.value"
@change-view="s.changeMachineView"
@toggle-edit="d.toggleEditMode"
@open-print="d.openPrintModal"
/>
<template v-if="s.isDetailsView.value">
<!-- Debug info -->
<div v-if="d.debug.value" class="bg-yellow-100 p-4 rounded-lg">
<p>Debug: Machine trouvée - {{ d.machine.value.name }}</p>
<p>Components count: {{ d.components.value.length }}</p>
<p>Pieces count: {{ d.pieces.value.length }}</p>
</div>
<!-- Debug info -->
<div v-if="d.debug.value" class="bg-yellow-100 p-4 rounded-lg">
<p>Debug: Machine trouvée - {{ d.machine.value.name }}</p>
<p>Components count: {{ d.components.value.length }}</p>
<p>Pieces count: {{ d.pieces.value.length }}</p>
</div>
<!-- Hero -->
<PageHero
:title="d.machine.value.name"
:subtitle="d.machine.value.description || d.machine.value.typeMachine?.description"
min-height="min-h-[20vh]"
max-width="max-w-md"
rounded
>
<div class="flex justify-center gap-4">
<div v-if="d.machine.value.typeMachine?.category" class="badge badge-outline">
{{ d.machine.value.typeMachine?.category }}
</div>
<div v-if="d.machine.value.site?.name" class="badge badge-outline">
{{ d.machine.value.site?.name }}
</div>
<div v-if="d.machine.value.reference" class="badge badge-outline">
{{ d.machine.value.reference }}
</div>
<!-- Hero -->
<PageHero
:title="d.machine.value.name"
:subtitle="d.machine.value.description"
min-height="min-h-[20vh]"
max-width="max-w-md"
rounded
>
<div class="flex justify-center gap-4">
<div v-if="d.machine.value.site?.name" class="badge badge-outline">
{{ d.machine.value.site?.name }}
</div>
<div v-if="d.machine.value.reference" class="badge badge-outline">
{{ d.machine.value.reference }}
</div>
</PageHero>
<!-- Machine Info Card -->
<MachineInfoCard
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo"
@set-custom-field-value="d.setMachineCustomFieldValue"
@update-custom-field="d.updateMachineCustomField"
/>
<!-- Documents -->
<MachineDocumentsCard
:documents="d.machineDocumentsList.value"
:is-edit-mode="d.isEditMode.value"
:uploading="d.machineDocumentsUploading.value"
:files="d.machineDocumentFiles.value"
@update:files="d.machineDocumentFiles.value = $event"
@files-added="d.handleMachineFilesAdded"
@preview="d.openPreview"
@download="d.downloadDocument"
@remove="d.removeMachineDocument"
/>
<!-- Produits associés -->
<MachineProductsCard :products="d.machineDirectProducts.value" />
<!-- Components Section -->
<MachineComponentsCard
:components="d.components.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.componentsCollapsed.value"
:collapse-toggle-token="d.collapseToggleToken.value"
@toggle-collapse="d.toggleAllComponents"
@update-component="d.updateComponent"
@edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.updatePieceCustomField"
/>
<!-- Machine Pieces Section -->
<MachinePiecesCard
:pieces="d.machinePieces.value"
:is-edit-mode="d.isEditMode.value"
@update-piece="d.updatePieceInfo"
@edit-piece="d.editPiece"
@custom-field-update="d.updatePieceCustomField"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="machine"
:entity-id="String(machineId)"
:entity-name="d.machine.value?.name"
show-resolved
/>
</div>
</template>
</PageHero>
<template v-else>
<MachineSkeletonSummary
:component-requirement-groups="d.componentRequirementGroups.value"
:piece-requirement-groups="d.pieceRequirementGroups.value"
:product-requirement-groups="d.productRequirementGroups.value"
<!-- Machine Info Card -->
<MachineInfoCard
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo"
@set-custom-field-value="d.setMachineCustomFieldValue"
@update-custom-field="d.updateMachineCustomField"
/>
<!-- Documents -->
<MachineDocumentsCard
:documents="d.machineDocumentsList.value"
:is-edit-mode="d.isEditMode.value"
:uploading="d.machineDocumentsUploading.value"
:files="d.machineDocumentFiles.value"
@update:files="d.machineDocumentFiles.value = $event"
@files-added="d.handleMachineFilesAdded"
@preview="d.openPreview"
@download="d.downloadDocument"
@remove="d.removeMachineDocument"
/>
<!-- Produits associés -->
<MachineProductsCard
:products="d.machineDirectProducts.value"
:is-edit-mode="d.isEditMode.value"
@add-product="openAddModal('product')"
@remove-product="d.removeProductLink"
/>
<!-- Components Section -->
<MachineComponentsCard
:components="d.components.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.componentsCollapsed.value"
:collapse-toggle-token="d.collapseToggleToken.value"
@toggle-collapse="d.toggleAllComponents"
@update-component="d.updateComponent"
@edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.updatePieceCustomField"
@add-component="openAddModal('component')"
@remove-component="d.removeComponentLink"
/>
<!-- Machine Pieces Section -->
<MachinePiecesCard
:pieces="d.machinePieces.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.piecesCollapsed.value"
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
@update-piece="d.updatePieceInfo"
@edit-piece="d.editPiece"
@custom-field-update="d.updatePieceCustomField"
@add-piece="openAddModal('piece')"
@remove-piece="d.removePieceLink"
@toggle-collapse="d.toggleAllPieces"
/>
<!-- Add Entity Modal -->
<AddEntityToMachineModal
:open="addModalOpen"
:entity-kind="addModalKind"
@close="addModalOpen = false"
@confirm="handleAddEntity"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="machine"
:entity-id="String(machineId)"
:entity-name="d.machine.value?.name"
show-resolved
/>
</template>
</div>
</div>
<!-- Error State -->
@@ -156,10 +159,9 @@
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMachineDetailData } from '~/composables/useMachineDetailData'
import { useMachineSkeletonEditor } from '~/composables/useMachineSkeletonEditor'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import PageHero from '~/components/PageHero.vue'
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
@@ -169,7 +171,7 @@ import MachineDocumentsCard from '~/components/machine/MachineDocumentsCard.vue'
import MachineProductsCard from '~/components/machine/MachineProductsCard.vue'
import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vue'
import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue'
import MachineSkeletonSummary from '~/components/machine/MachineSkeletonSummary.vue'
import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
const route = useRoute()
@@ -182,41 +184,25 @@ if (!machineId) {
const d = useMachineDetailData(machineId)
const s = useMachineSkeletonEditor({
machine: d.machine,
components: d.components,
pieces: d.pieces,
machineComponentLinks: d.machineComponentLinks,
machinePieceLinks: d.machinePieceLinks,
machineProductLinks: d.machineProductLinks,
machineType: d.machineType,
machineHasSkeletonRequirements: d.machineHasSkeletonRequirements,
componentRequirements: d.componentRequirements,
pieceRequirements: d.pieceRequirements,
productRequirements: d.productRequirements,
componentTypeLabelMap: d.componentTypeLabelMap,
pieceTypeLabelMap: d.pieceTypeLabelMap,
productInventory: d.productInventory,
flattenedComponents: d.flattenedComponents,
machinePieces: d.machinePieces,
machineDocumentsLoaded: d.machineDocumentsLoaded,
findProductById: d.findProductById,
findComponentById: d.findComponentById,
findPieceById: d.findPieceById,
transformCustomFields: d.transformCustomFields,
transformComponentCustomFields: d.transformComponentCustomFields,
applyMachineLinks: d.applyMachineLinks,
collapseAllComponents: d.collapseAllComponents,
initMachineFields: d.initMachineFields,
collectPiecesForSkeleton: d.collectPiecesForSkeleton,
constructeurs: d.constructeurs,
loadProducts: d.loadProducts,
reconfigureMachineSkeleton: d.reconfigureMachineSkeleton,
toast: d.toast,
})
const addModalOpen = ref(false)
const addModalKind = ref('component')
const openAddModal = (kind) => {
addModalKind.value = kind
addModalOpen.value = true
}
const handleAddEntity = async (entityId) => {
if (addModalKind.value === 'component') {
await d.addComponentLink(entityId)
} else if (addModalKind.value === 'piece') {
await d.addPieceLink(entityId)
} else {
await d.addProductLink(entityId)
}
}
const machineViewTitle = computed(() => {
if (s.isSkeletonView.value) return 'Squelette de la machine'
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
})

View File

@@ -13,7 +13,7 @@
<div class="card bg-base-100 shadow-lg mb-6">
<div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Site</span>
@@ -29,29 +29,14 @@
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
<span class="label-text">Recherche</span>
</label>
<select v-model="selectedType" class="select select-bordered">
<option value="">
Tous les types
</option>
<option v-for="type in machineTypes" :key="type.id" :value="type.id">
{{ type.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie</span>
</label>
<select v-model="selectedCategory" class="select select-bordered">
<option value="">
Toutes les catégories
</option>
<option v-for="category in categories" :key="category" :value="category">
{{ category }}
</option>
</select>
<input
v-model="searchQuery"
type="text"
placeholder="Rechercher par nom ou référence..."
class="input input-bordered"
>
</div>
</div>
</div>
@@ -88,26 +73,14 @@
<h3 class="card-title text-lg">
{{ machine.name }}
</h3>
<div class="badge badge-primary badge-sm">
{{ machine.typeMachine?.category || 'N/A' }}
</div>
</div>
<p class="text-sm text-gray-600 mb-3">
{{ machine.description || machine.typeMachine?.description }}
</p>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<IconLucideMapPin class="w-4 h-4 text-blue-500" aria-hidden="true" />
<span class="text-gray-600">{{ machine.site?.name || 'Site inconnu' }}</span>
</div>
<div class="flex items-center gap-2">
<IconLucideSettings2 class="w-4 h-4 text-green-500" aria-hidden="true" />
<span class="text-gray-600">{{ machine.typeMachine?.name || 'Type inconnu' }}</span>
</div>
<div v-if="machine.reference" class="flex items-center gap-2">
<IconLucideTag class="w-4 h-4 text-orange-500" aria-hidden="true" />
<span class="text-gray-600">{{ machine.reference }}</span>
@@ -136,44 +109,28 @@
import { ref, computed, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideTag from '~icons/lucide/tag'
const { canEdit } = usePermissions()
const { machines, loading, loadMachines, deleteMachine } = useMachines()
const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
const toast = useToast()
const selectedSite = ref('')
const selectedType = ref('')
const selectedCategory = ref('')
const searchQuery = ref('')
const categories = computed(() => {
const cats = new Set()
machineTypes.value.forEach((type) => {
if (type.category) {
cats.add(type.category)
}
})
return Array.from(cats)
})
// Enrichir les machines avec les objets site et typeMachine complets
// Enrichir les machines avec les objets site complets
const enrichedMachines = computed(() => {
return machines.value.map((machine) => {
const site = sites.value.find(s => s.id === machine.siteId)
const typeMachine = machineTypes.value.find(t => t.id === machine.typeMachineId)
return {
...machine,
site: site || null,
typeMachine: typeMachine || null
}
})
})
@@ -185,12 +142,12 @@ const filteredMachines = computed(() => {
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
}
if (selectedType.value) {
filtered = filtered.filter(machine => machine.typeMachineId === selectedType.value)
}
if (selectedCategory.value) {
filtered = filtered.filter(machine => machine.typeMachine?.category === selectedCategory.value)
if (searchQuery.value.trim()) {
const term = searchQuery.value.trim().toLowerCase()
filtered = filtered.filter(machine =>
machine.name?.toLowerCase().includes(term)
|| machine.reference?.toLowerCase().includes(term),
)
}
return filtered
@@ -227,7 +184,6 @@ onMounted(async () => {
await Promise.all([
loadMachines(),
loadSites(),
loadMachineTypes()
])
})
</script>

View File

@@ -1,13 +1,13 @@
<template>
<main class="container mx-auto px-6 py-8">
<div class="max-w-5xl mx-auto">
<div class="max-w-3xl mx-auto">
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
<div>
<h1 class="text-2xl font-bold">
Nouvelle machine
</h1>
<p class="text-sm text-gray-500">
Renseignez les informations et la configuration avant de créer la machine.
Renseignez les informations de base pour créer la machine.
</p>
</div>
<NuxtLink to="/machines" class="btn btn-ghost">
@@ -15,21 +15,25 @@
</NuxtLink>
</div>
<form class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
<div v-if="c.loading" class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg" />
</div>
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-6">
<!-- Basic fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="machine-field-name">
<span class="label-text">Nom de la machine</span>
<span class="label-text">Nom de la machine <span class="text-error">*</span></span>
</label>
<input
id="machine-field-name"
v-model="c.newMachine.name"
type="text"
placeholder="Ex: Presse hydraulique #1"
class="input input-bordered"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit"
required
>
@@ -37,9 +41,15 @@
<div class="form-control">
<label class="label" for="machine-field-site">
<span class="label-text">Site</span>
<span class="label-text">Site <span class="text-error">*</span></span>
</label>
<select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" :disabled="!canEdit" required>
<select
id="machine-field-site"
v-model="c.newMachine.siteId"
class="select select-bordered select-sm md:select-md"
:disabled="!canEdit"
required
>
<option value="">
Sélectionner un site
</option>
@@ -52,129 +62,43 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Type de machine</span>
<label class="label" for="machine-field-reference">
<span class="label-text">Référence</span>
</label>
<SearchSelect
v-model="c.newMachine.typeMachineId"
:options="c.machineTypes"
:loading="c.machineTypesLoading"
<input
id="machine-field-reference"
v-model="c.newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit"
placeholder="Rechercher un type…"
empty-text="Aucun type trouvé"
:option-label="c.machineTypeLabel"
:option-description="c.machineTypeDescription"
/>
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
<span class="label-text">Cloner depuis une machine</span>
</label>
<input
v-model="c.newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
<SearchSelect
v-model="c.newMachine.cloneFromMachineId"
:options="c.machines"
:disabled="!canEdit"
>
:clearable="true"
placeholder="Rechercher une machine..."
empty-text="Aucune machine trouvée"
/>
</div>
</div>
<!-- Type structure summary -->
<div v-if="c.selectedMachineType" class="p-4 bg-gray-50 rounded-lg space-y-2 text-sm">
<h4 class="font-semibold text-sm">
Structure du type sélectionné :
</h4>
<div class="flex flex-wrap gap-3">
<span class="inline-flex items-center gap-2">
<span class="font-medium">Familles de composants :</span>
<span class="badge badge-sm">{{ c.selectedMachineType.componentRequirements?.length || 0 }}</span>
</span>
<span class="inline-flex items-center gap-2">
<span class="font-medium">Groupes de pièces :</span>
<span class="badge badge-sm">{{ c.selectedMachineType.pieceRequirements?.length || 0 }}</span>
</span>
<span class="inline-flex items-center gap-2">
<span class="font-medium">Produits requis :</span>
<span class="badge badge-sm">{{ c.selectedMachineType.productRequirements?.length || 0 }}</span>
</span>
<span class="inline-flex items-center gap-2">
<span class="font-medium">Catégorie :</span>
<span class="badge badge-outline badge-sm">{{ c.selectedMachineType.category || 'N/A' }}</span>
</span>
</div>
<p
v-if="(c.selectedMachineType.componentRequirements?.length || 0) === 0 && (c.selectedMachineType.pieceRequirements?.length || 0) === 0 && (c.selectedMachineType.productRequirements?.length || 0) === 0"
class="text-xs text-gray-500"
>
Ce type n'a pas encore de familles configurées. La machine héritera de la structure legacy du type.
</p>
</div>
<!-- Requirement selectors -->
<RequirementComponentSelector
:requirements="c.selectedMachineType?.componentRequirements || []"
:loading="c.composantsLoading"
:get-entries="c.getComponentRequirementEntries"
:get-options="c.getComponentOptions"
:resolve-type-label="c.resolveComponentRequirementTypeLabel"
:find-by-id="c.findComponentById"
:option-label="c.componentOptionLabel"
:option-description="c.componentOptionDescription"
@add-entry="c.addComponentSelectionEntry"
@remove-entry="c.removeComponentSelectionEntry"
@set-component="c.setComponentRequirementComponent"
/>
<RequirementPieceSelector
:requirements="c.selectedMachineType?.pieceRequirements || []"
:loading="c.piecesLoading"
:piece-loading-by-key="c.pieceLoadingByKey"
:get-entries="c.getPieceRequirementEntries"
:get-options="c.getPieceOptions"
:get-piece-key="c.getPieceKey"
:resolve-type-label="c.resolvePieceRequirementTypeLabel"
:find-by-id="c.findPieceById"
:option-label="c.pieceOptionLabel"
:option-description="c.pieceOptionDescription"
@add-entry="c.addPieceSelectionEntry"
@remove-entry="c.removePieceSelectionEntry"
@set-piece="c.setPieceRequirementPiece"
@search="c.fetchPieceOptions"
/>
<RequirementProductSelector
:requirements="c.selectedMachineType?.productRequirements || []"
:products-loading="c.productsLoading"
:get-entries="c.getProductRequirementEntries"
:get-product-options="c.getProductOptions"
:find-by-id="c.findProductById"
@add-entry="c.addProductSelectionEntry"
@remove-entry="c.removeProductSelectionEntry"
@set-product="c.setProductRequirementProduct"
/>
<!-- Preview -->
<MachineCreatePreview :preview="c.machinePreview" />
<!-- Blocking issues warning -->
<div
v-if="c.blockingPreviewIssues.length"
class="text-xs text-error bg-error/10 border border-error/20 rounded-md px-3 py-2"
>
Compléter les informations bloquantes avant de créer la machine.
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-base-200">
<NuxtLink to="/machines" class="btn btn-outline">
<NuxtLink to="/machines" class="btn btn-outline btn-sm md:btn-md">
Annuler
</NuxtLink>
<button
type="submit"
class="btn btn-primary"
:disabled="!canEdit || !c.canCreateMachine || c.submitting"
class="btn btn-sm md:btn-md btn-primary"
:disabled="!canEdit || !c.newMachine.name?.trim() || c.submitting"
:class="{ loading: c.submitting }"
>
Créer la machine
@@ -191,10 +115,6 @@
import { proxyRefs } from 'vue'
import { useMachineCreatePage } from '~/composables/useMachineCreatePage'
import SearchSelect from '~/components/common/SearchSelect.vue'
import RequirementComponentSelector from '~/components/machine/create/RequirementComponentSelector.vue'
import RequirementPieceSelector from '~/components/machine/create/RequirementPieceSelector.vue'
import RequirementProductSelector from '~/components/machine/create/RequirementProductSelector.vue'
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
const c = proxyRefs(useMachineCreatePage())
const { canEdit } = usePermissions()

View File

@@ -945,7 +945,6 @@ const submitEdition = async () => {
updatedPiece.id,
[
updatedPiece?.typePiece?.pieceCustomFields,
updatedPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)

View File

@@ -573,7 +573,6 @@ const submitCreation = async () => {
createdPiece.id,
[
createdPiece?.typePiece?.pieceCustomFields,
createdPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)

View File

@@ -1,215 +0,0 @@
<template>
<main class="container mx-auto px-6 py-8">
<!-- Loading State -->
<div v-if="loading" class="my-8 text-center">
<div class="loading loading-spinner loading-lg" />
<p class="mt-4 text-gray-600">
Chargement du type...
</p>
</div>
<!-- Type Details -->
<div v-else-if="type" class="my-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-6">
<h2 class="card-title text-2xl">
{{ typePageTitle }}
</h2>
<div class="flex gap-2">
<NuxtLink :to="`/type/edit/${type.id}`" class="btn btn-secondary">
<IconLucideSquarePen class="w-4 h-4 mr-2" aria-hidden="true" />
Éditer complètement
</NuxtLink>
<NuxtLink to="/machine-skeleton" class="btn btn-outline">
Retour
</NuxtLink>
</div>
</div>
<!-- Current Type Info -->
<TypeInfoDisplay :type="type" />
<!-- Familles de composants -->
<div v-if="componentRequirementCount > 0" class="mb-8 space-y-3">
<h3 class="text-lg font-semibold">
Familles de composants
</h3>
<div class="space-y-3">
<div
v-for="requirement in type.componentRequirements"
:key="requirement.id"
class="border border-base-200 rounded-lg p-4 bg-base-100"
>
<div class="flex items-start justify-between gap-2">
<div>
<h4 class="text-sm font-semibold">
{{ requirement.label || requirement.typeComposant?.name || 'Famille' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ requirement.typeComposant?.name || 'Non défini' }}
</p>
</div>
<span class="badge badge-outline badge-sm">
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }}
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
</span>
</div>
<p class="text-xs text-gray-500 mt-2">
{{ requirement.allowNewModels ? 'Création de modèles autorisée' : 'Modèles existants uniquement' }}
</p>
</div>
</div>
</div>
<!-- Groupes de pièces -->
<div v-if="pieceRequirementCount > 0" class="mb-8 space-y-3">
<h3 class="text-lg font-semibold">
Groupes de pièces
</h3>
<div class="space-y-3">
<div
v-for="requirement in type.pieceRequirements"
:key="requirement.id"
class="border border-base-200 rounded-lg p-4 bg-base-100"
>
<div class="flex items-start justify-between gap-2">
<div>
<h4 class="text-sm font-semibold">
{{ requirement.label || requirement.typePiece?.name || 'Groupe' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ requirement.typePiece?.name || 'Non défini' }}
</p>
</div>
<span class="badge badge-outline badge-sm">
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }}
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
</span>
</div>
<p class="text-xs text-gray-500 mt-2">
{{ requirement.allowNewModels ? 'Création de modèles autorisée' : 'Modèles existants uniquement' }}
</p>
</div>
</div>
</div>
<!-- Produits requis -->
<div v-if="productRequirementCount > 0" class="mb-8 space-y-3">
<h3 class="text-lg font-semibold">
Produits requis
</h3>
<div class="space-y-3">
<div
v-for="requirement in type.productRequirements"
:key="requirement.id || requirement.typeProductId"
class="border border-base-200 rounded-lg p-4 bg-base-100"
>
<div class="flex items-start justify-between gap-2">
<div>
<h4 class="text-sm font-semibold">
{{ requirement.label || requirement.typeProduct?.name || 'Produit' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ requirement.typeProduct?.name || 'Non défini' }}
</p>
</div>
<span class="badge badge-outline badge-sm">
Min {{ toDisplayCount(requirement.minCount, requirement.required ? 1 : 0) }}
Max {{ toDisplayCount(requirement.maxCount, '∞') }}
</span>
</div>
<p class="text-xs text-gray-500 mt-2">
{{ requirement.allowNewModels ? 'Création de produits autorisée' : 'Produits existants uniquement' }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Commentaires -->
<CommentSection
entity-type="machine_skeleton"
:entity-id="type.id"
:entity-name="type.name"
/>
</div>
<!-- Error State -->
<div v-else class="my-8 text-center">
<div class="alert alert-error">
<div>
<h3 class="font-bold">
Type non trouvé
</h3>
<p>Le type de machine demandé n'existe pas.</p>
</div>
</div>
<NuxtLink to="/machine-skeleton" class="btn btn-primary mt-4">
Retour aux types
</NuxtLink>
</div>
</main>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import IconLucideSquarePen from '~icons/lucide/square-pen'
const { canEdit } = usePermissions()
const route = useRoute()
const { getMachineTypeById } = useMachineTypesApi()
const { showError } = useToast()
const type = ref(null)
const loading = ref(true)
const isEditView = computed(() => {
const editQuery = Array.isArray(route.query.edit) ? route.query.edit[0] : route.query.edit
const modeQuery = Array.isArray(route.query.mode) ? route.query.mode[0] : route.query.mode
return editQuery === 'true' || editQuery === '1' || modeQuery === 'edit'
})
const typePageTitle = computed(() => {
const currentName = type.value?.name || 'Type de squelette'
return isEditView.value ? `Modification : ${currentName}` : currentName
})
const componentRequirementCount = computed(() => type.value?.componentRequirements?.length || 0)
const pieceRequirementCount = computed(() => type.value?.pieceRequirements?.length || 0)
const productRequirementCount = computed(() => type.value?.productRequirements?.length || 0)
const toDisplayCount = (value, fallback) => {
if (value === null || value === undefined) {
return fallback
}
return value
}
onMounted(async () => {
try {
const typeId = route.params.id
if (!typeId) {
showError('Aucun identifiant de type fourni')
loading.value = false
return
}
const result = await getMachineTypeById(typeId)
if (result.success) {
type.value = result.data
} else {
showError('Type non trouvé')
}
} catch (error) {
console.error('Erreur lors du chargement:', error)
showError('Erreur lors du chargement')
} finally {
loading.value = false
}
})
</script>

View File

@@ -1,265 +0,0 @@
<template>
<main class="container mx-auto px-6 py-8">
<!-- Loading State -->
<div v-if="loading" class="my-8 text-center">
<div class="loading loading-spinner loading-lg" />
<p class="mt-4 text-gray-600">
Chargement du type...
</p>
</div>
<!-- Locked: machines linked -->
<div v-else-if="type && hasMachines" class="my-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-6">
<h2 class="card-title text-2xl">
{{ type.name }}
</h2>
<NuxtLink to="/machine-skeleton" class="btn btn-outline">
Retour
</NuxtLink>
</div>
<div class="alert alert-warning">
<IconLucideTriangleAlert class="w-5 h-5" />
<span>Ce squelette ne peut pas être modifié car des machines y sont rattachées.</span>
</div>
</div>
</div>
</div>
<!-- Edit Form -->
<div v-else-if="type" class="my-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-6">
<h2 class="card-title text-2xl">
Modifier : {{ type.name }}
</h2>
<NuxtLink to="/machine-skeleton" class="btn btn-outline">
Retour
</NuxtLink>
</div>
<TypeEditForm
v-model="editedType"
:saving="saving"
@submit="saveChanges"
/>
</div>
</div>
</div>
<!-- Error State -->
<div v-else class="my-8 text-center">
<div class="alert alert-error">
<div>
<h3 class="font-bold">
Type non trouvé
</h3>
<p>Le type de machine demandé n'existe pas.</p>
</div>
</div>
<NuxtLink to="/machine-skeleton" class="btn btn-primary mt-4">
Retour aux types
</NuxtLink>
</div>
</main>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import IconLucideTriangleAlert from '~icons/lucide/triangle-alert'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { getMachineTypeById, updateMachineType } = useMachineTypesApi()
const { showSuccess, showError } = useToast()
const type = ref(null)
const loading = ref(true)
const saving = ref(false)
const hasMachines = computed(() => {
const machines = type.value?.machines
return Array.isArray(machines) && machines.length > 0
})
// Données éditées du type
const editedType = ref({
name: '',
description: '',
category: '',
maintenanceFrequency: '',
customFields: [],
componentRequirements: [],
pieceRequirements: [],
productRequirements: []
})
const parseOptions = (field = {}) => {
if (field.type !== 'select') { return [] }
if (field.optionsText && typeof field.optionsText === 'string') {
return field.optionsText
.split('\n')
.map(option => option.trim())
.filter(Boolean)
}
if (Array.isArray(field.options)) {
return field.options
.map(option => String(option).trim())
.filter(Boolean)
}
return []
}
const toModelTypeIri = (value) => {
if (!value) {
return undefined
}
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
return value
}
const relationId = extractRelationId(value)
if (relationId) {
return `/api/model_types/${relationId}`
}
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
}
const normalizeCustomFields = (fields = []) =>
fields
.filter(field => field?.name && field.name.trim() !== '')
.map((field, index) => ({
name: field.name,
type: field.type || '',
required: !!field.required,
options: parseOptions(field),
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
const toIntegerOrNull = (value, fallback = null) => {
if (value === '' || value === undefined || value === null) {
return fallback
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
const normalizeComponentRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeComposantId || req?.typeComposant)
.map((req, index) => ({
typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 1),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? true,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizePieceRequirements = (requirements = []) =>
requirements
.filter(req => req?.typePieceId || req?.typePiece)
.map((req, index) => ({
typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const normalizeProductRequirements = (requirements = []) =>
requirements
.filter(req => req?.typeProductId || req?.typeProduct)
.map((req, index) => ({
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
label: req.label?.trim() ? req.label.trim() : undefined,
minCount: toIntegerOrNull(req.minCount, 0),
maxCount: toIntegerOrNull(req.maxCount, null),
required: req.required ?? false,
allowNewModels: req.allowNewModels ?? true,
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
}))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((req, index) => ({ ...req, orderIndex: index }))
const saveChanges = async () => {
try {
saving.value = true
const currentEditedType = editedType.value
// Préparer les données pour l'API
const updatedType = {
...currentEditedType,
customFields: normalizeCustomFields(currentEditedType.customFields),
componentRequirements: normalizeComponentRequirements(currentEditedType.componentRequirements),
pieceRequirements: normalizePieceRequirements(currentEditedType.pieceRequirements),
productRequirements: normalizeProductRequirements(currentEditedType.productRequirements)
}
const result = await updateMachineType(type.value.id, updatedType)
if (result.success) {
showSuccess('Type mis à jour avec succès !')
router.push('/machine-skeleton')
} else {
showError('Erreur lors de la mise à jour du type')
}
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error)
showError('Erreur lors de la sauvegarde')
} finally {
saving.value = false
}
}
// Charger le type au montage
onMounted(async () => {
if (!canEdit.value) {
router.replace(`/type/${route.params.id}`)
return
}
try {
const typeId = route.params.id
const result = await getMachineTypeById(typeId, true)
if (result.success) {
type.value = result.data
// Initialiser les données éditées
editedType.value = {
name: type.value.name || '',
description: type.value.description || '',
category: type.value.category || '',
maintenanceFrequency: type.value.maintenanceFrequency || '',
customFields: type.value.customFields || [],
componentRequirements: type.value.componentRequirements || [],
pieceRequirements: type.value.pieceRequirements || [],
productRequirements: type.value.productRequirements || [],
}
} else {
console.error('Failed to load type:', result.error)
showError('Type non trouvé')
}
} catch (error) {
console.error('Erreur lors du chargement:', error)
showError('Erreur lors du chargement')
} finally {
loading.value = false
}
})
</script>

View File

@@ -4,7 +4,6 @@ export const RELATION_ID_MAP: Record<string, { key: string; path: string }> = {
composantId: { key: 'composant', path: 'composants' },
pieceId: { key: 'piece', path: 'pieces' },
productId: { key: 'product', path: 'products' },
typeMachineId: { key: 'typeMachine', path: 'type_machines' },
typeComposantId: { key: 'typeComposant', path: 'model_types' },
typePieceId: { key: 'typePiece', path: 'model_types' },
typeProductId: { key: 'typeProduct', path: 'model_types' },

View File

@@ -60,9 +60,8 @@ export const uniqueConstructeurIds = (...sources: unknown[]): string[] => {
if (value.constructeur) {
explore(value.constructeur);
}
// Only extract ID if this looks like a constructeur object (has @type or recognizable fields)
// Don't extract ID from component/piece/product objects that happen to be passed in
if (typeof value.id === 'string' && !value.name && !value.typeComposant && !value.typePiece && !value.typeProduct) {
// Extract ID from constructeur-like objects, but skip component/piece/product entities
if (typeof value.id === 'string' && !value.typeComposant && !value.typePiece && !value.typeProduct) {
pushId(value.id);
}
return;

View File

@@ -123,7 +123,8 @@ export function extractStructureCustomFields(structure: any): any[] {
export function deduplicateFieldDefinitions(definitions: any[]): any[] {
const result: any[] = []
const seen = new Set<string>()
const seenIds = new Set<string>()
const seenNames = new Set<string>()
const orderedDefinitions = (Array.isArray(definitions) ? definitions.slice() : [])
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
@@ -132,9 +133,12 @@ export function deduplicateFieldDefinitions(definitions: any[]): any[] {
if (!field || typeof field !== 'object') return
const id = field.id ?? field.customFieldId ?? field.customField?.id ?? null
const nameKey = fieldKeyFromNameAndType(field.name, field.type)
const key = id || nameKey
if (key && seen.has(key)) return
if (key) seen.add(key)
// Deduplicate by name+type (primary) AND by id — a field with the same
// name+type is the same field even when stored with different IDs.
if (nameKey && seenNames.has(nameKey)) return
if (id && seenIds.has(id)) return
if (id) seenIds.add(id)
if (nameKey) seenNames.add(nameKey)
field.orderIndex = resolveOrderIndex(field)
result.push(field)
})
@@ -219,7 +223,8 @@ export function dedupeMergedFields(fields: any[]): any[] {
: []
}
const seen = new Map<string, any>()
const seenById = new Map<string, any>()
const seenByName = new Map<string, any>()
const result: any[] = []
const orderedFields = fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
@@ -236,18 +241,14 @@ export function dedupeMergedFields(fields: any[]): any[] {
const fieldId = resolveCustomFieldId(field)
const nameKey = fieldKeyFromNameAndType(normalizedName, field.type)
const key = fieldId || nameKey
if (!key) {
field.orderIndex = resolveOrderIndex(field)
result.push(field)
return
}
// Check duplicates by name+type first (same field can have different IDs)
const existing = (nameKey ? seenByName.get(nameKey) : undefined) ?? (fieldId ? seenById.get(fieldId) : undefined)
const existing = seen.get(key)
if (!existing) {
field.orderIndex = resolveOrderIndex(field)
seen.set(key, field)
if (fieldId) seenById.set(fieldId, field)
if (nameKey) seenByName.set(nameKey, field)
result.push(field)
return
}
@@ -258,7 +259,8 @@ export function dedupeMergedFields(fields: any[]): any[] {
if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field)
existing.orderIndex = Math.min(resolveOrderIndex(existing), resolveOrderIndex(field))
seen.set(key, existing)
if (fieldId) seenById.set(fieldId, existing)
if (nameKey) seenByName.set(nameKey, existing)
}
})
@@ -276,45 +278,29 @@ export function buildDefinitionSources(entity: any, entityType: 'composant' | 'p
}
if (entityType === 'composant') {
const requirement = entity.typeMachineComponentRequirement || {}
const type = requirement.typeComposant || entity.typeComposant || {}
const type = entity.typeComposant || {}
pushFields(entity.customFields)
pushFields(entity.definition?.customFields)
pushFields(type.customFields)
pushFields(requirement.customFields)
pushFields(requirement.definition?.customFields)
;[
entity.definition?.structure,
type.structure,
type.componentSkeleton,
requirement.structure,
requirement.componentSkeleton,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure)
if (fields.length) definitions.push(...fields)
})
} else {
const requirement = entity.typeMachinePieceRequirement || {}
const type = requirement.typePiece || entity.typePiece || {}
const type = entity.typePiece || {}
pushFields(entity.customFields)
pushFields(entity.definition?.customFields)
pushFields(entity.typePiece?.customFields)
pushFields(type.customFields)
pushFields(requirement.typePiece?.customFields)
pushFields(requirement.customFields)
pushFields(requirement.definition?.customFields)
;[
entity.definition?.structure,
entity.typePiece?.structure,
type.structure,
type.pieceSkeleton,
entity.typePiece?.pieceSkeleton,
requirement.structure,
requirement.pieceSkeleton,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure)
if (fields.length) definitions.push(...fields)

View File

@@ -208,11 +208,6 @@ export const getProductDisplay = (
((source.requirement as AnyRecord)?.structure as AnyRecord)?.products,
((source.requirement as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
((source.requirement as AnyRecord)?.componentSkeleton as AnyRecord)?.products,
(source.typeMachineComponentRequirement as AnyRecord)?.products,
(source.typeMachineComponentRequirement as AnyRecord)?.productSkeleton,
((source.typeMachineComponentRequirement as AnyRecord)?.structure as AnyRecord)?.products,
((source.typeMachineComponentRequirement as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
((source.typeMachineComponentRequirement as AnyRecord)?.componentSkeleton as AnyRecord)?.products,
(source.typeComposant as AnyRecord)?.products,
(source.typeComposant as AnyRecord)?.productSkeleton,
((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.products,

View File

@@ -412,9 +412,6 @@ export const buildMachinePrintContext = ({
}
const machineBadges = []
if (machine?.typeMachine?.category) {
machineBadges.push(machine.typeMachine.category)
}
if (machine?.site?.name) {
machineBadges.push(`Site: ${machine.site.name}`)
}
@@ -480,10 +477,8 @@ export const buildMachinePrintContext = ({
id: machine?.id || null,
name: machineName,
description: machine?.description || '',
typeDescription: machine?.typeMachine?.description || '',
reference: machineReference,
site: machine?.site?.name || '',
category: machine?.typeMachine?.category || '',
badges: machineBadges,
constructeurs: machineConstructeurs,
constructeur: machineConstructeurs[0] || null,