Restore component catalog with requirement-based instantiation

This commit is contained in:
MatthieuTD
2025-10-06 16:55:45 +02:00
parent c5f2c568b6
commit 384c3f0680
5 changed files with 626 additions and 90 deletions

View File

@@ -62,7 +62,11 @@ export function useComposants () {
const result = await post('/composants', composantData)
if (result.success) {
composants.value.push(result.data)
showSuccess(`Composant "${composantData.name}" créé avec succès`)
const displayName = result.data?.name
|| composantData?.definition?.name
|| composantData?.name
|| 'Composant'
showSuccess(`Composant "${displayName}" créé avec succès`)
}
return result
} catch (error) {

View File

@@ -1,29 +1,587 @@
<template>
<main class="container mx-auto px-6 py-12">
<section class="mx-auto max-w-2xl space-y-4 rounded-2xl border border-dashed border-base-300 bg-base-100 p-8 text-center shadow-sm">
<h1 class="text-3xl font-semibold text-gray-800">
Le catalogue de modèles a été retiré
</h1>
<main class="container mx-auto px-6 py-10 space-y-8">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Catalogue des composants</h1>
<p class="text-sm text-gray-500">
Les modèles sont désormais gérés directement depuis les catégories de composants.
Retrouvez tous vos squelettes et paramètres de référence dans la gestion des catégories.
Consultez les catégories disponibles et instanciez des composants à partir de leur squelette canonique.
</p>
<div class="flex flex-wrap items-center justify-center gap-3 pt-2">
<NuxtLink to="/component-category" class="btn btn-primary">
Ouvrir la gestion des catégories
</NuxtLink>
<NuxtLink to="/piece-category" class="btn btn-outline">
Gérer les catégories de pièces
</NuxtLink>
</div>
<NuxtLink to="/component-category" class="btn btn-outline btn-sm md:btn-md self-start">
Gérer les catégories
</NuxtLink>
</header>
<section>
<div v-if="loadingTypes" class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
</div>
<template v-else>
<p v-if="!componentTypeList.length" class="text-sm text-gray-500">
Aucune catégorie de composant n'est disponible pour le moment. Créez des catégories dans la gestion dédiée pour définir
vos squelettes.
</p>
<div v-else class="grid gap-6 lg:grid-cols-2">
<article
v-for="type in componentTypeList"
:key="type.id"
class="card bg-base-100 border border-base-200 shadow-sm"
>
<div class="card-body space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-1">
<h2 class="card-title text-xl">{{ type.name }}</h2>
<p v-if="type.description" class="text-sm text-gray-500">
{{ type.description }}
</p>
</div>
<button type="button" class="btn btn-primary btn-sm md:btn-md self-start" @click="openCreationModal(type)">
Instancier un composant
</button>
</div>
<div class="flex flex-wrap gap-2 text-xs text-gray-500">
<span class="badge badge-outline">{{ formatStructurePreview(type.structure) }}</span>
<span v-if="getCategoryCustomFields(type).length" class="badge badge-outline">
{{ getCategoryCustomFields(type).length }} champ(s) personnalisés
</span>
</div>
<div v-if="type.structure" class="space-y-3">
<details class="collapse collapse-arrow bg-base-200/60">
<summary class="collapse-title text-sm font-medium">
Détails du squelette
</summary>
<div class="collapse-content space-y-3 text-sm text-base-content/80">
<div v-if="getStructureCustomFields(type.structure).length" class="space-y-1">
<h3 class="font-semibold text-sm text-base-content">Valeurs par défaut</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="field in getStructureCustomFields(type.structure)"
:key="field.key || field.name"
>
<span class="font-medium">{{ field.key || field.name }}</span>
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
</li>
</ul>
</div>
<div v-if="getStructurePieces(type.structure).length" class="space-y-1">
<h3 class="font-semibold text-sm text-base-content">Pièces imposées</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(piece, index) in getStructurePieces(type.structure)"
:key="piece.role || piece.typePieceId || piece.familyCode || index"
>
{{ resolvePieceLabel(piece) }}
</li>
</ul>
</div>
<div v-if="getStructureSubcomponents(type.structure).length" class="space-y-1">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(subcomponent, index) in getStructureSubcomponents(type.structure)"
:key="subcomponent.alias || subcomponent.typeComposantId || subcomponent.familyCode || index"
>
{{ resolveSubcomponentLabel(subcomponent) }}
</li>
</ul>
</div>
<p
v-if="!getStructureCustomFields(type.structure).length && !getStructurePieces(type.structure).length && !getStructureSubcomponents(type.structure).length"
class="text-xs text-gray-500"
>
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
</p>
</div>
</details>
</div>
<div v-if="getCategoryCustomFields(type).length" class="space-y-2">
<h3 class="text-sm font-semibold text-base-content">Champs personnalisés de la catégorie</h3>
<ul class="space-y-1 text-sm text-base-content/80">
<li
v-for="field in getCategoryCustomFields(type)"
:key="field.id || field.name"
class="flex flex-wrap gap-2 items-center"
>
<span class="font-medium">{{ field.name }}</span>
<span class="badge badge-outline badge-xs">{{ field.type || 'text' }}</span>
<span v-if="field.required" class="badge badge-outline badge-xs badge-error">Obligatoire</span>
</li>
</ul>
</div>
<div v-if="getExistingComponents(type).length" class="space-y-2">
<h3 class="text-sm font-semibold text-base-content">Composants existants</h3>
<ul class="space-y-1 text-sm text-base-content/80">
<li
v-for="component in getExistingComponents(type).slice(0, 5)"
:key="component.id"
>
<span class="font-medium">{{ component.name || 'Composant sans nom' }}</span>
<span v-if="component.machine?.name" class="text-xs text-gray-500">
· Machine : {{ component.machine.name }}
</span>
</li>
</ul>
<p
v-if="getExistingComponents(type).length > 5"
class="text-xs text-gray-500"
>
+ {{ getExistingComponents(type).length - 5 }} composant(s) supplémentaires.
</p>
</div>
</div>
</article>
</div>
</template>
</section>
</main>
<Teleport to="body">
<div v-if="creationModalOpen" class="modal modal-open">
<div class="modal-box max-w-3xl space-y-6">
<div class="space-y-1">
<h3 class="text-lg font-semibold">
Instancier un composant
</h3>
<p class="text-sm text-gray-500">
Sélectionnez la machine et le requirement cible puis ajustez les informations d'override avant la création.
</p>
<p v-if="selectedType" class="badge badge-outline badge-sm">
Catégorie : {{ selectedType.name }}
</p>
</div>
<form class="space-y-4" @submit.prevent="submitCreation">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Machine cible</span>
</label>
<select
v-model="creationForm.machineId"
class="select select-bordered select-sm md:select-md"
:disabled="machinesLoading || submitting"
required
>
<option value="">Sélectionner une machine</option>
<option
v-for="machine in machines"
:key="machine.id"
:value="machine.id"
>
{{ machine.name }}
</option>
</select>
<p v-if="machinesLoading" class="text-xs text-gray-500 mt-1">
Chargement des machines...
</p>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Requirement</span>
</label>
<select
v-model="creationForm.requirementId"
class="select select-bordered select-sm md:select-md"
:disabled="requirementLoading || !requirementOptions.length || submitting"
required
>
<option value="">Sélectionner un requirement</option>
<option
v-for="requirement in requirementOptions"
:key="requirement.id"
:value="requirement.id"
>
{{ resolveRequirementLabel(requirement) }}
</option>
</select>
<p v-if="requirementLoading" class="text-xs text-gray-500 mt-1">
Chargement des requirements de la machine...
</p>
<p
v-else-if="creationForm.machineId && !requirementOptions.length"
class="text-xs text-error mt-1"
>
Cette machine n'a pas de requirement de composant configuré.
</p>
</div>
</div>
<div
v-if="selectedRequirement"
class="rounded-lg border border-base-200 bg-base-200/60 p-4 text-xs text-base-content/80 space-y-1"
>
<div class="flex flex-wrap gap-2 items-center">
<span class="font-medium text-sm">Requirement sélectionné :</span>
<span class="badge badge-outline badge-sm">{{ resolveRequirementLabel(selectedRequirement) }}</span>
</div>
<p v-if="selectedRequirement.typeComposant?.name" class="text-xs">
Type attendu : {{ selectedRequirement.typeComposant.name }}
</p>
<p v-if="selectedRequirement.maxCount !== null && selectedRequirement.maxCount !== undefined" class="text-xs">
Capacité : {{ selectedRequirement.minCount ?? (selectedRequirement.required ? 1 : 0) }} -
{{ selectedRequirement.maxCount ?? '' }} élément(s)
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<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="submitting"
placeholder="Nom affiché après instanciation"
>
</div>
<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="submitting"
placeholder="Référence interne ou constructeur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Constructeur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurId"
class="w-full"
:disabled="submitting"
placeholder="Rechercher un constructeur..."
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Prix</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="submitting"
placeholder="Valeur indicative (€)"
>
</div>
</div>
</form>
<div class="modal-action">
<button type="button" class="btn btn-ghost" :disabled="submitting" @click="closeCreationModal">
Annuler
</button>
<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>
<button class="modal-backdrop" aria-label="Fermer" @click="closeCreationModal"></button>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useMachines } from '~/composables/useMachines'
import { useComposants } from '~/composables/useComposants'
import { useToast } from '~/composables/useToast'
import { useApi } from '~/composables/useApi'
import { formatStructurePreview, sanitizeDefinitionOverrides } from '~/shared/modelUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
onMounted(() => {
navigateTo('/component-category', { replace: true })
interface ComponentCatalogType extends ModelType {
structure: ComponentModelStructure | null
customFields?: Array<Record<string, any>>
components?: Array<Record<string, any>>
}
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
const { machines, loadMachines, loading: machinesLoadingRef } = useMachines()
const { createComposant } = useComposants()
const toast = useToast()
const { apiCall } = useApi()
const creationModalOpen = ref(false)
const selectedType = ref<ComponentCatalogType | null>(null)
const submitting = ref(false)
const requirementLoading = ref(false)
const creationForm = reactive({
machineId: '' as string,
requirementId: '' as string,
name: '' as string,
reference: '' as string,
constructeurId: null as string | null,
prix: '' as string,
})
const machineRequirementCache = reactive<Record<string, { requirements: any[] }>>({})
const lastSuggestedName = ref('')
let requirementRequestToken = 0
const loadingTypes = computed(() => loadingComponentTypes.value)
const componentTypeList = computed<ComponentCatalogType[]>(() =>
(componentTypes.value || [])
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
)
const machinesLoading = computed(() => machinesLoadingRef.value)
const requirementOptions = computed(() => {
const machineId = creationForm.machineId
if (!machineId) {
return []
}
const entry = machineRequirementCache[machineId]
if (!entry) {
return []
}
return Array.isArray(entry.requirements) ? entry.requirements : []
})
const selectedRequirement = computed(() => {
return requirementOptions.value.find((requirement: any) => requirement.id === creationForm.requirementId) || null
})
const canSubmit = computed(() => {
return Boolean(creationForm.machineId && creationForm.requirementId && !submitting.value && !requirementLoading.value)
})
const getCategoryCustomFields = (type: ComponentCatalogType) => {
return Array.isArray(type?.customFields) ? type.customFields : []
}
const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.customFields) ? structure.customFields : []
}
const getStructurePieces = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.pieces) ? structure.pieces : []
}
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
if (Array.isArray(structure?.subcomponents)) {
return structure.subcomponents
}
const legacy = (structure as any)?.subComponents
return Array.isArray(legacy) ? legacy : []
}
const getExistingComponents = (type: ComponentCatalogType) => {
return Array.isArray(type?.components) ? type.components : []
}
const resolvePieceLabel = (piece: Record<string, any>) => {
const parts: string[] = []
if (piece.role) {
parts.push(piece.role)
}
if (piece.typePiece?.name) {
parts.push(piece.typePiece.name)
} else if (piece.typePieceLabel) {
parts.push(piece.typePieceLabel)
} else if (piece.familyCode) {
parts.push(piece.familyCode)
} else if (piece.typePieceId) {
parts.push(`#${piece.typePieceId}`)
}
return parts.length ? parts.join(' • ') : 'Pièce'
}
const resolveSubcomponentLabel = (node: Record<string, any>) => {
const parts: string[] = []
if (node.alias) {
parts.push(node.alias)
}
if (node.typeComposant?.name) {
parts.push(node.typeComposant.name)
} else if (node.typeComposantLabel) {
parts.push(node.typeComposantLabel)
} else if (node.familyCode) {
parts.push(node.familyCode)
} else if (node.typeComposantId) {
parts.push(`#${node.typeComposantId}`)
}
const childCount = Array.isArray(node.subcomponents)
? node.subcomponents.length
: Array.isArray(node.subComponents)
? node.subComponents.length
: 0
if (childCount) {
parts.push(`${childCount} sous-composant(s)`)
}
return parts.length ? parts.join(' • ') : 'Sous-composant'
}
const resolveRequirementLabel = (requirement: any) => {
return requirement?.label || requirement?.typeComposant?.name || 'Requirement'
}
const clearCreationForm = () => {
creationForm.machineId = ''
creationForm.requirementId = ''
creationForm.name = ''
creationForm.reference = ''
creationForm.constructeurId = null
creationForm.prix = ''
lastSuggestedName.value = ''
}
const resetCreationFormForType = () => {
clearCreationForm()
creationForm.name = selectedType.value?.name || ''
lastSuggestedName.value = creationForm.name
}
const openCreationModal = async (type: ComponentCatalogType) => {
selectedType.value = type
resetCreationFormForType()
creationModalOpen.value = true
if (!machines.value?.length) {
await loadMachines()
}
}
const closeCreationModal = () => {
creationModalOpen.value = false
selectedType.value = null
clearCreationForm()
}
const ensureMachineRequirements = async (machineId: string) => {
if (!machineId || machineRequirementCache[machineId]) {
return
}
const requestId = ++requirementRequestToken
requirementLoading.value = true
try {
const result = await apiCall(`/machines/${machineId}`, { method: 'GET' })
if (result.success) {
const requirements = result.data?.typeMachine?.componentRequirements || []
machineRequirementCache[machineId] = { requirements }
}
} finally {
if (requestId === requirementRequestToken) {
requirementLoading.value = false
}
}
}
watch(
() => creationForm.machineId,
async (machineId) => {
creationForm.requirementId = ''
if (!machineId) {
return
}
await ensureMachineRequirements(machineId)
},
)
watch(
() => creationForm.requirementId,
(requirementId) => {
if (!selectedType.value) {
return
}
const requirement = requirementId ? selectedRequirement.value : null
const suggestion =
requirement?.label || requirement?.typeComposant?.name || selectedType.value?.name || ''
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
creationForm.name = suggestion
}
lastSuggestedName.value = suggestion
},
)
const submitCreation = async () => {
if (!creationForm.machineId || !creationForm.requirementId) {
toast.showError('Sélectionnez une machine et un requirement avant de continuer.')
return
}
if (!selectedType.value) {
toast.showError('Aucune catégorie sélectionnée.')
return
}
const payload: Record<string, any> = {
machineId: creationForm.machineId,
typeMachineComponentRequirementId: creationForm.requirementId,
}
const requirement = selectedRequirement.value
if (selectedType.value.id) {
const requirementTypeId = requirement?.typeComposantId || null
if (!requirementTypeId || requirementTypeId !== selectedType.value.id) {
payload.typeComposantId = selectedType.value.id
}
}
const overrides = sanitizeDefinitionOverrides({
name: creationForm.name,
reference: creationForm.reference,
constructeurId: creationForm.constructeurId,
prix: creationForm.prix,
})
if (overrides) {
payload.definition = overrides
}
submitting.value = true
try {
const result = await createComposant(payload)
if (result.success) {
await loadComponentTypes()
closeCreationModal()
} else if (result.error) {
toast.showError(result.error)
}
} catch (error: any) {
toast.showError(error?.message || "Erreur lors de la création du composant")
} finally {
submitting.value = false
}
}
onMounted(async () => {
await Promise.allSettled([
loadComponentTypes(),
loadMachines(),
])
})
</script>

