Files
Inventory_frontend/app/pages/product/[id]/edit.vue

687 lines
24 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>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
@close="closePreview"
/>
<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>
<NuxtLink to="/product-catalog" class="btn btn-primary mt-6">
Retour au catalogue
</NuxtLink>
</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>
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
Retour au catalogue
</NuxtLink>
</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="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="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="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="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>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="saving"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
<input
v-model="field.value"
type="checkbox"
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
:disabled="saving"
>
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="saving"
>
</div>
</div>
</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': 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>
<div v-else-if="productDocuments.length" class="space-y-2">
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && document.path"
:src="document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
<button
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments || saving"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-base-content/70">
Aucun document n'est associé à ce produit pour le moment.
</p>
</div>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-base-content">Historique</h2>
<p class="text-xs text-base-content/70">
Qui a changé quoi, et quand.
</p>
</div>
<span v-if="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in historyEntries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
<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>
</div>
</section>
</main>
</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 { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProductHistory, type ProductHistoryEntry } 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,
fieldKey,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import {
documentIcon,
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
formatHistoryValue,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
deleteDocument: deleteProductDocument,
} = 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 historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
supplierPrice: 'Prix fournisseur',
typeProduct: 'Catégorie',
constructeurIds: 'Fournisseurs',
}
const historyDiffEntries = (entry: ProductHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
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(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 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) {
product.value = result.data
productDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
await loadProductType()
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) {
product.value.customFieldValues = customValues.data
refreshCustomFieldInputs(undefined, customValues.data)
}
await hydrateForm()
await refreshDocuments()
await loadHistory(result.data.id)
} 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 () => {
if (!product.value?.typeProductId) {
productType.value = product.value?.typeProduct ?? null
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
return
}
try {
const type = await getModelType(product.value.typeProductId)
productType.value = type
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
} catch (error) {
console.error('Erreur lors du chargement du type de produit:', error)
productType.value = product.value?.typeProduct ?? null
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
}
}
const hydrateForm = async () => {
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) {
await ensureConstructeurs(editionForm.constructeurIds)
}
}
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,
[],
{ 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')
await router.push('/product-catalog')
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour du produit')
} finally {
saving.value = false
}
}
onMounted(async () => {
await loadProduct()
})
</script>