Files
Inventory/frontend/app/pages/pieces/create.vue
r-dev 201485552a fix(ui) : remove legacy edit pages and history composables, unify create/edit forms
Consolidate create and edit pages into single create pages with edit mode support.
Remove obsolete catalog pages, history composables, and fix remaining code review issues.
Include migration to relink orphaned custom fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:19:50 +02:00

549 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="Un produit est requis pour cette pièce."
@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" />
</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>
</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,
areProductSelectionsFilled,
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,
})
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(() =>
areProductSelectionsFilled(
requiresProductSelection.value,
productRequirementEntries.value,
productSelections.value,
),
)
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
}
if (!productSelectionsFilled.value) {
toast.showError('Sélectionnez un produit conforme au squelette.')
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>