refactor : merge Inventory_frontend submodule into frontend/ directory
Merges the full git history of Inventory_frontend into the monorepo under frontend/. Removes the submodule in favor of a unified repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
439
frontend/app/pages/product/create.vue
Normal file
439
frontend/app/pages/product/create.vue
Normal file
@@ -0,0 +1,439 @@
|
||||
<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">
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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 { 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 {
|
||||
type CustomFieldInput,
|
||||
normalizeCustomFieldInputs as normalizeCustomFieldInputsFromUtils,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
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 { upsertCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { canEdit } = usePermissions()
|
||||
const { syncLinks } = useConstructeurLinks()
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
|
||||
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 customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
|
||||
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 = normalizeCustomFieldInputsFromUtils(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(
|
||||
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
|
||||
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.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
|
||||
}
|
||||
}
|
||||
|
||||
const saveCustomFieldValues = async (productId: string) => {
|
||||
const failed: string[] = []
|
||||
for (const field of customFieldInputs.value) {
|
||||
if (!field.name) {
|
||||
continue
|
||||
}
|
||||
const value = field.value ?? ''
|
||||
const metadata = field.customFieldId
|
||||
? undefined
|
||||
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
|
||||
const result = await upsertCustomFieldValue(
|
||||
field.customFieldId,
|
||||
'product',
|
||||
productId,
|
||||
String(value ?? ''),
|
||||
metadata,
|
||||
)
|
||||
if (!result.success) {
|
||||
failed.push(field.name)
|
||||
} else {
|
||||
const createdValue = result.data
|
||||
if (createdValue?.id) {
|
||||
field.customFieldValueId = createdValue.id
|
||||
}
|
||||
const resolvedId = createdValue?.customField?.id || field.customFieldId
|
||||
if (resolvedId) {
|
||||
field.customFieldId = resolvedId
|
||||
}
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// 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>
|
||||
Reference in New Issue
Block a user