Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Les sélections de produits liés ne bloquent plus la soumission du formulaire de création ou d'édition de pièce. Les slots vides restent visibles et peuvent être remplis ultérieurement. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
545 lines
20 KiB
Vue
545 lines
20 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="Nouvelle pièce"
|
|
subtitle="Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce."
|
|
:is-edit-mode="false"
|
|
:can-edit="false"
|
|
back-link="/catalogues/pieces"
|
|
/>
|
|
|
|
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections de la pièce">
|
|
<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 pièce</span>
|
|
</label>
|
|
<SearchSelect
|
|
v-model="selectedTypeId"
|
|
:options="pieceTypeList"
|
|
: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-gray-500 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 de la pièce</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>
|
|
|
|
<!-- Description -->
|
|
<div class="form-control">
|
|
<label class="label">
|
|
<span class="label-text">Description</span>
|
|
</label>
|
|
<textarea
|
|
v-model="creationForm.description"
|
|
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
|
:disabled="!canEdit || submitting || !selectedType"
|
|
placeholder="Description de la pièce (optionnel)"
|
|
rows="3"
|
|
/>
|
|
</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">Fournisseur</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 indicatif (€)</span>
|
|
</label>
|
|
<input
|
|
v-model="creationForm.prix"
|
|
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 -->
|
|
<StructureSkeletonPreview
|
|
v-if="selectedType"
|
|
:structure="selectedType.structure"
|
|
:description="selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
|
|
:preview-badge="formatPieceStructurePreview(selectedType.structure)"
|
|
variant="piece"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<template #tab-products>
|
|
<div class="space-y-6">
|
|
<div
|
|
v-if="structureProducts.length"
|
|
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
|
>
|
|
<header class="space-y-1">
|
|
<h2 class="font-semibold text-base-content">
|
|
Produit requis par le squelette
|
|
</h2>
|
|
<p class="text-xs text-base-content/70">
|
|
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
|
|
</p>
|
|
</header>
|
|
<ul class="space-y-2 text-sm text-base-content/80">
|
|
<li
|
|
v-for="(description, index) in productRequirementDescriptions"
|
|
:key="`requirement-${index}`"
|
|
class="flex items-start gap-2"
|
|
>
|
|
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
|
|
<span>{{ description }}</span>
|
|
</li>
|
|
</ul>
|
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
<div
|
|
v-for="entry in productRequirementEntries"
|
|
:key="entry.key"
|
|
class="form-control"
|
|
>
|
|
<label class="label">
|
|
<span class="label-text text-xs font-medium">
|
|
{{ entry.label }}
|
|
</span>
|
|
</label>
|
|
<ProductSelect
|
|
:model-value="productSelections[entry.index] || null"
|
|
:disabled="!canEdit || submitting || !selectedType"
|
|
:type-product-id="entry.typeProductId"
|
|
helper-text="Sélectionnez un produit (optionnel)."
|
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<EmptyState
|
|
v-if="!structureProducts.length"
|
|
title="Aucun produit requis"
|
|
:description="selectedType ? 'Cette catégorie ne requiert pas de produit lié.' : 'Sélectionnez une catégorie pour voir les produits requis.'"
|
|
/>
|
|
</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 (PDF, images, textes…) liés à cette pièce.
|
|
</p>
|
|
</div>
|
|
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }}
|
|
</span>
|
|
</header>
|
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
|
<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 à cette pièce. Ces champs complètent le squelette sélectionné.
|
|
</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/pieces" 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 la pièce
|
|
</button>
|
|
</div>
|
|
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
|
Merci de renseigner tous les champs personnalisés obligatoires avant de créer la pièce.
|
|
</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 ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
|
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
|
import ProductSelect from '~/components/ProductSelect.vue'
|
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
|
import { usePieces } from '~/composables/usePieces'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
|
import { useDocuments } from '~/composables/useDocuments'
|
|
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
|
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
|
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
|
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
|
import type { PieceModelStructure } from '~/shared/types/inventory'
|
|
import type { ModelType } from '~/services/modelTypes'
|
|
import {
|
|
getStructureProducts,
|
|
buildProductRequirementDescriptions,
|
|
buildProductRequirementEntries,
|
|
resizeProductSelections,
|
|
applyProductSelection,
|
|
collectNormalizedProductIds,
|
|
} from '~/shared/utils/pieceProductSelectionUtils'
|
|
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
|
|
|
|
interface PieceCatalogType extends ModelType {
|
|
structure: PieceModelStructure | null
|
|
customFields?: Array<Record<string, any>>
|
|
}
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
|
|
const { createPiece } = usePieces()
|
|
const toast = useToast()
|
|
const { uploadDocuments } = useDocuments()
|
|
const { syncLinks } = useConstructeurLinks()
|
|
const { getConstructeurById } = useConstructeurs()
|
|
const { canEdit } = usePermissions()
|
|
|
|
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,
|
|
description: '' as string,
|
|
reference: '' as string,
|
|
constructeurIds: [] as string[],
|
|
prix: '' as string,
|
|
})
|
|
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
|
const productSelections = ref<(string | null)[]>([])
|
|
|
|
const lastSuggestedName = ref('')
|
|
const cfDefinitions = ref<any[]>([])
|
|
const createdEntityId = ref<string | null>(null)
|
|
const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, saveAll: saveAllCustomFields } = useCustomFieldInputs({
|
|
definitions: cfDefinitions,
|
|
values: [] as any[],
|
|
entityType: 'piece' as CustomFieldEntityType,
|
|
entityId: createdEntityId,
|
|
context: 'standalone',
|
|
})
|
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
|
const selectedDocuments = ref<File[]>([])
|
|
const uploadingDocuments = ref(false)
|
|
|
|
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(() => {})
|
|
})
|
|
|
|
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
|
|
|
|
const typeOptionLabel = (type?: PieceCatalogType) =>
|
|
type?.name || 'Catégorie'
|
|
|
|
const typeOptionDescription = (type?: PieceCatalogType) =>
|
|
type?.description ? String(type.description) : ''
|
|
|
|
const selectedType = computed(() => {
|
|
if (!selectedTypeId.value) {
|
|
return null
|
|
}
|
|
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
|
})
|
|
|
|
const structureProducts = computed(() =>
|
|
getStructureProducts(selectedType.value?.structure ?? null),
|
|
)
|
|
|
|
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
|
|
|
const productRequirementDescriptions = computed(() =>
|
|
buildProductRequirementDescriptions(structureProducts.value),
|
|
)
|
|
|
|
const ensureProductSelections = (count: number) => {
|
|
productSelections.value = resizeProductSelections(productSelections.value, count)
|
|
}
|
|
|
|
const productRequirementEntries = computed(() =>
|
|
buildProductRequirementEntries(structureProducts.value, 'piece-create-product-requirement'),
|
|
)
|
|
|
|
const productSelectionsFilled = computed(() => true)
|
|
|
|
const setProductSelection = (index: number, value: string | null) => {
|
|
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
|
}
|
|
|
|
watch(structureProducts, (products) => {
|
|
ensureProductSelections(products.length)
|
|
})
|
|
|
|
watch(selectedType, (type) => {
|
|
if (!type) {
|
|
clearCreationForm()
|
|
cfDefinitions.value = []
|
|
return
|
|
}
|
|
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
|
|
creationForm.name = type.name
|
|
}
|
|
lastSuggestedName.value = creationForm.name
|
|
cfDefinitions.value = type.structure?.customFields ?? []
|
|
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
|
|
})
|
|
|
|
const canSubmit = computed(() =>
|
|
Boolean(
|
|
canEdit.value &&
|
|
selectedType.value &&
|
|
creationForm.name &&
|
|
requiredCustomFieldsFilled.value &&
|
|
productSelectionsFilled.value &&
|
|
!submitting.value,
|
|
),
|
|
)
|
|
|
|
const entityTabs = computed(() => [
|
|
{ key: 'general', label: 'Général' },
|
|
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
|
|
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
|
|
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
|
|
])
|
|
|
|
const clearCreationForm = () => {
|
|
creationForm.name = ''
|
|
creationForm.description = ''
|
|
creationForm.reference = ''
|
|
creationForm.constructeurIds = []
|
|
constructeurLinks.value = []
|
|
creationForm.prix = ''
|
|
productSelections.value = []
|
|
lastSuggestedName.value = ''
|
|
}
|
|
|
|
const submitCreation = async () => {
|
|
if (!selectedType.value) {
|
|
toast.showError('Sélectionnez une catégorie de pièce.')
|
|
return
|
|
}
|
|
|
|
const payload: Record<string, any> = {
|
|
name: creationForm.name.trim(),
|
|
typePieceId: selectedType.value.id,
|
|
}
|
|
|
|
const description = creationForm.description.trim()
|
|
if (description) {
|
|
payload.description = description
|
|
}
|
|
|
|
const reference = creationForm.reference.trim()
|
|
if (reference) {
|
|
payload.reference = reference
|
|
}
|
|
|
|
const normalizedProductIds = collectNormalizedProductIds(
|
|
productRequirementEntries.value,
|
|
productSelections.value,
|
|
)
|
|
if (normalizedProductIds.length) {
|
|
payload.productIds = normalizedProductIds
|
|
payload.productId = normalizedProductIds[0]
|
|
}
|
|
|
|
const rawPrice = typeof creationForm.prix === 'string'
|
|
? creationForm.prix.trim()
|
|
: creationForm.prix === null || creationForm.prix === undefined
|
|
? ''
|
|
: String(creationForm.prix).trim()
|
|
|
|
if (rawPrice) {
|
|
const parsed = Number(rawPrice)
|
|
if (!Number.isNaN(parsed)) {
|
|
payload.prix = String(parsed)
|
|
}
|
|
}
|
|
|
|
submitting.value = true
|
|
try {
|
|
const result = await createPiece(payload)
|
|
if (result.success && result.data) {
|
|
const createdPiece = result.data as Record<string, any>
|
|
createdEntityId.value = createdPiece.id
|
|
const failedFields = await saveAllCustomFields()
|
|
if (failedFields.length) {
|
|
toast.showError(`Pièce créée, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
|
}
|
|
// Sync constructeur links after creation
|
|
if (constructeurLinks.value.length) {
|
|
await syncLinks('piece', createdPiece.id, [], constructeurLinks.value)
|
|
}
|
|
if (selectedDocuments.value.length && createdPiece.id) {
|
|
uploadingDocuments.value = true
|
|
const uploadResult = await uploadDocuments(
|
|
{
|
|
files: selectedDocuments.value,
|
|
context: { pieceId: createdPiece.id },
|
|
},
|
|
{ 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)
|
|
}
|
|
selectedDocuments.value = []
|
|
}
|
|
toast.showSuccess('Pièce créée avec succès')
|
|
await router.replace(`/piece/${createdPiece.id}?edit=true`)
|
|
} else if (result.error) {
|
|
toast.showError(result.error)
|
|
}
|
|
} catch (error: any) {
|
|
toast.showError(humanizeError(error?.message) || 'Impossible de créer la pièce')
|
|
} 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))
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
onMounted(async () => {
|
|
await loadPieceTypes()
|
|
})
|
|
</script>
|