Files
Inventory/frontend/app/pages/component/create.vue
r-dev f964df76b9
All checks were successful
Auto Tag Develop / tag (push) Successful in 10s
feat(custom-fields) : messages warning champs obligatoires + commandes make frontend
Ajoute des messages visuels (warning + error) quand des champs perso
obligatoires ne sont pas renseignés sur les pages composant (création
et édition). Ajoute make test-front et make test-front-watch au Makefile.

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

330 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" />
<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/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>
<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 le composant.
</p>
</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,
requiredCustomFieldsFilled,
} = useComponentCreate()
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
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>