Files
Inventory/app/pages/component/[id]/edit.vue
Matthieu 480aaa24b2 feat(navigation) : preserve list state in URL and use browser history for back buttons
Add useUrlState composable to sync page, search, sort and filter state
with URL query params. Back/forward navigation now restores the exact
list position. Replace hardcoded NuxtLink back buttons with
router.back() across all create/edit pages. Fix documents attachment
filter that checked non-existent ID fields instead of relation objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:48:40 +01:00

1145 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
@close="closePreview"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du composant</p>
</div>
<div v-else-if="!component" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Composant introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier le composant</h1>
<p class="text-sm text-base-content/70">
Mettez à jour les informations du composant et ses champs personnalisés.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="saving"
placeholder="Valeur indicatrice"
>
</div>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.' }}
</p>
</div>
<span class="badge badge-outline">{{ formatStructurePreview(selectedTypeStructure) }}</span>
</div>
<details v-if="selectedTypeStructure" class="collapse collapse-arrow bg-base-100">
<summary class="collapse-title text-sm font-medium">
Consulter le détail du squelette
</summary>
<div class="collapse-content space-y-4 text-sm text-base-content/80">
<div v-if="getStructureCustomFields(selectedTypeStructure).length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
<ul class="space-y-2">
<li
v-for="field in getStructureCustomFields(selectedTypeStructure)"
:key="field.customFieldId || field.id || field.name"
class="rounded bg-base-200/60 px-3 py-2"
>
<p class="font-medium text-sm text-base-content">
{{ field.name || field.key }}
</p>
<p class="text-xs text-base-content/70 mt-1">
Type : {{ field.type || 'text' }}<span v-if="field.required"> Obligatoire</span>
<span v-if="Array.isArray(field.options) && field.options.length">
Options : {{ field.options.join(', ') }}
</span>
<span v-if="field.defaultValue">
Défaut : {{ field.defaultValue }}
</span>
</p>
</li>
</ul>
</div>
<div v-if="getStructurePieces(selectedTypeStructure).length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces imposées</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(piece, index) in getStructurePieces(selectedTypeStructure)"
:key="piece.role || piece.typePieceId || piece.familyCode || index"
>
{{ resolvePieceLabel(piece) }}
</li>
</ul>
</div>
<div v-if="getStructureProducts(selectedTypeStructure).length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(product, index) in getStructureProducts(selectedTypeStructure)"
:key="product.role || product.typeProductId || product.familyCode || index"
>
{{ resolveProductLabel(product) }}
</li>
</ul>
</div>
<div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(subcomponent, index) in getStructureSubcomponents(selectedTypeStructure)"
:key="subcomponent.alias || subcomponent.typeComposantId || subcomponent.familyCode || index"
>
{{ resolveSubcomponentLabel(subcomponent) }}
</li>
</ul>
</div>
<p
v-if="!getStructureCustomFields(selectedTypeStructure).length && !getStructurePieces(selectedTypeStructure).length && !getStructureProducts(selectedTypeStructure).length && !getStructureSubcomponents(selectedTypeStructure).length"
class="text-xs text-gray-500"
>
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
</p>
</div>
</details>
</div>
<div
v-if="structureSelections.hasAny"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Sélections actuelles</h2>
<p class="text-xs text-base-content/70">
Voici les pièces, produits et sous-composants réellement choisis pour ce composant.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div v-if="structureSelections.pieces.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces choisies</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.pieces" :key="`selected-piece-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
</div>
<div v-if="structureSelections.products.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits choisis</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.products" :key="`selected-product-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
</div>
<div v-if="structureSelections.components.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants choisis</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.components" :key="`selected-component-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
</div>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce composant.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="saving"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
<input
v-model="field.value"
type="checkbox"
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="saving"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
</div>
</div>
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à ce composant.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<div v-else-if="componentDocuments.length" class="space-y-2">
<div
v-for="document in componentDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(document)"
>
Télécharger
</button>
<button
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-base-content/70">
Aucun document n'est associé à ce composant pour le moment.
</p>
</div>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-base-content">Historique</h2>
<p class="text-xs text-base-content/70">
Qui a changé quoi, et quand.
</p>
</div>
<span v-if="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in historyEntries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
fieldKey,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import {
documentIcon,
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
interface ComponentCatalogType extends ModelType {
structure: ComponentModelStructure | null
customFields?: Array<Record<string, any>>
}
const route = useRoute()
const router = useRouter()
const { get } = useApi()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
const { pieces, loadPieces } = usePieces()
const { products, loadProducts } = useProducts()
const { ensureConstructeurs } = useConstructeurs()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useComponentHistory()
const component = ref<any | null>(null)
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const componentDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
structure: 'Structure',
typeComposant: 'Catégorie',
product: 'Produit lié',
constructeurIds: 'Fournisseurs',
}
const historyDiffEntries = (entry: ComponentHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const selectedTypeId = ref<string>('')
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
})
const customFieldInputs = ref<CustomFieldInput[]>([])
const fetchedPieceTypeMap = ref<Record<string, string>>({})
const pieceTypeLabelMap = computed(() => ({
...Object.fromEntries(
(pieceTypes.value || [])
.filter((type: any) => type?.id)
.map((type: any) => [type.id, type.name || type.code || '']),
),
...fetchedPieceTypeMap.value,
}))
const fetchedProductTypeMap = ref<Record<string, string>>({})
const productTypeLabelMap = computed(() => ({
...Object.fromEntries(
(productTypes.value || [])
.filter((type: any) => type?.id)
.map((type: any) => [type.id, type.name || type.code || '']),
),
...fetchedProductTypeMap.value,
}))
const pieceCatalogMap = computed(() =>
new Map(
(pieces.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const productCatalogMap = computed(() =>
new Map(
(products.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const componentCatalogMap = computed(() =>
new Map(
(componentCatalogRef.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) {
return
}
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteDocument(documentId, { updateStore: false })
if (result.success) {
componentDocuments.value = componentDocuments.value.filter((doc) => doc.id !== documentId)
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !component.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadDocuments(
{
files,
context: { composantId: component.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
}
} finally {
uploadingDocuments.value = false
}
}
const refreshDocuments = async () => {
if (!component.value?.id) {
componentDocuments.value = []
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByComponent(component.value.id, { updateStore: false })
if (result.success) {
componentDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
}
} finally {
loadingDocuments.value = false
}
}
const componentTypeList = computed<ComponentCatalogType[]>(() =>
(componentTypes.value || [])
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
)
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
const structure = selectedType.value?.structure ?? null
return structure ? normalizeStructureForEditor(structure) : null
})
const refreshCustomFieldInputs = (
structureOverride?: ComponentModelStructure | null,
valuesOverride?: any[] | null,
) => {
const structure = structureOverride ?? selectedTypeStructure.value ?? null
const values = valuesOverride ?? component.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(structure, values)
}
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() => Boolean(
component.value &&
editionForm.name &&
requiredCustomFieldsFilled.value &&
!saving.value,
))
const fetchComponent = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
component.value = null
componentDocuments.value = []
return
}
const result = await get(`/composants/${id}`)
if (result.success) {
component.value = result.data
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
loadHistory(result.data.id).catch(() => {})
} else {
component.value = null
componentDocuments.value = []
}
}
let initialized = false
watch(
[component, selectedTypeStructure],
([currentComponent, currentStructure]) => {
if (!currentComponent || initialized) {
return
}
const resolvedTypeId = currentComponent.typeComposantId
|| extractRelationId(currentComponent.typeComposant)
|| ''
if (resolvedTypeId && !currentComponent.typeComposantId) {
currentComponent.typeComposantId = resolvedTypeId
}
selectedTypeId.value = resolvedTypeId
editionForm.name = currentComponent.name || ''
editionForm.reference = currentComponent.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds(
currentComponent,
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [],
currentComponent.constructeur ? [currentComponent.constructeur] : [],
)
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
// After setting selectedTypeId, read selectedTypeStructure.value (now updated) instead of
// the stale destructured currentStructure which was captured before the ID change.
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
initialized = true
},
{ immediate: true },
)
watch(selectedTypeStructure, (currentStructure) => {
if (!component.value) {
return
}
refreshCustomFieldInputs(currentStructure, component.value.customFieldValues)
})
const submitEdition = async () => {
if (!component.value) {
return
}
const rawPrice = typeof editionForm.prix === 'string'
? editionForm.prix.trim()
: editionForm.prix === null || editionForm.prix === undefined
? ''
: String(editionForm.prix).trim()
const payload: Record<string, any> = {
name: editionForm.name.trim(),
}
const reference = editionForm.reference.trim()
payload.reference = reference ? reference : null
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.prix = String(parsed)
}
} else {
payload.prix = null
}
saving.value = true
try {
const result = await updateComposant(component.value.id, payload)
if (result.success && result.data) {
const updatedComponent = result.data as Record<string, any>
await _saveCustomFieldValues(
'composant',
updatedComponent.id,
[
updatedComponent?.typeComposant?.customFields,
updatedComponent?.typeMachineComponentRequirement?.typeComposant?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
await router.push('/component-catalog')
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour du composant')
} finally {
saving.value = false
}
}
const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.customFields) ? structure.customFields : []
}
const getStructurePieces = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.pieces) ? structure.pieces : []
}
const getStructureProducts = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.products) ? structure.products : []
}
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
if (Array.isArray(structure?.subcomponents)) {
return structure.subcomponents
}
const legacy = (structure as any)?.subComponents
return Array.isArray(legacy) ? legacy : []
}
const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0
const resolvePieceLabel = (piece: Record<string, any>) => {
const parts: string[] = []
if (piece.role) {
parts.push(piece.role)
}
if (piece.typePiece?.name) {
parts.push(piece.typePiece.name)
} else if (piece.typePieceLabel) {
parts.push(piece.typePieceLabel)
} else if (piece.typePieceId && pieceTypeLabelMap.value[piece.typePieceId]) {
parts.push(pieceTypeLabelMap.value[piece.typePieceId])
} else if (piece.typePiece?.code) {
parts.push(`Famille ${piece.typePiece.code}`)
} else if (piece.familyCode) {
parts.push(`Famille ${piece.familyCode}`)
} else if (piece.typePieceId) {
parts.push(`#${piece.typePieceId}`)
}
return parts.length ? parts.join(' • ') : 'Pièce'
}
const fetchPieceTypeNames = async (ids: string[]) => {
const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id])
if (!missing.length) {
return
}
const results = await Promise.allSettled(
missing.map((id) => get(`/model_types/${id}`)),
)
const next = { ...fetchedPieceTypeMap.value }
results.forEach((result, index) => {
const key = missing[index]
if (!key || result.status !== 'fulfilled') {
return
}
const data = result.value?.data
const name = data?.name || data?.code
if (name) {
next[key] = name
}
})
fetchedPieceTypeMap.value = next
}
const resolveProductLabel = (product: Record<string, any>) => {
const parts: string[] = []
if (product.role) {
parts.push(product.role)
}
if (product.typeProduct?.name) {
parts.push(product.typeProduct.name)
} else if (product.typeProductLabel) {
parts.push(product.typeProductLabel)
} else if (product.typeProductId && productTypeLabelMap.value[product.typeProductId]) {
parts.push(productTypeLabelMap.value[product.typeProductId])
} else if (product.typeProduct?.code) {
parts.push(`Catégorie ${product.typeProduct.code}`)
} else if (product.familyCode) {
parts.push(`Catégorie ${product.familyCode}`)
} else if (product.typeProductId) {
parts.push(`#${product.typeProductId}`)
}
return parts.length ? parts.join(' • ') : 'Produit'
}
const fetchProductTypeNames = async (ids: string[]) => {
const missing = ids.filter((id) => id && !productTypeLabelMap.value[id])
if (!missing.length) {
return
}
const results = await Promise.allSettled(
missing.map((id) => get(`/model_types/${id}`)),
)
const next = { ...fetchedProductTypeMap.value }
results.forEach((result, index) => {
const key = missing[index]
if (!key || result.status !== 'fulfilled') {
return
}
const data = result.value?.data
const name = data?.name || data?.code
if (name) {
next[key] = name
}
})
fetchedProductTypeMap.value = next
}
watch(
selectedTypeStructure,
(structure) => {
const pieceIds = getStructurePieces(structure)
.map((piece: any) => piece?.typePieceId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
if (pieceIds.length) {
fetchPieceTypeNames(Array.from(new Set(pieceIds))).catch(() => {})
}
const productIds = getStructureProducts(structure)
.map((product: any) => product?.typeProductId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
if (productIds.length) {
fetchProductTypeNames(Array.from(new Set(productIds))).catch(() => {})
}
},
{ immediate: true },
)
const resolveSubcomponentLabel = (node: Record<string, any>) => {
const parts: string[] = []
if (node.alias) {
parts.push(node.alias)
}
if (node.typeComposant?.name) {
parts.push(node.typeComposant.name)
} else if (node.typeComposantLabel) {
parts.push(node.typeComposantLabel)
} else if (node.familyCode) {
parts.push(node.familyCode)
} else if (node.typeComposantId) {
parts.push(`#${node.typeComposantId}`)
}
const childCount = Array.isArray(node.subcomponents)
? node.subcomponents.length
: Array.isArray(node.subComponents)
? node.subComponents.length
: 0
if (childCount) {
parts.push(`${childCount} sous-composant(s)`)
}
return parts.length ? parts.join(' • ') : 'Sous-composant'
}
type SelectionEntry = {
id: string
path: string
requirementLabel: string
resolvedName: string
}
const collectStructureSelections = (root: any): {
pieces: SelectionEntry[]
products: SelectionEntry[]
components: SelectionEntry[]
} => {
const piecesSelected: SelectionEntry[] = []
const productsSelected: SelectionEntry[] = []
const componentsSelected: SelectionEntry[] = []
if (!root || typeof root !== 'object') {
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
}
const visitNode = (node: any, fallbackPath = 'racine') => {
if (!node || typeof node !== 'object') {
return
}
const nodePath = isNonEmptyString(node.path) ? node.path : fallbackPath
const nodePieces = Array.isArray(node.pieces) ? node.pieces : []
nodePieces.forEach((entry: any, index: number) => {
const selectedId = entry?.selectedPieceId
if (!isNonEmptyString(selectedId)) {
return
}
const definition = entry?.definition ?? entry
const catalogPiece = pieceCatalogMap.value.get(selectedId)
piecesSelected.push({
id: selectedId,
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
requirementLabel: resolvePieceLabel(definition),
resolvedName: catalogPiece?.name || selectedId,
})
})
const nodeProducts = Array.isArray(node.products) ? node.products : []
nodeProducts.forEach((entry: any, index: number) => {
const selectedId = entry?.selectedProductId
if (!isNonEmptyString(selectedId)) {
return
}
const definition = entry?.definition ?? entry
const catalogProduct = productCatalogMap.value.get(selectedId)
productsSelected.push({
id: selectedId,
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:product-${index + 1}`,
requirementLabel: resolveProductLabel(definition),
resolvedName: catalogProduct?.name || selectedId,
})
})
const nodeChildren = Array.isArray(node.subcomponents)
? node.subcomponents
: Array.isArray(node.subComponents)
? node.subComponents
: []
nodeChildren.forEach((child: any, index: number) => {
const selectedId = child?.selectedComponentId
if (isNonEmptyString(selectedId)) {
const definition = child?.definition ?? child
const catalogComponent = componentCatalogMap.value.get(selectedId)
componentsSelected.push({
id: selectedId,
path: isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`,
requirementLabel: resolveSubcomponentLabel(definition),
resolvedName: catalogComponent?.name || selectedId,
})
}
visitNode(child, isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`)
})
}
visitNode(root, isNonEmptyString(root?.path) ? root.path : 'racine')
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
}
const structureSelections = computed(() => {
const selections = collectStructureSelections(component.value?.structure)
const total =
selections.pieces.length + selections.products.length + selections.components.length
return {
...selections,
total,
hasAny: total > 0,
}
})
onMounted(async () => {
await Promise.allSettled([
loadComponentTypes(),
loadPieceTypes(),
loadProductTypes(),
fetchComponent(),
])
loading.value = false
// Defer bulk catalog loads — not needed for initial render
Promise.allSettled([
loadPieces({ itemsPerPage: 200 }),
loadProducts({ itemsPerPage: 200 }),
loadComposants({ itemsPerPage: 200 }),
]).catch(() => {})
})
</script>