fix(ui) : remove legacy edit pages and history composables, unify create/edit forms
Consolidate create and edit pages into single create pages with edit mode support. Remove obsolete catalog pages, history composables, and fix remaining code review issues. Include migration to relink orphaned custom fields. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,563 +0,0 @@
|
||||
<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>
|
||||
|
||||
<ConstructeurLinksTable
|
||||
v-if="constructeurLinks.length"
|
||||
v-model="constructeurLinks"
|
||||
/>
|
||||
|
||||
<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"
|
||||
/>
|
||||
|
||||
<EntityVersionList
|
||||
entity-type="product"
|
||||
:entity-id="String(route.params.id)"
|
||||
:field-labels="historyFieldLabels"
|
||||
:refresh-key="versionRefreshKey"
|
||||
@restored="loadProduct()"
|
||||
/>
|
||||
|
||||
<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 { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useProductHistory } from '~/composables/useProductHistory'
|
||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const versionRefreshKey = ref(0)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { getProduct, updateProduct } = useProducts()
|
||||
const {
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments: uploadProductDocuments,
|
||||
deleteDocument: deleteProductDocument,
|
||||
updateDocument,
|
||||
} = useDocuments()
|
||||
const { ensureConstructeurs, getConstructeurById } = useConstructeurs()
|
||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||
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 cfDefinitions = ref<any[]>([])
|
||||
const cfValues = ref<any[]>([])
|
||||
const entityId = computed(() => product.value?.id ?? null)
|
||||
const {
|
||||
fields: customFieldInputs,
|
||||
requiredFilled: requiredCustomFieldsFilled,
|
||||
saveAll: saveAllCustomFields,
|
||||
} = useCustomFieldInputs({
|
||||
definitions: cfDefinitions,
|
||||
values: cfValues,
|
||||
entityType: 'product' as CustomFieldEntityType,
|
||||
entityId,
|
||||
})
|
||||
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 constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
|
||||
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
|
||||
cfDefinitions.value = nextStructure?.customFields ?? []
|
||||
cfValues.value = Array.isArray(nextValues) ? nextValues : []
|
||||
}
|
||||
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
supplierPrice: '' as string,
|
||||
})
|
||||
|
||||
// requiredCustomFieldsFilled comes from useCustomFieldInputs composable
|
||||
|
||||
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 || ''
|
||||
// Load constructeur links
|
||||
fetchLinks('product', String(route.params.id)).then((links) => {
|
||||
constructeurLinks.value = links
|
||||
originalConstructeurLinks.value = links.map(l => ({ ...l }))
|
||||
editionForm.constructeurIds = constructeurIdsFromLinks(links)
|
||||
if (editionForm.constructeurIds.length) {
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
})
|
||||
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
|
||||
? String(product.value.supplierPrice)
|
||||
: ''
|
||||
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => product.value?.documents,
|
||||
(docs) => {
|
||||
if (Array.isArray(docs)) {
|
||||
productDocuments.value = docs
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const submitEdition = async () => {
|
||||
if (!product.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: editionForm.name.trim(),
|
||||
reference: editionForm.reference.trim() || null,
|
||||
}
|
||||
|
||||
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 saveAllCustomFields()
|
||||
if (failedFields.length) {
|
||||
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||
return
|
||||
}
|
||||
await syncLinks('product', product.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||
toast.showSuccess('Produit mis à jour avec succès')
|
||||
versionRefreshKey.value++
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
||||
watch(
|
||||
() => editionForm.constructeurIds,
|
||||
(ids) => {
|
||||
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
|
||||
for (const id of ids) {
|
||||
if (!currentIds.has(id)) {
|
||||
const resolved = getConstructeurById(id)
|
||||
constructeurLinks.value.push({
|
||||
constructeurId: id,
|
||||
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
|
||||
supplierReference: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProduct()
|
||||
})
|
||||
</script>
|
||||
@@ -359,7 +359,7 @@ import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useProductHistory } from '~/composables/useProductHistory'
|
||||
import { useEntityHistory } from '~/composables/useEntityHistory'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
@@ -386,7 +386,7 @@ const {
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useProductHistory()
|
||||
} = useEntityHistory('product')
|
||||
|
||||
const isEditMode = ref(false)
|
||||
const versionRefreshKey = ref(0)
|
||||
|
||||
@@ -1,158 +1,175 @@
|
||||
<template>
|
||||
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-3xl font-semibold text-base-content">Nouveau produit</h1>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||
<div class="card-body space-y-6">
|
||||
<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>
|
||||
<SearchSelect
|
||||
v-model="selectedTypeId"
|
||||
:options="productTypeList"
|
||||
:loading="loadingTypes"
|
||||
size="sm"
|
||||
placeholder="Rechercher une catégorie..."
|
||||
empty-text="Aucune catégorie disponible"
|
||||
:option-label="typeOptionLabel"
|
||||
:option-description="typeOptionDescription"
|
||||
:disabled="!canEdit || loadingTypes || submitting"
|
||||
/>
|
||||
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
|
||||
Chargement des catégories…
|
||||
</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="creationForm.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
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="creationForm.reference"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
placeholder="Référence interne ou fournisseur"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Fournisseurs</span>
|
||||
</label>
|
||||
<ConstructeurSelect
|
||||
v-model="creationForm.constructeurIds"
|
||||
class="w-full"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConstructeurLinksTable
|
||||
v-if="constructeurLinks.length"
|
||||
v-model="constructeurLinks"
|
||||
<DetailHeader
|
||||
title="Nouveau produit"
|
||||
subtitle="Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue."
|
||||
:is-edit-mode="false"
|
||||
:can-edit="false"
|
||||
back-link="/catalogues/produits"
|
||||
/>
|
||||
|
||||
<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="creationForm.supplierPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
placeholder="Valeur indicatrice"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections du produit">
|
||||
<template #tab-general>
|
||||
<div class="space-y-6">
|
||||
<!-- Catégorie -->
|
||||
<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>
|
||||
<SearchSelect
|
||||
v-model="selectedTypeId"
|
||||
:options="productTypeList"
|
||||
:loading="loadingTypes"
|
||||
size="sm"
|
||||
placeholder="Rechercher une catégorie..."
|
||||
empty-text="Aucune catégorie disponible"
|
||||
:option-label="typeOptionLabel"
|
||||
:option-description="typeOptionDescription"
|
||||
:disabled="!canEdit || loadingTypes || submitting"
|
||||
/>
|
||||
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
|
||||
Chargement des catégories…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedType" 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 les champs personnalisés applicables aux produits de cette catégorie.' }}
|
||||
<!-- Nom -->
|
||||
<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="creationForm.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
placeholder="Nom affiché dans le catalogue"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Référence + Fournisseurs -->
|
||||
<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="creationForm.reference"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
placeholder="Référence interne ou fournisseur"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Fournisseurs</span>
|
||||
</label>
|
||||
<ConstructeurSelect
|
||||
v-model="creationForm.constructeurIds"
|
||||
class="w-full"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConstructeurLinksTable
|
||||
v-if="constructeurLinks.length"
|
||||
v-model="constructeurLinks"
|
||||
/>
|
||||
|
||||
<!-- Prix -->
|
||||
<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="creationForm.supplierPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || submitting || !selectedType"
|
||||
placeholder="Valeur indicatrice"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton preview -->
|
||||
<div v-if="selectedType" 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 les champs personnalisés applicables aux produits de cette catégorie.' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
|
||||
Cette catégorie ne définit pas encore de champs personnalisés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #tab-documents>
|
||||
<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">
|
||||
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
|
||||
<DocumentUpload
|
||||
v-model="selectedDocuments"
|
||||
title="Déposer vos fichiers"
|
||||
subtitle="Formats acceptés : PDF, images, documents…"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||
Téléversement des documents en cours…
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
|
||||
Cette catégorie ne définit pas encore de champs personnalisés.
|
||||
</p>
|
||||
</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">
|
||||
Renseignez les valeurs propres à ce produit catalogue.
|
||||
</p>
|
||||
</header>
|
||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||
</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">
|
||||
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
|
||||
</p>
|
||||
<template #tab-custom-fields>
|
||||
<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">
|
||||
Renseignez les valeurs propres à ce produit catalogue.
|
||||
</p>
|
||||
</header>
|
||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||
</div>
|
||||
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
|
||||
<DocumentUpload
|
||||
v-model="selectedDocuments"
|
||||
title="Déposer vos fichiers"
|
||||
subtitle="Formats acceptés : PDF, images, documents…"
|
||||
<EmptyState
|
||||
v-else
|
||||
title="Aucun champ personnalisé"
|
||||
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||
Téléversement des documents en cours…
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</EntityTabs>
|
||||
|
||||
<!-- Save/Cancel buttons -->
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
|
||||
<NuxtLink to="/catalogues/produits" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
|
||||
Annuler
|
||||
</NuxtLink>
|
||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
|
||||
@@ -203,6 +220,7 @@ const { canEdit } = usePermissions()
|
||||
const { syncLinks } = useConstructeurLinks()
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
|
||||
const activeTab = ref('general')
|
||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||
const submitting = ref(false)
|
||||
@@ -240,6 +258,12 @@ const selectedType = computed(() => {
|
||||
return productTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||
})
|
||||
|
||||
const entityTabs = computed(() => [
|
||||
{ key: 'general', label: 'Général' },
|
||||
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
|
||||
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
|
||||
])
|
||||
|
||||
watch(
|
||||
() => route.query.typeId,
|
||||
(value) => {
|
||||
@@ -276,8 +300,6 @@ watch(selectedType, (type) => {
|
||||
cfDefinitions.value = normalized?.customFields ?? []
|
||||
})
|
||||
|
||||
// requiredCustomFieldsFilled comes from useCustomFieldInputs composable
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
canEdit.value &&
|
||||
selectedType.value &&
|
||||
|
||||
Reference in New Issue
Block a user