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>
321 lines
13 KiB
Vue
321 lines
13 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="Nouveau composant"
|
|
subtitle="Sélectionnez la catégorie cible puis complétez les informations du composant."
|
|
:is-edit-mode="false"
|
|
:can-edit="false"
|
|
back-link="/catalogues/composants"
|
|
/>
|
|
|
|
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections composant">
|
|
<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 composant</span>
|
|
</label>
|
|
<SearchSelect
|
|
v-model="selectedTypeId"
|
|
:options="componentTypeList"
|
|
: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 du composant</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 du composant (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>
|
|
</div>
|
|
</template>
|
|
|
|
<template #tab-structure>
|
|
<div class="space-y-6">
|
|
<StructureSkeletonPreview
|
|
v-if="selectedType"
|
|
:structure="selectedTypeStructure"
|
|
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
|
|
:preview-badge="formatStructurePreview(selectedTypeStructure)"
|
|
variant="component"
|
|
show-empty-state
|
|
:resolve-piece-label="resolvePieceLabel"
|
|
:resolve-product-label="resolveProductLabel"
|
|
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
|
/>
|
|
|
|
<div
|
|
v-if="structureHasRequirements"
|
|
class="space-y-4 rounded-lg border border-primary/30 bg-primary/5 p-4"
|
|
>
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 class="font-semibold text-base-content">
|
|
Sélection des éléments du squelette
|
|
</h2>
|
|
<p class="text-xs text-base-content/70">
|
|
Affectez les pièces et sous-composants concrets correspondant à la catégorie choisie.
|
|
</p>
|
|
</div>
|
|
<span
|
|
class="badge"
|
|
:class="structureSelectionsComplete ? 'badge-success' : structureDataLoading ? 'badge-info' : 'badge-warning'"
|
|
>
|
|
{{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
v-if="structureDataLoading"
|
|
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
|
|
>
|
|
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
|
Chargement du catalogue de pièces, produits et composants…
|
|
</div>
|
|
<ComponentStructureAssignmentNode
|
|
v-else-if="structureAssignments"
|
|
:assignment="structureAssignments"
|
|
:pieces="availablePieces"
|
|
:products="availableProducts"
|
|
:components="availableComponents"
|
|
:pieces-loading="piecesLoading"
|
|
:products-loading="productsLoading"
|
|
:components-loading="componentsLoading"
|
|
:piece-type-label-map="pieceTypeLabelMap"
|
|
:product-type-label-map="productTypeLabelMap"
|
|
:component-type-label-map="componentTypeLabelMap"
|
|
/>
|
|
<p v-else class="text-xs text-error">
|
|
Impossible de générer les emplacements définis par le squelette.
|
|
</p>
|
|
</div>
|
|
|
|
<EmptyState
|
|
v-if="!selectedType"
|
|
title="Aucune catégorie sélectionnée"
|
|
description="Sélectionnez une catégorie dans l'onglet Général pour voir la structure du squelette."
|
|
/>
|
|
</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 à ce composant.
|
|
</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 à ce composant selon le squelette choisi.
|
|
</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/composants" 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 composant
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
|
|
|
const route = useRoute()
|
|
const { getConstructeurById } = useConstructeurs()
|
|
|
|
const activeTab = ref('general')
|
|
|
|
const {
|
|
selectedTypeId,
|
|
submitting,
|
|
creationForm,
|
|
constructeurLinks,
|
|
customFieldInputs,
|
|
structureAssignments,
|
|
selectedDocuments,
|
|
uploadingDocuments,
|
|
loadingTypes,
|
|
componentTypeList,
|
|
selectedType,
|
|
selectedTypeStructure,
|
|
availablePieces,
|
|
availableProducts,
|
|
availableComponents,
|
|
piecesLoading,
|
|
productsLoading,
|
|
componentsLoading,
|
|
structureDataLoading,
|
|
pieceTypeLabelMap,
|
|
productTypeLabelMap,
|
|
componentTypeLabelMap,
|
|
structureHasRequirements,
|
|
structureSelectionsComplete,
|
|
canEdit,
|
|
canSubmit,
|
|
typeOptionLabel,
|
|
typeOptionDescription,
|
|
formatStructurePreview,
|
|
resolvePieceLabel,
|
|
resolveProductLabel,
|
|
resolveSubcomponentLabel,
|
|
submitCreation,
|
|
} = useComponentCreate()
|
|
|
|
const entityTabs = computed(() => [
|
|
{ key: 'general', label: 'Général' },
|
|
{ key: 'structure', label: 'Structure' },
|
|
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
|
|
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
|
|
])
|
|
|
|
// 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))
|
|
},
|
|
)
|
|
</script>
|