Files
Inventory/frontend/app/components/PieceItem.vue
Matthieu 03a5d05a2c
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
feat(machine) : champs perso machine en badges plus gros dans entete composants et pieces
Affiche les champs perso machine entre Row 1 (titre/prix) et Row 2 (fournisseur/catalogue) de l'entete ComponentItem et PieceItem.
Badges plus gros (text-sm), visibles en lecture ET en edition. Edition complete reste dans la section depliee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:23:47 +02:00

751 lines
28 KiB
Vue
Raw Permalink 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>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<!-- HEADER BAR -->
<div
class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
:class="[
piece._emptySlot || piece.pendingEntity
? 'bg-error/10 border border-error/40 hover:border-error/60'
: 'bg-base-200/70 border border-base-300/30 hover:border-base-300/60 hover:bg-base-200',
!isCollapsed ? 'shadow-md ring-1 ring-base-300/20' : 'shadow-sm',
]"
@click="toggleCollapse"
>
<!-- Chevron -->
<div
class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
:class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
>
<IconLucideChevronRight
class="w-3.5 h-3.5 transition-transform duration-200"
:class="[
isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
]"
aria-hidden="true"
/>
</div>
<!-- Content -->
<div class="flex-1 min-w-0 space-y-1.5">
<!-- Row 1: Name + identifiers -->
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-bold tracking-tight truncate" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
<NuxtLink
v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId"
:to="machineId
? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } }
: `/piece/${piece.pieceId}`"
class="hover:text-primary transition-colors"
@click.stop
>
{{ pieceData.name }}
</NuxtLink>
<template v-else>{{ pieceData.name }}</template>
</h3>
<span v-if="piece._emptySlot" class="text-[0.65rem] font-bold text-error bg-error/10 px-1.5 py-0.5 rounded">manquant</span>
<button
v-if="piece.pendingEntity"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
title="Cliquer pour associer un item"
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
>
À remplir
</button>
<span v-if="displayQuantity > 1" class="text-[0.65rem] font-bold text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">×{{ displayQuantity }}</span>
<span v-if="pieceData.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ pieceData.reference }}</span>
<span v-if="pieceData.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ pieceData.referenceAuto }}</span>
<span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}</span>
</div>
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
<div
v-if="visibleContextFieldTags.length"
class="flex flex-wrap items-center gap-2"
>
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
:class="contextFieldBadgeClass(field)"
>
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
<span class="text-sm font-bold">{{ field.value }}</span>
</span>
</div>
<!-- Row 2: Metadata tags -->
<div
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName"
class="flex flex-wrap items-center gap-1.5"
>
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
{{ piece.parentComponentName }}
</span>
<span
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="text-[0.65rem] text-base-content/45"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
</span>
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }}
</span>
</div>
</div>
<!-- Delete button -->
<button
v-if="showDelete"
type="button"
class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
title="Supprimer cette pièce"
@click.stop="$emit('delete')"
>
<IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
<!-- EXPANDED PANEL -->
<div v-show="!isCollapsed && !piece.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
<!-- Section: Informations -->
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
</div>
<div class="p-4">
<!-- Edit mode -->
<div v-if="isEditMode" class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Quantité</span></label>
<input
v-model.number="pieceData.quantity"
type="number"
min="1"
step="1"
class="input input-bordered input-sm w-full"
@blur="updatePiece"
/>
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
<input
:id="`piece-reference-${piece.id}`"
v-model="pieceData.reference"
type="text"
class="input input-bordered input-sm w-full"
@blur="updatePiece"
/>
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
<input
:id="`piece-prix-${piece.id}`"
v-model="pieceData.prix"
type="number"
step="0.01"
class="input input-bordered input-sm w-full"
@blur="updatePiece"
/>
</div>
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
<ConstructeurSelect
class="w-full"
:model-value="pieceConstructeurIds"
:initial-options="pieceConstructeursDisplay"
placeholder="Sélectionner un ou plusieurs fournisseurs..."
@update:model-value="handleConstructeurChange"
/>
</div>
</div>
<!-- Read-only mode -->
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
<div v-if="displayQuantity > 1">
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Quantité</p>
<p class="text-sm text-base-content font-medium">{{ displayQuantity }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
<p class="text-sm" :class="pieceData.reference ? 'text-base-content font-mono' : 'text-base-content/30'">{{ pieceData.reference || '—' }}</p>
</div>
<div v-if="pieceData.referenceAuto">
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
<p class="text-sm text-base-content font-mono">{{ pieceData.referenceAuto }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
<p class="text-sm" :class="pieceData.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ pieceData.prix ? `${pieceData.prix}` : '—' }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
<p
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="text-sm text-base-content"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
<span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
{{ formatConstructeurContact(constructeur) }}
</span>
</p>
</div>
<p v-else class="text-sm text-base-content/30"></p>
</div>
</div>
</div>
</div>
<!-- Section: Produit catalogue -->
<div v-if="isEditMode || displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-info/10 border-b border-info/20">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
</div>
<div class="p-4">
<!-- Edit mode -->
<div v-if="isEditMode" class="space-y-3">
<ProductSelect
:model-value="pieceData.productId"
placeholder="Associer un produit…"
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
@update:modelValue="handleProductChange"
/>
<div
v-if="selectedProduct"
class="rounded-lg border border-base-200/60 bg-base-200/20 p-3 space-y-1.5"
>
<p class="text-sm font-bold text-base-content">{{ selectedProduct.name }}</p>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
</p>
</div>
<NuxtLink v-if="selectedProduct.id" :to="`/product/${selectedProduct.id}`" class="link link-primary text-xs">
Ouvrir la fiche produit
</NuxtLink>
</div>
</div>
<!-- Read-only mode -->
<div v-else-if="displayProduct">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1.5">
<p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
</p>
</div>
</div>
<NuxtLink
v-if="piece.product?.id || piece.productId"
:to="`/product/${piece.product?.id || piece.productId}`"
class="btn btn-ghost btn-xs shrink-0 gap-1"
>
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
Voir
</NuxtLink>
</div>
<ProductDocumentsInline
v-if="productDocuments.length"
class="mt-3 pt-3 border-t border-base-200/50"
:documents="productDocuments"
@preview="openPreview"
/>
</div>
</div>
</div>
<!-- Section: Champs personnalisés item -->
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
</div>
<div class="p-4">
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:show-header="false"
:with-top-border="false"
:editable="false"
@field-input="handleCustomFieldInput"
@field-blur="handleCustomFieldBlur"
/>
</div>
</div>
<!-- Section: Champs personnalisés machine -->
<div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
</div>
<div class="p-4">
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</div>
</div>
<!-- Section: Documents -->
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</div>
<div class="p-4 space-y-3">
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement des documents...</p>
<DocumentUpload
v-if="isEditMode"
v-model="selectedFiles"
title="Déposer des fichiers pour cette pièce"
subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded"
/>
<DocumentListInline
:documents="pieceDocuments"
:can-delete="isEditMode"
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à cette pièce."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
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 IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import IconLucideExternalLink from '~icons/lucide/external-link'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProducts } from '~/composables/useProducts'
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils'
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
import { useCustomFields } from '~/composables/useCustomFields'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
const route = useRoute()
const machineId = computed(() => route.params.id as string | undefined)
const props = defineProps({
piece: { type: Object, required: true },
isEditMode: { type: Boolean, default: false },
showDelete: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete', 'fill-entity'])
// --- Local reactive data for editing ---
const pieceData = reactive({
name: props.piece.name || '',
reference: props.piece.reference || '',
referenceAuto: props.piece.referenceAuto || null,
prix: props.piece.prix || '',
productId: props.piece.product?.id || props.piece.productId || null,
quantity: props.piece.quantity ?? 1,
})
const displayQuantity = computed(() => {
return pieceData.quantity ?? 1
})
// --- Products ---
const { products, loadProducts, getProduct } = useProducts()
const selectedProduct = computed(() => {
const id = pieceData.productId
if (!id) return null
const list = Array.isArray(products.value) ? products.value : []
const cached = list.find((p) => p && p.id === id) || null
if (cached) return cached
const current = props.piece.product
if (current && current.id === id) return current
return null
})
// --- Shared composables ---
const {
documents: pieceDocuments,
selectedFiles,
uploadingDocuments,
loadingDocuments,
previewDocument,
previewVisible,
openPreview,
closePreview,
refreshDocuments,
handleFilesAdded,
removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
const {
displayProduct,
displayProductName,
productInfoRows,
productDocuments,
} = useEntityProductDisplay({ entity: () => props.piece, selectedProduct })
const {
updateCustomFieldValue: updateCustomFieldValueApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
// Parent already pre-merges standalone custom fields into props.piece.customFields
const displayedCustomFields = computed(() => {
const fields = props.piece?.customFields
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
})
const updateCustomField = async (field) => {
if (!field || field.readOnly) return
const e = props.piece
const fieldValueId = field.customFieldValueId
if (fieldValueId) {
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
}
return
}
if (!e?.id) {
showError('Impossible de créer la valeur pour ce champ')
return
}
const metadata = field.customFieldId ? undefined : {
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
}
const result = await upsertCustomFieldValue(
field.customFieldId,
'piece',
e.id,
field.value ?? '',
metadata,
)
if (result.success) {
const newValue = result.data
if (newValue?.id) {
field.customFieldValueId = newValue.id
field.value = newValue.value ?? field.value ?? ''
if (newValue.customField?.id) {
field.customFieldId = newValue.customField.id
}
}
showSuccess(`Champ "${field.name}" créé avec succès`)
} else {
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
}
}
// Context fields are NOT pre-merged — merge locally
const mergedContextFields = computed(() => {
const definitions = props.piece?.contextCustomFields ?? []
const values = props.piece?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return mergeDefinitionsWithValues(definitions, values)
})
// Context fields shown as tags on the header (consultation mode)
const visibleContextFieldTags = computed(() =>
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
)
const CONTEXT_FIELD_COLORS = [
'bg-secondary/25 text-secondary border border-secondary/35',
'bg-accent/25 text-accent border border-accent/35',
'bg-info/25 text-info border border-info/35',
'bg-success/25 text-success border border-success/35',
'bg-warning/25 text-warning border border-warning/35',
]
const contextFieldBadgeClass = (field) => {
const idx = visibleContextFieldTags.value.indexOf(field)
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
}
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.piece?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId
const customFieldValueId = field.customFieldValueId
if (!customFieldId && !customFieldValueId) return
field.value = value
emit('custom-field-update', {
entityType: 'machinePieceLink',
entityId: linkId,
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || 'Champ contextuel',
})
}
// --- Document edit modal ---
const editingDocument = ref(null)
const editModalVisible = ref(false)
const openEditModal = (doc) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data) => {
if (!editingDocument.value?.id) return
await editDocument(editingDocument.value.id, data)
editModalVisible.value = false
editingDocument.value = null
}
// --- 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()
const pieceConstructeurLinks = computed(() =>
parseConstructeurLinksFromApi(
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
),
)
const supplierReferenceMap = computed(() => {
const map = new Map()
pieceConstructeurLinks.value.forEach(l => {
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
})
return map
})
const pieceConstructeurIds = computed(() =>
pieceConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
)
const pieceConstructeursDisplay = computed(() => {
// Extract nested constructeur objects from link entries
const linkConstructeurs = pieceConstructeurLinks.value
.filter(l => l.constructeur && l.constructeur.id)
.map(l => l.constructeur)
return resolveConstructeurs(
pieceConstructeurIds.value,
linkConstructeurs,
constructeurs.value,
)
})
const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur)
const handleConstructeurChange = (value) => {
const ids = uniqueConstructeurIds(value)
props.piece.constructeurIds = [...ids]
props.piece.constructeurId = null
props.piece.constructeur = null
props.piece.constructeurs = resolveConstructeurs(
ids,
constructeurs.value,
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
)
updatePiece()
}
// --- Product handling ---
const ensureProductLoaded = async (id) => {
if (!id) return null
const list = Array.isArray(products.value) ? products.value : []
const cached = list.find((p) => p && p.id === id)
if (cached) return cached
const result = await getProduct(id, { force: true })
return result.success && result.data ? result.data : null
}
const handleProductChange = async (value) => {
const nextId = value || null
pieceData.productId = nextId
props.piece.productId = nextId
if (!nextId) {
props.piece.product = null
updatePiece()
return
}
const resolved = await ensureProductLoaded(nextId)
if (resolved) {
props.piece.product = resolved
const supplierPrice = resolved.supplierPrice
if (
(pieceData.prix === '' || pieceData.prix === null || pieceData.prix === undefined) &&
supplierPrice !== null && supplierPrice !== undefined
) {
const number = Number(supplierPrice)
if (!Number.isNaN(number)) pieceData.prix = String(number)
}
}
updatePiece()
}
// --- Custom field event handlers ---
const handleCustomFieldInput = (field, value) => {
if (field.readOnly) return
const fieldValueId = field.customFieldValueId
if (!fieldValueId) return
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
if (fieldValue) fieldValue.value = value
}
const handleCustomFieldBlur = async (field) => {
await updateCustomField(field)
const cfId = field?.customFieldId || null
if (cfId || field?.customFieldValueId) {
emit('custom-field-update', {
fieldId: cfId,
pieceId: props.piece.id,
value: field?.value ?? '',
})
}
}
// --- Update piece ---
const updatePiece = () => {
const prixValue = pieceData.prix
let parsedPrice = null
if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) {
const numeric = Number(prixValue)
if (!Number.isNaN(numeric)) parsedPrice = String(numeric)
}
const product = selectedProduct.value ? { ...selectedProduct.value } : null
emit('update', {
...props.piece,
...pieceData,
prix: parsedPrice,
quantity: pieceData.quantity ?? 1,
productId: pieceData.productId || null,
product,
constructeurIds: pieceConstructeurIds.value,
})
}
// --- Watchers ---
watch(
() => props.piece.product?.id || props.piece.productId || null,
async (id) => {
if (pieceData.productId === id) {
if (id && !selectedProduct.value) {
const resolved = await ensureProductLoaded(id)
if (resolved) props.piece.product = resolved
}
if (!id) props.piece.product = null
return
}
pieceData.productId = id
if (id) {
const resolved = await ensureProductLoaded(id)
if (resolved) {
props.piece.product = resolved
if (
(pieceData.prix === '' || pieceData.prix === null || pieceData.prix === undefined) &&
resolved.supplierPrice !== null && resolved.supplierPrice !== undefined
) {
const number = Number(resolved.supplierPrice)
if (!Number.isNaN(number)) pieceData.prix = String(number)
}
}
} else {
props.piece.product = null
}
},
{ immediate: true },
)
watch(
() => [props.piece.name, props.piece.reference, props.piece.prix, props.piece.quantity],
() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
},
)
onMounted(() => {
loadProducts().catch(() => {})
if (!props.piece.documents?.length) refreshDocuments()
})
</script>