- Add usePermissions composable (isAdmin, canEdit, canView) - Password-protected profile login with modal on profiles page - Disable all form fields for ROLE_VIEWER across edit/create pages - Show navigation buttons (Modifier/Consulter) for all roles, hide delete for viewers - Add readonly prop to ModelTypeForm for category pages - Disable modal fields (sites, constructeurs) for viewers - Guard /admin routes in middleware Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
483 lines
17 KiB
Vue
483 lines
17 KiB
Vue
<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>
|
|
|
|
<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>
|
|
<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="!canEdit || 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="!canEdit || 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="!canEdit || 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="!canEdit || 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="!canEdit || submitting"
|
|
>
|
|
<input
|
|
v-else
|
|
v-model="field.value"
|
|
type="text"
|
|
class="input input-bordered input-sm md:input-md"
|
|
:required="field.required"
|
|
:disabled="!canEdit || 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': !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 { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
|
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 } = useProductTypes()
|
|
const { createProduct } = useProducts()
|
|
const toast = useToast()
|
|
const { upsertCustomFieldValue } = useCustomFields()
|
|
const { uploadDocuments } = useDocuments()
|
|
const { canEdit } = usePermissions()
|
|
|
|
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)
|
|
|
|
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 = 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 fieldKey = (field: CustomFieldInput, index: number) =>
|
|
field.customFieldId || field.id || `${field.name}-${index}`
|
|
|
|
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
|
|
}
|
|
|
|
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
|
|
|
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.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.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
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadProductTypes()
|
|
if (selectedTypeId.value && !selectedType.value) {
|
|
await router.replace({
|
|
path: route.path,
|
|
query: { ...route.query, typeId: undefined },
|
|
}).catch(() => {})
|
|
}
|
|
})
|
|
</script>
|