feat: add product catalogue and product-aware UI
- introduce product catalogue pages, management view entries and shared product composables\n- wire product selection into component/piece flows and machine skeleton requirements\n- display linked product metadata and documents across machine, component and piece views\n- generalize model type tooling to handle PRODUCT category
This commit is contained in:
518
app/pages/product/create.vue
Normal file
518
app/pages/product/create.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<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>
|
||||
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
||||
Retour au catalogue
|
||||
</NuxtLink>
|
||||
</header>
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<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="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="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="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="submitting || !selectedType"
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
/>
|
||||
</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="creationForm.supplierPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="submitting || !selectedType"
|
||||
placeholder="Valeur indicatrice"
|
||||
>
|
||||
</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.' }}
|
||||
</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 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>
|
||||
<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="submitting"
|
||||
>
|
||||
<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="submitting"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<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="submitting"
|
||||
>
|
||||
<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="submitting"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="submitting"
|
||||
>
|
||||
</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">
|
||||
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': 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>
|
||||
|
||||
<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 }">
|
||||
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 && !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 { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
|
||||
interface ProductCatalogType extends ModelType {
|
||||
structure: ProductModelStructure | null
|
||||
customFields?: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
|
||||
const { createProduct } = useProducts()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
|
||||
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 selectedDocuments = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
|
||||
interface CustomFieldInput {
|
||||
id: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
value: string
|
||||
customFieldId: string | null
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
|
||||
const loadingTypes = computed(() => loadingProductTypes.value)
|
||||
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
|
||||
})
|
||||
|
||||
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()
|
||||
customFieldInputs.value = []
|
||||
return
|
||||
}
|
||||
if (!creationForm.name) {
|
||||
creationForm.name = type.name
|
||||
}
|
||||
customFieldInputs.value = normalizeCustomFieldInputs(normalizeProductStructureForSave(type.structure))
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
customFieldInputs.value.every((field) => {
|
||||
if (!field.required) {
|
||||
return true
|
||||
}
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return field.value.trim().length > 0
|
||||
}),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
selectedType.value &&
|
||||
creationForm.name.trim().length >= 2 &&
|
||||
requiredCustomFieldsFilled.value &&
|
||||
!submitting.value,
|
||||
))
|
||||
|
||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||
field.customFieldId || field.id || `${field.name}-${index}`
|
||||
|
||||
const normalizeCustomFieldInputs = (structure: ProductModelStructure | null): CustomFieldInput[] => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return []
|
||||
}
|
||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||
return fields
|
||||
.map((field, index) => normalizeCustomField(field, index))
|
||||
.filter((field): field is CustomFieldInput => field !== null)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||
if (!rawField || typeof rawField !== 'object') {
|
||||
return null
|
||||
}
|
||||
const name = typeof rawField.name === 'string' ? rawField.name.trim() : ''
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const type = typeof rawField.type === 'string' ? rawField.type : 'text'
|
||||
const required = !!rawField.required
|
||||
const options = Array.isArray(rawField.options)
|
||||
? rawField.options.filter((option: unknown): option is string => typeof option === 'string')
|
||||
: []
|
||||
const defaultSource = rawField.defaultValue ?? rawField.value ?? rawField.default ?? null
|
||||
const value = formatDefaultValue(type, defaultSource)
|
||||
const id = typeof rawField.id === 'string' ? rawField.id : null
|
||||
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
|
||||
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||
|
||||
return { id, name, type, required, options, value, customFieldId, orderIndex }
|
||||
}
|
||||
|
||||
const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
if (defaultValue === null || defaultValue === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
return String(defaultValue === true || String(defaultValue).toLowerCase() === 'true')
|
||||
}
|
||||
return String(defaultValue)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (creationForm.constructeurIds.length) {
|
||||
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||
}
|
||||
|
||||
const rawPrice = creationForm.supplierPrice.trim()
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.supplierPrice = 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
|
||||
const failedFields = await saveCustomFieldValues(result.data.id)
|
||||
if (failedFields.length) {
|
||||
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||
await router.push(`/product/${result.data.id}/edit`)
|
||||
return
|
||||
}
|
||||
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.push('/product-catalog')
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la création du produit')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveCustomFieldValues = async (productId: string) => {
|
||||
const failed: string[] = []
|
||||
for (const field of customFieldInputs.value) {
|
||||
if (!field.customFieldId || !field.name) {
|
||||
continue
|
||||
}
|
||||
const value = field.value ?? ''
|
||||
const result = await upsertCustomFieldValue(
|
||||
field.customFieldId,
|
||||
'product',
|
||||
productId,
|
||||
String(value ?? ''),
|
||||
{ customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required },
|
||||
)
|
||||
if (!result.success) {
|
||||
failed.push(field.name)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProductTypes()
|
||||
if (selectedTypeId.value && !selectedType.value) {
|
||||
await router.replace({
|
||||
path: route.path,
|
||||
query: { ...route.query, typeId: undefined },
|
||||
}).catch(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user