Ajoute context: 'standalone' aux appels useCustomFieldInputs dans les vues composant, pièce et produit (création et édition) pour filtrer les champs perso réservés au contexte machine. Exclut également ces champs de la formule de référence automatique dans le ReferenceFormulaBuilder des catégories. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
428 lines
16 KiB
Vue
428 lines
16 KiB
Vue
<template>
|
|
<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">
|
|
<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"
|
|
/>
|
|
|
|
<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>
|
|
|
|
<!-- 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>
|
|
</template>
|
|
|
|
<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" />
|
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
|
</p>
|
|
</div>
|
|
<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.'"
|
|
/>
|
|
</template>
|
|
</EntityTabs>
|
|
|
|
<!-- Save/Cancel buttons -->
|
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
|
<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">
|
|
<span v-if="submitting" class="loading loading-spinner loading-sm mr-2"></span>
|
|
Créer le produit
|
|
</button>
|
|
</div>
|
|
<p v-if="selectedType && hasRequiredCustomFields && !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 SearchSelect from '~/components/common/SearchSelect.vue'
|
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
|
import { useProductTypes } from '~/composables/useProductTypes'
|
|
import { useProducts } from '~/composables/useProducts'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { useDocuments } from '~/composables/useDocuments'
|
|
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
|
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
|
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
|
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
|
import type { ProductModelStructure } from '~/shared/types/inventory'
|
|
import type { ModelType } from '~/services/modelTypes'
|
|
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
|
|
|
|
interface ProductCatalogType extends ModelType {
|
|
structure: ProductModelStructure | null
|
|
customFields?: Array<Record<string, any>>
|
|
}
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
|
|
const { createProduct } = useProducts()
|
|
const toast = useToast()
|
|
const { uploadDocuments } = useDocuments()
|
|
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)
|
|
const creationForm = reactive({
|
|
name: '' as string,
|
|
reference: '' as string,
|
|
constructeurIds: [] as string[],
|
|
supplierPrice: '' as string,
|
|
})
|
|
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
|
const selectedDocuments = ref<File[]>([])
|
|
const uploadingDocuments = ref(false)
|
|
|
|
const cfDefinitions = ref<any[]>([])
|
|
const createdEntityId = ref<string | null>(null)
|
|
const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, saveAll: saveAllCustomFields } = useCustomFieldInputs({
|
|
definitions: cfDefinitions,
|
|
values: [] as any[],
|
|
entityType: 'product' as CustomFieldEntityType,
|
|
entityId: createdEntityId,
|
|
context: 'standalone',
|
|
})
|
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
|
|
|
const productTypeList = computed<ProductCatalogType[]>(() =>
|
|
(productTypes.value || []) as ProductCatalogType[],
|
|
)
|
|
|
|
const typeOptionLabel = (type?: ProductCatalogType) => type?.name || 'Catégorie'
|
|
const typeOptionDescription = (type?: ProductCatalogType) =>
|
|
type?.description ? String(type.description) : ''
|
|
|
|
const selectedType = computed(() => {
|
|
if (!selectedTypeId.value) {
|
|
return null
|
|
}
|
|
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) => {
|
|
if (typeof value === 'string') {
|
|
selectedTypeId.value = value
|
|
}
|
|
},
|
|
)
|
|
|
|
watch(selectedTypeId, (id) => {
|
|
const current = typeof route.query.typeId === 'string' ? route.query.typeId : ''
|
|
if ((id || '') === current) {
|
|
return
|
|
}
|
|
const nextQuery = { ...route.query }
|
|
if (id) {
|
|
nextQuery.typeId = id
|
|
} else {
|
|
delete nextQuery.typeId
|
|
}
|
|
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
|
})
|
|
|
|
watch(selectedType, (type) => {
|
|
if (!type) {
|
|
clearForm()
|
|
cfDefinitions.value = []
|
|
return
|
|
}
|
|
if (!creationForm.name) {
|
|
creationForm.name = type.name
|
|
}
|
|
const normalized = normalizeProductStructureForSave(type.structure)
|
|
cfDefinitions.value = normalized?.customFields ?? []
|
|
})
|
|
|
|
const canSubmit = computed(() => Boolean(
|
|
canEdit.value &&
|
|
selectedType.value &&
|
|
creationForm.name.trim().length >= 2 &&
|
|
requiredCustomFieldsFilled.value &&
|
|
!submitting.value,
|
|
))
|
|
|
|
const clearForm = () => {
|
|
creationForm.name = ''
|
|
creationForm.reference = ''
|
|
creationForm.constructeurIds = []
|
|
creationForm.supplierPrice = ''
|
|
}
|
|
|
|
const buildPayload = () => {
|
|
const payload: Record<string, any> = {
|
|
name: creationForm.name.trim(),
|
|
typeProductId: selectedType.value?.id,
|
|
}
|
|
|
|
const reference = creationForm.reference.trim()
|
|
if (reference) {
|
|
payload.reference = reference
|
|
}
|
|
|
|
const rawPrice = typeof creationForm.supplierPrice === 'string'
|
|
? creationForm.supplierPrice.trim()
|
|
: creationForm.supplierPrice
|
|
if (rawPrice !== '' && rawPrice !== null && rawPrice !== undefined) {
|
|
const parsed = Number(rawPrice)
|
|
if (!Number.isNaN(parsed)) {
|
|
payload.supplierPrice = String(parsed)
|
|
}
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
const submitCreation = async () => {
|
|
if (!selectedType.value) {
|
|
toast.showError('Sélectionnez une catégorie de produit.')
|
|
return
|
|
}
|
|
|
|
submitting.value = true
|
|
try {
|
|
const payload = buildPayload()
|
|
const result = await createProduct(payload)
|
|
if (result.success && result.data?.id) {
|
|
const productId = result.data.id
|
|
createdEntityId.value = productId
|
|
const failedFields = await saveAllCustomFields()
|
|
if (failedFields.length) {
|
|
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
|
await router.replace(`/product/${result.data.id}?edit=true`)
|
|
return
|
|
}
|
|
// Sync constructeur links after creation
|
|
if (constructeurLinks.value.length) {
|
|
await syncLinks('product', productId, [], constructeurLinks.value)
|
|
}
|
|
if (selectedDocuments.value.length) {
|
|
uploadingDocuments.value = true
|
|
const uploadResult = await uploadDocuments(
|
|
{
|
|
files: selectedDocuments.value,
|
|
context: { productId },
|
|
},
|
|
{ updateStore: false },
|
|
)
|
|
if (!uploadResult.success) {
|
|
const message = uploadResult.error
|
|
? `Documents non ajoutés : ${uploadResult.error}`
|
|
: 'Documents non ajoutés : une erreur est survenue.'
|
|
toast.showError(message)
|
|
} else {
|
|
selectedDocuments.value = []
|
|
}
|
|
}
|
|
toast.showSuccess('Produit créé avec succès')
|
|
await router.replace(`/product/${productId}?edit=true`)
|
|
}
|
|
} catch (error: any) {
|
|
toast.showError(error?.message || 'Erreur lors de la création du produit')
|
|
} finally {
|
|
submitting.value = false
|
|
uploadingDocuments.value = false
|
|
}
|
|
}
|
|
|
|
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
|
watch(
|
|
() => creationForm.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 loadProductTypes()
|
|
if (selectedTypeId.value && !selectedType.value) {
|
|
await router.replace({
|
|
path: route.path,
|
|
query: { ...route.query, typeId: undefined },
|
|
}).catch(() => {})
|
|
}
|
|
})
|
|
</script>
|