View File

@@ -783,6 +783,7 @@ import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { getFileIcon } from '~/utils/fileIcons'
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
import { canPreviewDocument } from '~/utils/documentPreview'
import ComponentHierarchy from '~/components/ComponentHierarchy.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
@@ -976,41 +977,6 @@ const createPieceSelectionEntry = (requirement, source = null) => ({
},
})
const sanitizeDefinitionOverrides = (definition) => {
if (!definition || typeof definition !== 'object') {
return null
}
const payload = {}
if (typeof definition.name === 'string') {
const name = definition.name.trim()
if (name.length > 0) {
payload.name = name
}
}
if (typeof definition.reference === 'string') {
const reference = definition.reference.trim()
if (reference.length > 0) {
payload.reference = reference
}
}
if (definition.constructeurId !== undefined && definition.constructeurId !== null && definition.constructeurId !== '') {
payload.constructeurId = definition.constructeurId
}
if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') {
const parsed = Number(definition.prix)
if (!Number.isNaN(parsed)) {
payload.prix = parsed
}
}
return Object.keys(payload).length ? payload : null
}
const resetSkeletonRequirementSelections = () => {
Object.keys(componentRequirementSelections).forEach((key) => {
delete componentRequirementSelections[key]

View File

@@ -560,6 +560,7 @@ import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useToast } from '~/composables/useToast'
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
import IconLucideEye from '~icons/lucide/eye'
@@ -652,41 +653,6 @@ const createPieceSelectionEntry = (requirement, source = null) => ({
},
})
const sanitizeDefinitionOverrides = (definition) => {
if (!definition || typeof definition !== 'object') {
return null
}
const payload = {}
if (typeof definition.name === 'string') {
const name = definition.name.trim()
if (name.length > 0) {
payload.name = name
}
}
if (typeof definition.reference === 'string') {
const reference = definition.reference.trim()
if (reference.length > 0) {
payload.reference = reference
}
}
if (definition.constructeurId !== undefined && definition.constructeurId !== null && definition.constructeurId !== '') {
payload.constructeurId = definition.constructeurId
}
if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') {
const parsed = Number(definition.prix)
if (!Number.isNaN(parsed)) {
payload.prix = parsed
}
}
return Object.keys(payload).length ? payload : null
}
const clearRequirementSelections = () => {
Object.keys(componentRequirementSelections).forEach((key) => {
delete componentRequirementSelections[key]

View File

@@ -387,6 +387,48 @@ export const formatStructurePreview = (structure: any) => {
return segments.join(' • ')
}
export interface DefinitionOverridePayload {
name?: string
reference?: string
constructeurId?: string | null
prix?: number
}
export const sanitizeDefinitionOverrides = (definition: any): DefinitionOverridePayload | null => {
if (!definition || typeof definition !== 'object') {
return null
}
const payload: DefinitionOverridePayload = {}
if (typeof definition.name === 'string') {
const name = definition.name.trim()
if (name.length > 0) {
payload.name = name
}
}
if (typeof definition.reference === 'string') {
const reference = definition.reference.trim()
if (reference.length > 0) {
payload.reference = reference
}
}
if (definition.constructeurId !== undefined && definition.constructeurId !== null && definition.constructeurId !== '') {
payload.constructeurId = definition.constructeurId
}
if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') {
const parsed = Number(definition.prix)
if (!Number.isNaN(parsed)) {
payload.prix = parsed
}
}
return Object.keys(payload).length ? payload : null
}
export const defaultPieceStructure = (): PieceModelStructure => ({
...createEmptyPieceModelStructure(),
})