Files
Inventory/frontend/app/components/PieceItem.vue
r-dev 1c3b566923 feat(machines) : allow category-only links on machine structure
Enable adding a component, piece, or product to a machine by selecting
only the category (ModelType) without a specific entity. The link
displays a red "À remplir" badge; clicking it reopens the modal
pre-filled with the category so the user can associate an item later.

Backend: entity FKs made nullable on the 3 link tables, modelType FK
added, controller/audit/version/MCP normalization adapted for null
entities.

Frontend: modal accepts category-only confirm, page handles fill mode,
hierarchy builder creates pending nodes, display components show
clickable badge with event propagation through the full hierarchy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:15:47 +02:00

589 lines
19 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>
<div class="space-y-4">
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
<div class="flex items-start justify-between p-4 rounded-lg" :class="piece._emptySlot || piece.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'">
<div class="flex items-start gap-3 flex-1 min-w-0">
<button
type="button"
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
:class="{ 'rotate-90': !isCollapsed }"
:aria-expanded="!isCollapsed"
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
@click="toggleCollapse"
>
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
</button>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
{{ pieceData.name }}
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1"> manquant</span>
<button
v-if="piece.pendingEntity"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors ml-1"
title="Cliquer pour associer un item"
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
>
À remplir
</button>
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
>
×{{ displayQuantity }}
</span>
</h3>
<div class="flex flex-wrap gap-2 mt-2">
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
Rattachée à {{ piece.parentComponentName }}
</span>
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
<template v-if="pieceConstructeursDisplay.length">
<span
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="badge badge-outline badge-sm"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5">
({{ supplierReferenceMap.get(constructeur.id) }})
</span>
</span>
</template>
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div>
</div>
</div>
<button
v-if="showDelete"
type="button"
class="btn btn-ghost btn-xs text-error shrink-0"
title="Supprimer cette pièce"
@click="$emit('delete')"
>
Supprimer
</button>
</div>
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4">
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
<div class="space-y-2 text-sm">
<div v-if="isEditMode" class="form-control">
<label class="label">
<span class="label-text text-sm">Quantité</span>
</label>
<input
v-model.number="pieceData.quantity"
type="number"
min="1"
step="1"
class="input input-bordered input-sm md:input-md w-24"
@blur="updatePiece"
/>
</div>
<div v-else-if="displayQuantity > 1">
<span class="font-medium">Quantité:</span>
<span class="ml-2">{{ displayQuantity }}</span>
</div>
<div>
<span class="font-medium">Référence:</span>
<input
v-if="isEditMode"
:id="`piece-reference-${piece.id}`"
v-model="pieceData.reference"
type="text"
class="input input-sm input-bordered ml-2"
@blur="updatePiece"
/>
<span v-else class="ml-2">{{
pieceData.reference || "Non définie"
}}</span>
</div>
<div v-if="pieceData.referenceAuto">
<span class="font-medium">Référence auto:</span>
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
</div>
<div>
<span class="font-medium">Fournisseur:</span>
<div v-if="!isEditMode" class="ml-2">
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
<div
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="flex flex-col"
>
<span class="font-medium">
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
</span>
<span
v-if="formatConstructeurContact(constructeur)"
class="text-xs text-base-content/50"
>
{{ formatConstructeurContact(constructeur) }}
</span>
</div>
</div>
<span v-else class="font-medium">
Non défini
</span>
</div>
<ConstructeurSelect
v-else
class="w-full"
:model-value="pieceConstructeurIds"
:initial-options="pieceConstructeursDisplay"
placeholder="Sélectionner un ou plusieurs fournisseurs..."
@update:model-value="handleConstructeurChange"
/>
</div>
<div>
<span class="font-medium">Prix:</span>
<input
v-if="isEditMode"
:id="`piece-prix-${piece.id}`"
v-model="pieceData.prix"
type="number"
step="0.01"
class="input input-sm input-bordered ml-2"
@blur="updatePiece"
/>
<span v-else class="ml-2">{{
pieceData.prix ? `${pieceData.prix}` : "Non défini"
}}</span>
</div>
<div>
<span class="font-medium">Produit catalogue:</span>
<div v-if="isEditMode" class="mt-2 space-y-2">
<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-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
>
<p class="text-sm font-semibold text-base-content">
{{ selectedProduct.name }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="flex flex-wrap gap-1"
>
<span class="font-semibold">{{ info.label }} :</span>
<span>{{ info.value }}</span>
</p>
<NuxtLink
v-if="selectedProduct.id"
:to="`/product/${selectedProduct.id}`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</div>
<p v-else class="text-xs text-base-content/60">
Aucun produit associé.
</p>
</div>
<div class="ml-2">
<div v-if="displayProduct" class="space-y-1">
<p class="font-medium text-base-content">
{{ displayProductName || 'Produit catalogue' }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/70"
>
<span class="font-semibold">{{ info.label }} :</span>
<span class="ml-1">{{ info.value }}</span>
</p>
<ProductDocumentsInline
:documents="productDocuments"
@preview="openPreview"
/>
</div>
<span v-else class="font-medium">
Non défini
</span>
</div>
</div>
</div>
</div>
<!-- Champs personnalisés de la pièce -->
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
@field-input="handleCustomFieldInput"
@field-blur="handleCustomFieldBlur"
/>
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
<span
v-if="isEditMode && selectedFiles.length"
class="badge badge-outline"
>
{{ selectedFiles.length }} fichier{{
selectedFiles.length > 1 ? "s" : ""
}}
sélectionné{{ selectedFiles.length > 1 ? "s" : "" }}
</span>
</div>
<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>
</template>
<script setup>
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 { useConstructeurs } from '~/composables/useConstructeurs'
import { useProducts } from '~/composables/useProducts'
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils'
import {
resolveFieldId,
resolveFieldReadOnly,
} from '~/shared/utils/entityCustomFieldLogic'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
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 {
displayedCustomFields,
updateCustomField,
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
// --- 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 (resolveFieldReadOnly(field)) return
const fieldValueId = resolveFieldId(field)
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 || field?.customField?.id || 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(() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
loadProducts().catch(() => {})
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
if (!props.piece.documents?.length) refreshDocuments()
})
</script>