The definitionSources passed to saveCustomFieldValues were pointing at properties not serialized by the API (typeComposant.customFields, typePiece.pieceCustomFields). Changed to structure.customFields which is the correct serialized path, preventing orphan custom field creation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
525 lines
18 KiB
Vue
525 lines
18 KiB
Vue
<template>
|
|
<div>
|
|
<DocumentPreviewModal
|
|
:document="previewDocument"
|
|
:visible="previewVisible"
|
|
:documents="productDocuments"
|
|
@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-16 text-center">
|
|
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
|
<p class="text-sm text-base-content/70">Chargement du produit…</p>
|
|
</div>
|
|
|
|
<div v-else-if="!product" class="max-w-xl mx-auto">
|
|
<div class="alert alert-error shadow-lg">
|
|
<div>
|
|
<h2 class="font-semibold text-lg">Produit introuvable</h2>
|
|
<p class="text-sm text-base-content/80">
|
|
Nous n'avons pas pu trouver le produit demandé. Il a peut-être été supprimé.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
|
Retour au catalogue
|
|
</button>
|
|
</div>
|
|
|
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
|
|
<div class="card-body space-y-6">
|
|
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<h1 class="text-3xl font-semibold text-base-content">Modifier le produit</h1>
|
|
<p class="text-sm text-base-content/70">
|
|
Mettez à jour les informations du produit et ses champs personnalisés.
|
|
</p>
|
|
</div>
|
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
|
Retour au catalogue
|
|
</button>
|
|
</header>
|
|
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Catégorie de produit</span>
|
|
</label>
|
|
<input
|
|
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
|
|
type="text"
|
|
class="input input-bordered input-sm md:input-md bg-base-200"
|
|
disabled
|
|
>
|
|
<p class="text-xs text-base-content/60 mt-1">
|
|
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Nom du produit</span>
|
|
</label>
|
|
<input
|
|
v-model="editionForm.name"
|
|
type="text"
|
|
class="input input-bordered input-sm md:input-md"
|
|
:disabled="!canEdit || saving"
|
|
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="!canEdit || saving"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Fournisseurs</span>
|
|
</label>
|
|
<ConstructeurSelect
|
|
v-model="editionForm.constructeurIds"
|
|
class="w-full"
|
|
:disabled="!canEdit || saving"
|
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
|
:initial-options="product?.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 fournisseur indicatif (€)</span>
|
|
</label>
|
|
<input
|
|
v-model="editionForm.supplierPrice"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
class="input input-bordered input-sm md:input-md"
|
|
:disabled="!canEdit || saving"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="structurePreview" 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">Champs définis par la catégorie</h2>
|
|
<p class="text-xs text-base-content/70">
|
|
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
|
|
</p>
|
|
</div>
|
|
<span class="badge badge-outline">{{ structurePreview }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
<header class="space-y-1">
|
|
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
|
<p class="text-xs text-base-content/70">
|
|
Mettez à jour les valeurs propres à ce produit.
|
|
</p>
|
|
</header>
|
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
|
</div>
|
|
|
|
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<h2 class="font-semibold text-base-content">Documents</h2>
|
|
<p class="text-xs text-base-content/70">
|
|
Gérez les documents associés à ce produit.
|
|
</p>
|
|
</div>
|
|
<span v-if="selectedFiles.length" class="badge badge-outline">
|
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
|
</span>
|
|
</header>
|
|
<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…
|
|
</p>
|
|
<DocumentListInline
|
|
v-else
|
|
:documents="productDocuments"
|
|
:can-delete="canEdit"
|
|
:can-edit="true"
|
|
:delete-disabled="uploadingDocuments || saving"
|
|
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
|
@preview="openPreview"
|
|
@edit="openEditModal"
|
|
@delete="removeDocument"
|
|
/>
|
|
</div>
|
|
|
|
<EntityHistorySection
|
|
:entries="history"
|
|
:loading="historyLoading"
|
|
:error="historyError"
|
|
:field-labels="historyFieldLabels"
|
|
/>
|
|
|
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
|
<NuxtLink to="/product-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>
|
|
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
|
</p>
|
|
|
|
<!-- Comments -->
|
|
<div class="mt-4">
|
|
<CommentSection
|
|
entity-type="product"
|
|
:entity-id="String(route.params.id)"
|
|
:entity-name="product?.name"
|
|
show-resolved
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</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 { useProducts } from '~/composables/useProducts'
|
|
import { useCustomFields } from '~/composables/useCustomFields'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
|
import { useDocuments } from '~/composables/useDocuments'
|
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
|
import { useProductHistory } from '~/composables/useProductHistory'
|
|
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
|
import { getModelType } from '~/services/modelTypes'
|
|
import type { ProductModelStructure } from '~/shared/types/inventory'
|
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
|
import {
|
|
type CustomFieldInput,
|
|
buildCustomFieldInputs,
|
|
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
|
saveCustomFieldValues as _saveCustomFieldValues,
|
|
} from '~/shared/utils/customFieldFormUtils'
|
|
|
|
const { canEdit } = usePermissions()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
const { getProduct, updateProduct } = useProducts()
|
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
|
const {
|
|
loadDocumentsByProduct,
|
|
uploadDocuments: uploadProductDocuments,
|
|
deleteDocument: deleteProductDocument,
|
|
updateDocument,
|
|
} = useDocuments()
|
|
const { ensureConstructeurs } = useConstructeurs()
|
|
const {
|
|
history,
|
|
loading: historyLoading,
|
|
error: historyError,
|
|
loadHistory,
|
|
} = useProductHistory()
|
|
|
|
const product = ref<any | null>(null)
|
|
const productType = ref<any | null>(null)
|
|
const structure = ref<ProductModelStructure | null>(null)
|
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const selectedFiles = ref<File[]>([])
|
|
const uploadingDocuments = ref(false)
|
|
const loadingDocuments = ref(false)
|
|
const productDocuments = ref<any[]>([])
|
|
const previewDocument = ref<any | null>(null)
|
|
const previewVisible = ref(false)
|
|
const editingDocument = ref<any | null>(null)
|
|
const editModalVisible = ref(false)
|
|
|
|
const historyFieldLabels: Record<string, string> = {
|
|
name: 'Nom',
|
|
reference: 'Référence',
|
|
supplierPrice: 'Prix fournisseur',
|
|
typeProduct: 'Catégorie',
|
|
constructeurIds: 'Fournisseurs',
|
|
}
|
|
|
|
const refreshCustomFieldInputs = (
|
|
structureOverride?: ProductModelStructure | null,
|
|
valuesOverride?: any[] | null,
|
|
) => {
|
|
const nextStructure = structureOverride ?? structure.value ?? null
|
|
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
|
|
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
|
|
}
|
|
|
|
const editionForm = reactive({
|
|
name: '' as string,
|
|
reference: '' as string,
|
|
constructeurIds: [] as string[],
|
|
supplierPrice: '' as string,
|
|
})
|
|
|
|
const requiredCustomFieldsFilled = computed(() =>
|
|
_requiredCustomFieldsFilled(customFieldInputs.value),
|
|
)
|
|
|
|
const canSubmit = computed(() =>
|
|
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
|
)
|
|
|
|
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
|
|
|
|
const openPreview = (doc: any) => {
|
|
if (!doc || !canPreviewDocument(doc)) return
|
|
previewDocument.value = doc
|
|
previewVisible.value = true
|
|
}
|
|
const closePreview = () => { previewVisible.value = false; previewDocument.value = null }
|
|
|
|
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 = productDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
|
|
if (idx !== -1) {
|
|
productDocuments.value[idx] = { ...productDocuments.value[idx], ...data }
|
|
}
|
|
}
|
|
editModalVisible.value = false
|
|
editingDocument.value = null
|
|
}
|
|
|
|
const loadProduct = async () => {
|
|
const id = route.params.id
|
|
if (!id || typeof id !== 'string') {
|
|
product.value = null
|
|
loading.value = false
|
|
return
|
|
}
|
|
const result = await getProduct(id)
|
|
if (result.success && result.data) {
|
|
product.value = result.data
|
|
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
|
|
|
|
await loadProductType()
|
|
|
|
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
|
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
|
refreshCustomFieldInputs(undefined, customValues)
|
|
|
|
hydrateForm()
|
|
|
|
// History is non-blocking — template handles its own loading state
|
|
loadHistory(result.data.id).catch(() => {})
|
|
} else {
|
|
product.value = null
|
|
}
|
|
loading.value = false
|
|
}
|
|
|
|
const refreshDocuments = async () => {
|
|
if (!product.value?.id) {
|
|
return
|
|
}
|
|
loadingDocuments.value = true
|
|
try {
|
|
const result = await loadDocumentsByProduct(product.value.id, { updateStore: false })
|
|
if (result.success) {
|
|
productDocuments.value = Array.isArray(result.data) ? result.data : []
|
|
}
|
|
} finally {
|
|
loadingDocuments.value = false
|
|
}
|
|
}
|
|
|
|
const removeDocument = async (documentId: string | number | null | undefined) => {
|
|
if (!documentId) {
|
|
return
|
|
}
|
|
const result = await deleteProductDocument(documentId, { updateStore: false })
|
|
if (result.success) {
|
|
productDocuments.value = productDocuments.value.filter((doc) => doc.id !== documentId)
|
|
toast.showSuccess('Document supprimé')
|
|
}
|
|
}
|
|
|
|
const handleFilesAdded = async (files: File[]) => {
|
|
if (!files?.length || !product.value?.id) {
|
|
return
|
|
}
|
|
uploadingDocuments.value = true
|
|
try {
|
|
const result = await uploadProductDocuments(
|
|
{
|
|
files,
|
|
context: { productId: product.value.id },
|
|
},
|
|
{ updateStore: false },
|
|
)
|
|
if (result.success) {
|
|
selectedFiles.value = []
|
|
await refreshDocuments()
|
|
toast.showSuccess('Document(s) ajouté(s)')
|
|
} else if (result.error) {
|
|
toast.showError(result.error)
|
|
}
|
|
} finally {
|
|
uploadingDocuments.value = false
|
|
}
|
|
}
|
|
|
|
const loadProductType = async () => {
|
|
// Try using the expanded typeProduct from entity response first
|
|
const embedded = product.value?.typeProduct
|
|
if (embedded && typeof embedded === 'object' && embedded.id) {
|
|
const embeddedStructure = embedded.structure ?? null
|
|
if (embeddedStructure) {
|
|
productType.value = embedded
|
|
structure.value = normalizeProductStructureForSave(embeddedStructure)
|
|
return
|
|
}
|
|
}
|
|
|
|
if (!product.value?.typeProductId) {
|
|
productType.value = embedded ?? null
|
|
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
|
return
|
|
}
|
|
try {
|
|
const type = await getModelType(product.value.typeProductId)
|
|
productType.value = type
|
|
structure.value = normalizeProductStructureForSave(type?.structure ?? null)
|
|
} catch (error) {
|
|
console.error('Erreur lors du chargement du type de produit:', error)
|
|
productType.value = embedded ?? null
|
|
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
|
}
|
|
}
|
|
|
|
const hydrateForm = () => {
|
|
if (!product.value) {
|
|
return
|
|
}
|
|
editionForm.name = product.value.name || ''
|
|
editionForm.reference = product.value.reference || ''
|
|
editionForm.constructeurIds = uniqueConstructeurIds(
|
|
product.value,
|
|
Array.isArray(product.value.constructeurs) ? product.value.constructeurs : [],
|
|
)
|
|
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
|
|
? String(product.value.supplierPrice)
|
|
: ''
|
|
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
|
if (editionForm.constructeurIds.length) {
|
|
// Smart-cached + deduped — fire-and-forget, ConstructeurSelect handles its own loading
|
|
ensureConstructeurs(editionForm.constructeurIds).catch(() => {})
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => product.value?.documents,
|
|
(docs) => {
|
|
if (Array.isArray(docs)) {
|
|
productDocuments.value = docs
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
const submitEdition = async () => {
|
|
if (!product.value) {
|
|
return
|
|
}
|
|
|
|
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
|
|
|
const payload: Record<string, any> = {
|
|
name: editionForm.name.trim(),
|
|
reference: editionForm.reference.trim() || null,
|
|
constructeurIds,
|
|
}
|
|
|
|
const rawPrice = typeof editionForm.supplierPrice === 'string'
|
|
? editionForm.supplierPrice.trim()
|
|
: editionForm.supplierPrice
|
|
payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined
|
|
? Number.isNaN(Number(rawPrice))
|
|
? null
|
|
: String(Number(rawPrice))
|
|
: null
|
|
|
|
saving.value = true
|
|
try {
|
|
const result = await updateProduct(product.value.id, payload)
|
|
if (result.success && result.data?.id) {
|
|
product.value = result.data
|
|
const failedFields = await _saveCustomFieldValues(
|
|
'product',
|
|
result.data.id,
|
|
[result.data?.typeProduct?.structure?.customFields],
|
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
|
)
|
|
if (failedFields.length) {
|
|
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
|
return
|
|
}
|
|
toast.showSuccess('Produit mis à jour avec succès')
|
|
}
|
|
} catch (error: any) {
|
|
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadProduct()
|
|
})
|
|
</script>
|