Files
Inventory/app/pages/pieces/[id]/edit.vue

1328 lines
44 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 de la pièce</p>
</div>
<div v-else-if="!piece" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Pièce introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée.
</p>
</div>
</div>
<NuxtLink to="/pieces-catalog" class="btn btn-primary mt-6">
Retour au catalogue
</NuxtLink>
</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 la pièce</h1>
<p class="text-sm text-base-content/70">
Ajustez les informations de la pièce et ses champs personnalisés.
</p>
</div>
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
Retour au catalogue
</NuxtLink>
</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 pièce</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 pieceTypeList"
: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 de la pièce</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="piece?.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="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="saving"
:type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
</div>
<div v-if="selectedType || resolvedStructure" 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 champs personnalisés de la pièce.' }}
</p>
</div>
<span class="badge badge-outline">{{ formatPieceStructurePreview(resolvedStructure) }}</span>
</div>
<details v-if="resolvedStructure" 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-2 text-sm text-base-content/80">
<div v-if="getStructureCustomFields(resolvedStructure).length" class="space-y-1">
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
<ul class="list-disc list-inside space-y-1">
<li v-for="field in getStructureCustomFields(resolvedStructure)" :key="field.name">
<span class="font-medium">{{ field.name }}</span>
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
</li>
</ul>
</div>
<p v-else class="text-xs text-base-content/70">
Ce squelette ne définit pas encore de champs personnalisés.
</p>
</div>
</details>
</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 à cette pièce.
</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 à cette pièce.
</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="pieceDocuments.length" class="space-y-2">
<div
v-for="document in pieceDocuments"
: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é à cette pièce 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="/pieces-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 ProductSelect from '~/components/ProductSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
import { extractRelationId } from '~/shared/apiRelations'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import { getModelType } from '~/services/modelTypes'
interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null
customFields?: Array<Record<string, any>>
}
interface CustomFieldInput {
id: string | null
name: string
type: string
required: boolean
options: string[]
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
const route = useRoute()
const router = useRouter()
const { get } = useApi()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { updatePiece } = usePieces()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const toast = useToast()
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
const { ensureConstructeurs } = useConstructeurs()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = usePieceHistory()
const piece = 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 pieceDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
typePiece: 'Catégorie',
product: 'Produit lié',
productIds: 'Produits liés',
constructeurIds: 'Fournisseurs',
}
const historyActionLabel = (action: string) => {
if (action === 'create') {
return 'Création'
}
if (action === 'delete') {
return 'Suppression'
}
return 'Modification'
}
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
dateStyle: 'medium',
timeStyle: 'short',
})
const formatHistoryDate = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return historyDateFormatter.format(date)
}
const formatHistoryValue = (value: unknown): string => {
if (value === null || value === undefined || value === '') {
return '—'
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '—'
}
return value.map((item) => formatHistoryValue(item)).join(', ')
}
if (typeof value === 'object') {
const maybeRecord = value as Record<string, unknown>
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
if (name && id) {
return `${name} (#${id})`
}
if (name) {
return name
}
if (id) {
return `#${id}`
}
try {
return JSON.stringify(value)
} catch (error) {
return String(value)
}
}
return String(value)
}
const historyDiffEntries = (entry: PieceHistoryEntry) => {
const diff = entry.diff ?? {}
return Object.entries(diff).map(([field, change]) => {
const label = historyFieldLabels[field] ?? field
const fromLabel = formatHistoryValue(change?.from)
const toLabel = formatHistoryValue(change?.to)
return {
field,
label,
fromLabel,
toLabel,
}
})
}
const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null)
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
})
const productSelections = ref<(string | null)[]>([])
const customFieldInputs = ref<CustomFieldInput[]>([])
const documentIcon = (doc: any) =>
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
const resolvedStructure = computed<PieceModelStructure | null>(() =>
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
)
const refreshCustomFieldInputs = (
structureOverride?: PieceModelStructure | null,
valuesOverride?: any[] | null,
) => {
const structure = structureOverride ?? resolvedStructure.value ?? null
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(structure, values)
}
const formatSize = (size: number | null | undefined) => {
if (size === null || size === undefined) {
return '—'
}
if (size === 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
const shouldInlinePdf = (document: any) => {
if (!document || !isPdfDocument(document) || !document.path) {
return false
}
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
return false
}
return true
}
const appendPdfViewerParams = (src: string) => {
if (!src || src.startsWith('data:')) {
return src || ''
}
if (src.includes('#')) {
return `${src}&toolbar=0&navpanes=0`
}
return `${src}#toolbar=0&navpanes=0`
}
const documentPreviewSrc = (document: any) => {
if (!document?.path) {
return ''
}
if (isPdfDocument(document)) {
return appendPdfViewerParams(document.path)
}
return document.path
}
const documentThumbnailClass = (document: any) => {
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
return 'h-24 w-20'
}
return 'h-16 w-16'
}
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) {
return
}
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const downloadDocument = (doc: any) => {
if (!doc?.path) {
return
}
const target = String(doc.path)
if (target.startsWith('data:')) {
const link = document.createElement('a')
link.href = target
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(target, '_blank')
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteDocument(documentId, { updateStore: false })
if (result.success) {
pieceDocuments.value = pieceDocuments.value.filter((doc) => doc.id !== documentId)
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !piece.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadDocuments(
{
files,
context: { pieceId: piece.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
}
} finally {
uploadingDocuments.value = false
}
}
const refreshDocuments = async () => {
if (!piece.value?.id) {
pieceDocuments.value = []
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false })
if (result.success) {
pieceDocuments.value = result.data || []
}
} finally {
loadingDocuments.value = false
}
}
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const getStructureProducts = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.products) ? structure.products : []
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.customFields) ? structure.customFields : []
const structureProducts = computed(() =>
getStructureProducts(resolvedStructure.value),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
if (!requirement) {
return `Produit ${index + 1}`
}
const parts: string[] = []
if (requirement.role) {
parts.push(requirement.role)
}
if (requirement.typeProductLabel) {
parts.push(requirement.typeProductLabel)
} else if (requirement.typeProductId) {
parts.push(`Catégorie #${requirement.typeProductId}`)
}
if (requirement.familyCode) {
parts.push(`Famille ${requirement.familyCode}`)
}
if (parts.length === 0) {
parts.push(`Produit ${index + 1}`)
}
return parts.join(' • ')
}
const productRequirementDescriptions = computed(() =>
structureProducts.value.map((requirement, index) =>
describeProductRequirement(requirement, index),
),
)
const ensureProductSelections = (count: number) => {
const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
productSelections.value = next
}
let pendingProductIds: string[] = []
const productRequirementEntries = computed(() =>
structureProducts.value.map((requirement, index) => ({
index,
key: `piece-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
label: describeProductRequirement(requirement, index),
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
})),
)
const productSelectionsFilled = computed(() =>
!requiresProductSelection.value ||
productRequirementEntries.value.every((entry) => {
const value = productSelections.value[entry.index]
return typeof value === 'string' && value.trim().length > 0
}),
)
const setProductSelection = (index: number, value: string | null) => {
const normalized = typeof value === 'string' ? value : null
const next = [...productSelections.value]
next[index] = normalized
productSelections.value = next
}
watch(structureProducts, (products) => {
ensureProductSelections(products.length)
if (!pendingProductIds.length || products.length === 0) {
return
}
const next = Array.from(
{ length: products.length },
(_, index) => pendingProductIds[index] ?? null,
)
productSelections.value = next
pendingProductIds = []
})
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return toFieldString(field.value).trim() !== ''
}),
)
const canSubmit = computed(() =>
Boolean(
piece.value &&
editionForm.name &&
requiredCustomFieldsFilled.value &&
productSelectionsFilled.value &&
!saving.value,
),
)
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return ''
}
const fetchPiece = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
piece.value = null
pieceDocuments.value = []
return
}
const result = await get(`/pieces/${id}`)
if (result.success) {
piece.value = result.data
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = await getCustomFieldValuesByEntity('piece', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) {
piece.value.customFieldValues = customValues.data
refreshCustomFieldInputs(undefined, customValues.data)
}
await loadPieceTypeDetails(result.data)
await loadHistory(result.data.id)
} else {
piece.value = null
pieceDocuments.value = []
}
}
const loadPieceTypeDetails = async (currentPiece: any) => {
const typeId = currentPiece?.typePieceId
|| extractRelationId(currentPiece?.typePiece)
|| ''
if (!typeId) {
pieceTypeDetails.value = null
return
}
try {
const type = await getModelType(typeId)
if (type && typeof type === 'object') {
pieceTypeDetails.value = type
refreshCustomFieldInputs(type.structure ?? null, currentPiece?.customFieldValues ?? null)
}
} catch (error) {
pieceTypeDetails.value = null
}
}
let initialized = false
watch(
[piece, selectedType],
([currentPiece, currentType]) => {
if (!currentPiece || initialized) {
return
}
const resolvedTypeId = currentPiece.typePieceId
|| extractRelationId(currentPiece.typePiece)
|| ''
if (resolvedTypeId && !currentPiece.typePieceId) {
currentPiece.typePieceId = resolvedTypeId
}
selectedTypeId.value = resolvedTypeId
editionForm.name = currentPiece.name || ''
editionForm.reference = currentPiece.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds(
currentPiece,
Array.isArray(currentPiece.constructeurs) ? currentPiece.constructeurs : [],
currentPiece.constructeur ? [currentPiece.constructeur] : [],
)
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
? currentPiece.productIds.map((id: unknown) => String(id))
: currentPiece.product?.id || currentPiece.productId
? [String(currentPiece.product?.id || currentPiece.productId)]
: []
pendingProductIds = existingProductIds
ensureProductSelections(structureProducts.value.length)
if (existingProductIds.length && structureProducts.value.length) {
const next = Array.from(
{ length: structureProducts.value.length },
(_, index) => existingProductIds[index] ?? null,
)
productSelections.value = next
pendingProductIds = []
}
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues)
initialized = true
},
{ immediate: true },
)
watch(selectedType, (currentType) => {
if (!piece.value || !currentType) {
return
}
if (!pieceTypeDetails.value) {
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
}
})
watch(resolvedStructure, (currentStructure) => {
if (!piece.value) {
return
}
ensureProductSelections(structureProducts.value.length)
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
})
const submitEdition = async () => {
if (!piece.value) {
return
}
if (!productSelectionsFilled.value) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const rawPrice = typeof editionForm.prix === 'string'
? editionForm.prix.trim()
: editionForm.prix === null || editionForm.prix === undefined
? ''
: String(editionForm.prix).trim()
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
const payload: Record<string, any> = {
name: editionForm.name.trim(),
constructeurIds,
}
const reference = editionForm.reference.trim()
payload.reference = reference ? reference : null
const normalizedProductIds = productRequirementEntries.value
.map((entry) => productSelections.value[entry.index])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
payload.productIds = normalizedProductIds
payload.productId = normalizedProductIds[0] || null
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 updatePiece(piece.value.id, payload)
if (result.success) {
const updatedPiece = result.data
await saveCustomFieldValues(updatedPiece)
await router.push('/pieces-catalog')
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour de la pièce')
} finally {
saving.value = false
}
}
const buildCustomFieldInputs = (
structure: PieceModelStructure | null,
values: any[] | null,
): CustomFieldInput[] => {
const definitions = normalizeCustomFieldInputs(structure)
const valueList = Array.isArray(values) ? values : []
const mapById = new Map<string, any>()
const mapByName = new Map<string, any>()
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return
}
const fieldId = entry.customField?.id || entry.customFieldId || null
if (fieldId) {
mapById.set(fieldId, entry)
}
const fieldName = entry.customField?.name || entry.name || entry.key || null
if (fieldName) {
mapByName.set(fieldName, entry)
}
})
const resolved: CustomFieldInput[] = definitions.map((definition) => {
const definitionId = definition.customFieldId || definition.id || null
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
if (!matched) {
return {
...definition,
customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null,
orderIndex: definition.orderIndex,
}
}
const resolvedValue = matched.value ?? ''
return {
...definition,
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
customFieldValueId: matched.id ?? null,
value: formatDefaultValue(definition.type, resolvedValue),
orderIndex: Math.min(
definition.orderIndex ?? 0,
typeof matched.customField?.orderIndex === 'number'
? matched.customField.orderIndex
: definition.orderIndex ?? 0,
),
}
}).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
return resolved
}
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldValueId || field.id || `${field.name}-${index}`
const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): CustomFieldInput[] => {
if (!structure || typeof structure !== 'object') {
return []
}
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
}
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') {
return null
}
const name = resolveFieldName(rawField)
if (!name) {
return null
}
const type = resolveFieldType(rawField)
const required = !!rawField.required
const options = Array.isArray(rawField.options)
? rawField.options.filter((option: unknown): option is string => typeof option === 'string')
: []
const defaultSource = resolveDefaultValue(rawField)
const value = formatDefaultValue(type, defaultSource)
const id = typeof rawField.id === 'string' ? rawField.id : null
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
? rawField.customFieldValueId
: null
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
}
const resolveFieldName = (field: any): string => {
if (typeof field?.name === 'string' && field.name.trim()) {
return field.name.trim()
}
if (typeof field?.key === 'string' && field.key.trim()) {
return field.key.trim()
}
if (typeof field?.label === 'string' && field.label.trim()) {
return field.label.trim()
}
return ''
}
const resolveFieldType = (field: any): string => {
const allowed = ['text', 'number', 'select', 'boolean', 'date']
const value = typeof field?.type === 'string' ? field.type.toLowerCase() : ''
return allowed.includes(value) ? value : 'text'
}
const resolveDefaultValue = (field: any): any => {
if (!field || typeof field !== 'object') {
return null
}
if (field.defaultValue !== undefined && field.defaultValue !== null) {
return field.defaultValue
}
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') {
return field.value
}
if (field.default !== undefined && field.default !== null) {
return field.default
}
if (field.value && typeof field.value === 'object') {
if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) {
return (field.value as any).defaultValue
}
if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') {
return (field.value as any).value
}
}
return null
}
const formatDefaultValue = (type: string, defaultValue: any): string => {
if (defaultValue === null || defaultValue === undefined) {
return ''
}
if (typeof defaultValue === 'object') {
if (defaultValue === null) {
return ''
}
if ('defaultValue' in (defaultValue as Record<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
}
if ('value' in (defaultValue as Record<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
}
return ''
}
if (type === 'boolean') {
const normalized = String(defaultValue).toLowerCase()
if (normalized === 'true' || normalized === '1') {
return 'true'
}
if (normalized === 'false' || normalized === '0') {
return 'false'
}
return ''
}
return String(defaultValue)
}
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
})
const shouldPersistField = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return toFieldString(field.value).trim() !== ''
}
const formatValueForPersistence = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' ? 'true' : 'false'
}
return toFieldString(field.value).trim()
}
const saveCustomFieldValues = async (updatedPiece: any) => {
if (!updatedPiece || !updatedPiece.id) {
return
}
const definitionMap = new Map<string, string>()
const registerDefinitions = (fields: any[]) => {
if (!Array.isArray(fields)) {
return
}
fields.forEach((field) => {
if (!field || typeof field !== 'object') {
return
}
const name = typeof field.name === 'string' ? field.name : null
const id = typeof field.id === 'string' ? field.id : null
if (name && id && !definitionMap.has(name)) {
definitionMap.set(name, id)
}
})
}
registerDefinitions(updatedPiece?.typePiece?.pieceCustomFields)
registerDefinitions(updatedPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields)
const resolveDefinitionId = (field: CustomFieldInput) => {
if (field.customFieldId) {
return field.customFieldId
}
if (field.id) {
return field.id
}
return definitionMap.get(field.name) ?? null
}
for (const field of customFieldInputs.value) {
if (!shouldPersistField(field)) {
continue
}
const definitionId = resolveDefinitionId(field)
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
const value = formatValueForPersistence(field)
if (field.customFieldValueId) {
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
if (!result.success) {
toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
} else if (definitionId && !field.customFieldId) {
field.customFieldId = definitionId
}
continue
}
const result = await upsertCustomFieldValue(
definitionId,
'piece',
updatedPiece.id,
value,
metadata,
)
if (!result.success) {
toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
} else {
const createdValue = result.data
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
}
const resolvedId = createdValue?.customField?.id || definitionId
if (resolvedId) {
field.customFieldId = resolvedId
}
}
}
}
onMounted(async () => {
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
loading.value = false
if (piece.value?.id) {
await refreshDocuments()
}
})
</script>