Files
Inventory/frontend/app/pages/piece/[id].vue
r-dev 9fc88df3ff
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
fix(piece) : rendre les slots produit optionnels en création et édition
Les sélections de produits liés ne bloquent plus la soumission du
formulaire de création ou d'édition de pièce. Les slots vides restent
visibles et peuvent être remplis ultérieurement.

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

565 lines
24 KiB
Vue

<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<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>
<EmptyState
v-else-if="!piece"
title="Pièce introuvable"
description="Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée."
action-label="Retour au catalogue"
action-to="/catalogues/pieces"
/>
<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">
<DetailHeader
:title="isEditMode ? 'Modifier la pièce' : piece.name"
:subtitle="isEditMode ? 'Ajustez les informations de la pièce et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/catalogues/pieces"
@toggle-edit="isEditMode = !isEditMode"
/>
<EntityTabs
v-model="activeTab"
:tabs="entityTabs"
aria-label="Sections de la pièce"
>
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie (always shown) -->
<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>
<template v-if="isEditMode">
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
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>
<NuxtLink
v-if="selectedTypeId"
:to="`/piece-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<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>
</template>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ selectedType?.name || '' }}
</p>
</div>
</div>
<!-- Nom (always shown) -->
<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-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ piece.name }}
</p>
</div>
</div>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || piece.description" class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-if="isEditMode"
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ piece.description }}
</div>
</div>
<!-- Référence auto (read-only, shown only if computed) -->
<div v-if="piece.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<p class="text-sm font-medium text-base-content py-1 flex items-center gap-2">
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
<span class="badge badge-sm badge-ghost">auto</span>
</p>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || piece.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ piece.reference }}
</p>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<template v-if="isEditMode">
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []"
/>
<ConstructeurLinksTable
v-model="constructeurLinks"
class="mt-2"
@remove="handleConstructeurRemoved"
/>
</template>
<ConstructeurLinksTable
v-else
:model-value="constructeurLinks"
readonly
/>
</div>
</div>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || piece.prix" 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-if="isEditMode"
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ piece.prix }} €
</p>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && (selectedType || resolvedStructure)"
:structure="resolvedStructure"
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
variant="piece"
/>
<UsedInSection entity-type="pieces" :entity-id="piece?.id ?? null" />
</div>
</template>
<template #tab-products>
<div class="space-y-6">
<!-- Product requirements -->
<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">
Produits liés
</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.' : 'Produits associés à cette pièce via le squelette.' }}
</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 v-if="isEditMode" 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" :class="{ 'text-error font-semibold': !productSelections[entry.index] }">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || saving"
:type-product-id="entry.typeProductId"
helper-text="Sélectionnez un produit (optionnel)."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="(entry, index) in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelectionLabels[index] }">{{ entry.label }}</span>
</label>
<div class="text-sm font-medium py-1 px-2 rounded" :class="productSelectionLabels[index] ? 'text-base-content' : 'border border-error bg-error/10 text-error font-semibold'">
<template v-if="!productSelectionLabels[index]">{{ entry.label }} — manquant</template>
<template v-else>{{ productSelectionLabels[index] }}</template>
</div>
</div>
</div>
</div>
</div>
</template>
<template #tab-documents>
<div class="space-y-6">
<!-- Documents -->
<div
v-if="isEditMode || pieceDocuments.length > 0"
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">
{{ isEditMode ? 'Gérez les documents associés à cette pièce.' : 'Documents associés à cette pièce.' }}
</p>
</div>
<span v-if="isEditMode && 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>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || 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>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
/>
</template>
</div>
</div>
</template>
<template #tab-custom-fields>
<div class="space-y-6">
<!-- Custom fields -->
<div v-if="visibleCustomFields.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 v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<p class="text-sm font-medium text-base-content py-1">
{{ field.value }}
</p>
</div>
</div>
</template>
</div>
</div>
</template>
<template #tab-history>
<div class="space-y-6">
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="piece"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchPiece()"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece"
:entity-id="String(route.params.id)"
:entity-name="piece?.name"
show-resolved
/>
</div>
</div>
</template>
</EntityTabs>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
Annuler
</button>
<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>
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from '#imports'
import { usePieceEdit } from '~/composables/usePieceEdit'
import { useDocuments } from '~/composables/useDocuments'
import { usePermissions } from '~/composables/usePermissions'
const route = useRoute()
const { canEdit } = usePermissions()
const { updateDocument } = useDocuments()
const isEditMode = ref(false)
const versionRefreshKey = ref(0)
const activeTab = ref((route.query.tab as string) || 'general')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const {
piece,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
pieceDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
constructeurLinks,
productSelections,
customFieldInputs,
requiredCustomFieldsFilled,
pieceTypeList,
selectedType,
resolvedStructure,
structureProducts,
productRequirementDescriptions,
productRequirementEntries,
canSubmit,
historyFieldLabels,
history,
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
setProductSelection,
submitEdition: _submitEdition,
fetchPiece,
formatPieceStructurePreview,
} = usePieceEdit(String(route.params.id))
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
{ key: 'documents', label: 'Documents', count: pieceDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: visibleCustomFields.value.length },
{ key: 'history', label: 'Historique' },
])
const submitEdition = async () => {
await _submitEdition()
if (!saving.value) {
await fetchPiece()
isEditMode.value = false
versionRefreshKey.value++
}
}
// Sync ConstructeurSelect changes → constructeurLinks
watch(() => editionForm.constructeurIds, (newIds) => {
const existing = new Map(constructeurLinks.value.map(l => [l.constructeurId, l]))
constructeurLinks.value = newIds.map(id =>
existing.get(id) || { constructeurId: id, supplierReference: null },
)
})
const handleConstructeurRemoved = (constructeurId: string) => {
editionForm.constructeurIds = editionForm.constructeurIds.filter(id => id !== constructeurId)
}
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
// Resolve product names for read-only display from piece data
const productSelectionLabels = computed(() => {
if (!piece.value) return []
const p = piece.value as any
// piece.product contains {id, name} for the legacy single product
if (p.product?.name) return [p.product.name]
return productSelections.value.map((id: string | null) => id || null)
})
const visibleCustomFields = computed(() => {
if (isEditMode.value) return customFieldInputs.value
return customFieldInputs.value.filter(
(f) => f.value !== null && f.value !== undefined && f.value !== '',
)
})
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = pieceDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
pieceDocuments.value[idx] = { ...pieceDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
onMounted(() => {
if (route.query.edit === 'true' && canEdit.value) {
isEditMode.value = true
}
})
</script>