chore: retire legacy model catalogs

This commit is contained in:
MatthieuTD
2025-10-02 16:29:50 +02:00
parent 386a1c9d1b
commit c5f2c568b6
19 changed files with 1234 additions and 3597 deletions

View File

@@ -75,7 +75,7 @@
@keydown.enter.prevent="toggleDropdown('pieces-mobile')"
@keydown.space.prevent="toggleDropdown('pieces-mobile')"
:class="
isActive('/pieces-catalog') || isActive('/piece-category')
isActive('/piece-category')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@@ -99,19 +99,6 @@
Catégorie de pièce
</NuxtLink>
</li>
<li>
<NuxtLink
to="/pieces-catalog"
class="rounded-md px-2 py-1 transition-colors"
:class="
isActive('/pieces-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue de pièce
</NuxtLink>
</li>
</ul>
</li>
<li
@@ -130,7 +117,6 @@
@keydown.enter.prevent="toggleDropdown('component-mobile')"
@keydown.space.prevent="toggleDropdown('component-mobile')"
:class="
isActive('/component-catalog') ||
isActive('/component-category')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
@@ -155,19 +141,6 @@
Catégorie de composant
</NuxtLink>
</li>
<li>
<NuxtLink
to="/component-catalog"
class="rounded-md px-2 py-1 transition-colors"
:class="
isActive('/component-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue de composant
</NuxtLink>
</li>
</ul>
</li>
<li
@@ -312,13 +285,13 @@
@keydown.enter.prevent="toggleDropdown('pieces-desktop')"
@keydown.space.prevent="toggleDropdown('pieces-desktop')"
:class="
isActive('/pieces-catalog') || isActive('/piece-category')
isActive('/piece-category')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Pièces
</div>
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Pièces
</div>
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-60"
@@ -336,19 +309,6 @@
Catégorie de pièce
</NuxtLink>
</li>
<li>
<NuxtLink
to="/pieces-catalog"
class="rounded-md px-2 py-1 transition-colors"
:class="
isActive('/pieces-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue de pièce
</NuxtLink>
</li>
</ul>
</li>
<li
@@ -367,14 +327,13 @@
@keydown.enter.prevent="toggleDropdown('component-desktop')"
@keydown.space.prevent="toggleDropdown('component-desktop')"
:class="
isActive('/component-category') ||
isActive('/component-catalog')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Composant
</div>
isActive('/component-category')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Composant
</div>
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-64"
@@ -392,19 +351,6 @@
Catégorie de composant
</NuxtLink>
</li>
<li>
<NuxtLink
to="/component-catalog"
class="rounded-md px-2 py-1 transition-colors"
:class="
isActive('/component-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue de composant
</NuxtLink>
</li>
</ul>
</li>
<li

View File

@@ -7,15 +7,9 @@
:is-edit-mode="isEditMode"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
:component-model-options="componentModelOptionsProvider(component)"
:component-model-options-provider="componentModelOptionsProvider"
:piece-model-options-provider="pieceModelOptionsProvider"
@update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)"
@assign-model="$emit('assign-model', $event)"
@assign-piece-model="$emit('assign-piece-model', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@create-model-from-component="$emit('create-model-from-component', $event)"
/>
</div>
</div>
@@ -40,16 +34,8 @@ defineProps({
toggleToken: {
type: Number,
default: 0
},
componentModelOptionsProvider: {
type: Function,
default: () => []
},
pieceModelOptionsProvider: {
type: Function,
default: () => []
}
})
defineEmits(['update', 'edit-piece', 'assign-model', 'assign-piece-model', 'custom-field-update', 'create-model-from-component'])
defineEmits(['update', 'edit-piece', 'custom-field-update'])
</script>

View File

@@ -34,12 +34,6 @@
>
Groupe : {{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Non défini' }}
</span>
<span
v-if="component.composantModel"
class="badge badge-outline badge-sm badge-primary"
>
Modèle : {{ component.composantModel.name }}
</span>
</div>
</div>
</div>
@@ -108,45 +102,6 @@
</div>
</div>
<div
v-if="isEditMode && component.typeMachineComponentRequirement"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Modèle de composant</span>
<span class="label-text-alt text-xs">
{{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Famille' }}
</span>
</label>
<div class="flex flex-col md:flex-row gap-2 items-start md:items-center">
<select
:value="selectedComponentModelId"
class="select select-bordered select-sm"
@change="assignComponentModel($event.target.value)"
>
<option value="">
Définir manuellement
</option>
<option
v-for="model in componentModelOptionsList"
:key="model.id"
:value="model.id"
>
{{ model.name }}
</option>
</select>
<button
v-if="isEditMode && component.typeMachineComponentRequirement?.typeComposantId"
type="button"
class="btn btn-ghost btn-xs"
@click="emit('create-model-from-component', component)"
>
Sauvegarder comme modèle
</button>
</div>
</div>
</div>
</div>
<!-- Custom Fields Display - Editable or Read-only -->
@@ -314,7 +269,6 @@
@update="updatePiece"
@edit="editPiece"
@custom-field-update="updatePieceCustomField"
@assign-model="emitAssignPieceModel"
/>
</div>
</div>
@@ -332,13 +286,9 @@
:is-edit-mode="isEditMode"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
:component-model-options="componentModelOptionsProvider(subComponent)"
:component-model-options-provider="componentModelOptionsProvider"
:piece-model-options-provider="pieceModelOptionsProvider"
@update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)"
@assign-model="$emit('assign-model', $event)"
@assign-piece-model="$emit('assign-piece-model', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
/>
</div>
</div>
@@ -375,28 +325,13 @@ const props = defineProps({
toggleToken: {
type: Number,
default: 0
},
componentModelOptions: {
type: Array,
default: () => []
},
componentModelOptionsProvider: {
type: Function,
default: () => []
},
pieceModelOptionsProvider: {
type: Function,
default: () => []
}
})
const emit = defineEmits([
'update',
'edit-piece',
'custom-field-update',
'assign-model',
'assign-piece-model',
'create-model-from-component'
'custom-field-update'
])
const isCollapsed = ref(true)
@@ -409,16 +344,19 @@ const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime:
const previewDocument = ref(null)
const previewVisible = ref(false)
const selectedComponentModelId = computed(() => props.component.composantModelId || props.component.composantModel?.id || '')
const componentModelOptionsList = computed(() => {
const provided = props.componentModelOptionsProvider(props.component)
return Array.isArray(provided) && provided.length ? provided : props.componentModelOptions
})
const pieceModelOptionsList = computed(() => props.pieceModelOptionsProvider(props.component) || [])
const childComponents = computed(() => {
const list = props.component.subcomponents || props.component.subComponents || []
return Array.isArray(list) ? list : []
})
const extractStructureCustomFields = (structure) => {
if (!structure || typeof structure !== 'object') {
return []
}
const customFields = structure.customFields
return Array.isArray(customFields) ? customFields : []
}
function fieldKeyFromNameAndType(name, type) {
const normalizedName = typeof name === 'string' ? name : ''
const normalizedType = typeof type === 'string' ? type : ''
@@ -520,24 +458,49 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
return merged
}
const componentDefinitionSources = computed(() => {
const requirement = props.component.typeMachineComponentRequirement || {}
const type = requirement.typeComposant || props.component.typeComposant || {}
const definitions = []
const pushFields = (collection) => {
if (Array.isArray(collection)) {
definitions.push(...collection)
}
}
pushFields(props.component.customFields)
pushFields(props.component.definition?.customFields)
pushFields(type.customFields)
pushFields(requirement.customFields)
pushFields(requirement.definition?.customFields)
;[
props.component.definition?.structure,
type.structure,
type.componentSkeleton,
requirement.structure,
requirement.componentSkeleton,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure)
if (fields.length) {
definitions.push(...fields)
}
})
return definitions
})
const displayedCustomFields = computed(() =>
mergeFieldDefinitionsWithValues(
props.component.customFields,
componentDefinitionSources.value,
props.component.customFieldValues,
),
)
const candidateCustomFields = computed(() => {
const sources = [
props.component.customFieldValues?.map((value) => value?.customField),
props.component.typeComposant?.customFields,
props.component.typeMachineComponentRequirement?.typeComposant?.customFields,
props.component.composantModel?.customFields,
props.component.typeMachineComponentRequirement?.customFields,
props.component.customFields,
]
const candidateCustomFields = computed(() => {
const map = new Map()
sources.forEach((collection) => {
const register = (collection) => {
if (!Array.isArray(collection)) {
return
}
@@ -553,7 +516,10 @@ const candidateCustomFields = computed(() => {
}
map.set(key, item)
})
})
}
register(props.component.customFieldValues?.map((value) => value?.customField))
register(componentDefinitionSources.value)
return Array.from(map.values())
})
@@ -838,25 +804,6 @@ const updatePieceCustomField = (fieldUpdate) => {
emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' })
}
const assignComponentModel = (value) => {
const previousModelId = props.component.composantModelId || props.component.composantModel?.id || null
const previousModel = props.component.composantModel || null
props.component.composantModelId = value || null
if (!value) {
props.component.composantModel = null
}
emit('assign-model', {
componentId: props.component.id,
composantModelId: value || null,
previousModelId,
previousModel
})
}
const emitAssignPieceModel = (payload) => {
emit('assign-piece-model', payload)
}
const ensureDocumentsLoaded = async () => {
if (documentsLoaded.value || !props.component?.id) { return }
await refreshDocuments()

View File

@@ -36,12 +36,6 @@
"Non défini"
}}
</span>
<span
v-if="piece.pieceModel"
class="badge badge-outline badge-primary badge-sm"
>
Modèle : {{ piece.pieceModel.name }}
</span>
<span
v-if="piece.parentComponentName"
class="badge badge-ghost badge-sm"
@@ -104,33 +98,6 @@
</div>
</div>
<div v-if="isEditMode && piece.typeMachinePieceRequirement" class="mt-3">
<label class="label">
<span class="label-text text-sm font-medium">Modèle de pièce</span>
<span class="label-text-alt text-xs">
{{
piece.typeMachinePieceRequirement.label ||
piece.typeMachinePieceRequirement.typePiece?.name ||
"Groupe"
}}
</span>
</label>
<select
:value="selectedPieceModelId"
class="select select-bordered select-sm w-full"
@change="assignPieceModel($event.target.value)"
>
<option value="">Définir manuellement</option>
<option
v-for="model in pieceModelOptions"
:key="model.id"
:value="model.id"
>
{{ model.name }}
</option>
</select>
</div>
<!-- Champs personnalisés de la pièce -->
<div
v-if="displayedCustomFields.length"
@@ -381,17 +348,12 @@ const props = defineProps({
type: Boolean,
default: false,
},
pieceModelOptions: {
type: Array,
default: () => [],
},
});
const emit = defineEmits([
"update",
"edit",
"custom-field-update",
"assign-model",
]);
// Données locales isolées pour cette pièce
@@ -412,10 +374,13 @@ const documentIcon = (doc) =>
getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType });
const previewDocument = ref(null);
const previewVisible = ref(false);
const selectedPieceModelId = computed(
() => props.piece.pieceModelId || props.piece.pieceModel?.id || ""
);
const pieceModelOptions = computed(() => props.pieceModelOptions || []);
const extractStructureCustomFields = (structure) => {
if (!structure || typeof structure !== "object") {
return [];
}
const customFields = structure.customFields;
return Array.isArray(customFields) ? customFields : [];
};
function fieldKeyFromNameAndType(name, type) {
const normalizedName = typeof name === 'string' ? name : '';
const normalizedType = typeof type === 'string' ? type : '';
@@ -529,25 +494,53 @@ function mergeFieldDefinitionsWithValues(definitions, values) {
return merged;
}
const pieceDefinitionSources = computed(() => {
const requirement = props.piece.typeMachinePieceRequirement || {};
const type = requirement.typePiece || props.piece.typePiece || {};
const definitions = [];
const pushFields = (collection) => {
if (Array.isArray(collection)) {
definitions.push(...collection);
}
};
pushFields(props.piece.customFields);
pushFields(props.piece.definition?.customFields);
pushFields(props.piece.typePiece?.customFields);
pushFields(type.customFields);
pushFields(requirement.typePiece?.customFields);
pushFields(requirement.customFields);
pushFields(requirement.definition?.customFields);
[
props.piece.definition?.structure,
props.piece.typePiece?.structure,
type.structure,
type.pieceSkeleton,
props.piece.typePiece?.pieceSkeleton,
requirement.structure,
requirement.pieceSkeleton,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure);
if (fields.length) {
definitions.push(...fields);
}
});
return definitions;
});
const displayedCustomFields = computed(() =>
mergeFieldDefinitionsWithValues(
props.piece.customFields,
pieceDefinitionSources.value,
props.piece.customFieldValues,
),
);
const candidateCustomFields = computed(() => {
const sources = [
props.piece.customFieldValues?.map((value) => value?.customField),
props.piece.typePiece?.customFields,
props.piece.typeMachinePieceRequirement?.typePiece?.customFields,
props.piece.typeMachinePieceRequirement?.customFields,
props.piece.pieceModel?.customFields,
props.piece.customFields,
];
const map = new Map();
sources.forEach((collection) => {
const register = (collection) => {
if (!Array.isArray(collection)) {
return;
}
@@ -563,7 +556,10 @@ const candidateCustomFields = computed(() => {
}
map.set(key, item);
});
});
};
register(props.piece.customFieldValues?.map((value) => value?.customField));
register(pieceDefinitionSources.value);
return Array.from(map.values());
});
@@ -827,22 +823,6 @@ const updatePiece = () => {
});
};
const assignPieceModel = (value) => {
const previousModelId =
props.piece.pieceModelId || props.piece.pieceModel?.id || null;
const previousModel = props.piece.pieceModel || null;
props.piece.pieceModelId = value || null;
if (!value) {
props.piece.pieceModel = null;
}
emit("assign-model", {
pieceId: props.piece.id,
pieceModelId: value || null,
previousModelId,
previousModel,
});
};
const updateCustomFieldValue = async (fieldValueId, field) => {
if (!field || resolveFieldReadOnly(field)) {
return;

View File

@@ -1,199 +0,0 @@
<template>
<div :class="['space-y-4', depth > 0 ? 'border-l border-base-200 pl-4 ml-2' : '']">
<div class="bg-base-100 border border-base-200 rounded-lg p-4 space-y-4">
<div class="flex flex-col gap-2">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<h4 class="font-semibold text-sm">
{{ node.alias || node.typeComposantLabel || 'Composant' }}
</h4>
<p class="text-xs text-gray-500">
{{
node.typeComposantLabel
? `Famille : ${node.typeComposantLabel}`
: node.typeComposantId
? `Famille : ${node.typeComposantId}`
: 'Famille non définie'
}}
</p>
</div>
<div v-if="!showSelfSelector && selectedComponentModelLabel" class="badge badge-outline badge-sm">
Modèle : {{ selectedComponentModelLabel }}
</div>
</div>
<div v-if="showSelfSelector" class="form-control max-w-xs">
<label class="label">
<span class="label-text text-xs">Modèle de composant</span>
</label>
<select
class="select select-bordered select-xs"
:value="selectedComponentModelId"
@change="onComponentModelSelect($event.target.value)"
>
<option value="">Sélectionner un modèle</option>
<option v-for="model in componentModels" :key="model.id" :value="model.id">
{{ model.name }}
</option>
</select>
<p v-if="loadingComponentModels" class="text-[10px] text-gray-500 mt-1">
Chargement des modèles
</p>
<p v-else-if="!componentModels.length" class="text-[10px] text-gray-500 mt-1">
Aucun modèle disponible pour cette famille.
</p>
</div>
</div>
<div v-if="hasPieces" class="space-y-2">
<h5 class="text-[11px] font-semibold uppercase text-gray-500">Pièces associées</h5>
<div
v-for="(piece, pieceIndex) in node.pieces"
:key="pieceIndex"
class="bg-base-200/60 border border-base-200 rounded-md p-3 space-y-2"
>
<div class="space-y-1">
<span class="font-medium text-sm">{{ piece.typePieceLabel || 'Pièce' }}</span>
<p class="text-[11px] text-gray-500">
{{
piece.typePieceLabel
? `Famille : ${piece.typePieceLabel}`
: piece.typePieceId
? `Famille : ${piece.typePieceId}`
: 'Famille non définie'
}}
</p>
</div>
<div class="form-control max-w-xs">
<label class="label">
<span class="label-text text-xs">Modèle de pièce</span>
</label>
<select
class="select select-bordered select-xs"
:value="piece.__pieceModelId || ''"
@change="onPieceModelSelect(piece, $event.target.value)"
>
<option value="">Sélectionner un modèle</option>
<option v-for="model in getPieceModels(piece.typePieceId)" :key="model.id" :value="model.id">
{{ model.name }}
</option>
</select>
<p v-if="loadingPieceModels" class="text-[10px] text-gray-500 mt-1">
Chargement des modèles
</p>
<p
v-else-if="getPieceModels(piece.typePieceId).length === 0"
class="text-[10px] text-gray-500 mt-1"
>
Aucun modèle disponible pour cette famille.
</p>
</div>
</div>
</div>
<div v-if="hasSubComponents" class="space-y-3">
<h5 class="text-[11px] font-semibold uppercase text-gray-500">Sous-composants</h5>
<SkeletonComponentNodeSelector
v-for="(subComponent, index) in (node.subcomponents || node.subComponents || [])"
:key="index"
:node="subComponent"
:depth="depth + 1"
:get-component-models-for-type="getComponentModelsForType"
:get-piece-models-for-type="getPieceModelsForType"
:loading-component-models="loadingComponentModels"
:loading-piece-models="loadingPieceModels"
@component-model-change="forwardComponentModelChange"
@piece-model-change="forwardPieceModelChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
defineOptions({ name: 'SkeletonComponentNodeSelector' })
const props = defineProps({
node: {
type: Object,
required: true,
},
depth: {
type: Number,
default: 0,
},
getComponentModelsForType: {
type: Function,
required: true,
},
getPieceModelsForType: {
type: Function,
required: true,
},
loadingComponentModels: {
type: Boolean,
default: false,
},
loadingPieceModels: {
type: Boolean,
default: false,
},
showSelfSelector: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['component-model-change', 'piece-model-change'])
const componentModels = computed(() => {
if (!props.node?.typeComposantId) {
return []
}
const models = props.getComponentModelsForType(props.node.typeComposantId)
return Array.isArray(models) ? models : []
})
const selectedComponentModelId = computed(() => props.node?.__componentModelId || '')
const selectedComponentModelLabel = computed(() => {
if (!selectedComponentModelId.value) {
return ''
}
const match = componentModels.value.find((model) => model.id === selectedComponentModelId.value)
return match?.name || ''
})
const hasPieces = computed(() => Array.isArray(props.node?.pieces) && props.node.pieces.length > 0)
const hasSubComponents = computed(() => {
const list = props.node?.subcomponents || props.node?.subComponents || []
return Array.isArray(list) && list.length > 0
})
const getPieceModels = (typePieceId) => {
if (!typePieceId) {
return []
}
const models = props.getPieceModelsForType(typePieceId)
return Array.isArray(models) ? models : []
}
const onComponentModelSelect = (value) => {
emit('component-model-change', props.node, props.node?.typeComposantId || '', value || '')
}
const onPieceModelSelect = (piece, value) => {
emit('piece-model-change', piece, piece?.typePieceId || '', value || '')
}
const forwardComponentModelChange = (...args) => {
emit('component-model-change', ...args)
}
const forwardPieceModelChange = (...args) => {
emit('piece-model-change', ...args)
}
</script>

View File

@@ -9,7 +9,7 @@
Les champs marqués d'un astérisque sont obligatoires.
</p>
<form class="mt-6 space-y-5" @submit.prevent="handleSubmit">
<form class="mt-6 space-y-6" @submit.prevent="handleSubmit">
<div>
<label class="label" for="model-type-name">
<span class="label-text">Nom *</span>
@@ -79,10 +79,59 @@
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
</div>
<div class="divider"></div>
<section class="space-y-4">
<header>
<h3 class="text-lg font-semibold text-base-content">
Structure du squelette
</h3>
<p class="mt-1 text-sm text-base-content/70">
Définissez la structure canonique appliquée lors de la création des composants ou pièces de cette catégorie.
</p>
</header>
<div
v-if="structureLoading"
class="flex items-center justify-center rounded-lg border border-dashed border-base-300 py-12"
>
<span class="loading loading-spinner loading-lg" aria-hidden="true"></span>
<span class="ml-3 text-sm text-base-content/70">Chargement du squelette…</span>
</div>
<template v-else>
<div
v-if="form.category === 'COMPONENT'"
class="space-y-3 rounded-lg border border-base-300 p-4"
>
<p class="text-sm text-base-content/70">
Aperçu :
<span class="font-medium text-base-content">{{ componentStructurePreview }}</span>
</p>
<ComponentModelStructureEditor v-model="componentStructure" />
</div>
<div
v-else
class="space-y-3 rounded-lg border border-base-300 p-4"
>
<p class="text-sm text-base-content/70">
Aperçu :
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
</p>
<PieceModelStructureEditor v-model="pieceStructure" />
</div>
</template>
</section>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="close">Annuler</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
<button type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
<span
v-if="saving"
class="loading loading-spinner loading-sm"
aria-hidden="true"
></span>
{{ submitLabel }}
</button>
</div>
@@ -95,6 +144,18 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch, onBeforeUnmount } from 'vue';
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue';
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue';
import {
clonePieceStructure,
cloneStructure,
defaultPieceStructure,
defaultStructure,
formatPieceStructurePreview,
formatStructurePreview,
normalizePieceStructureForSave,
normalizeStructureForSave,
} from '~/shared/modelUtils';
import type { ModelCategory, ModelTypePayload } from '~/services/modelTypes';
const props = defineProps<{
@@ -104,6 +165,7 @@ const props = defineProps<{
initialData?: Partial<ModelTypePayload> | null;
saving?: boolean;
lockCategory?: boolean;
structureLoading?: boolean;
}>();
const emit = defineEmits<{
@@ -112,12 +174,14 @@ const emit = defineEmits<{
}>();
const lockCategory = computed(() => props.lockCategory ?? false);
const structureLoading = computed(() => props.structureLoading ?? false);
const form = reactive<ModelTypePayload>({
name: '',
code: '',
category: 'COMPONENT',
notes: '',
structure: undefined,
});
const errors = reactive<{ name?: string; code?: string }>({});
@@ -126,6 +190,9 @@ const nameInput = ref<HTMLInputElement | null>(null);
const codePattern = /^[a-z0-9\-_.]+$/i;
const componentStructure = ref(normalizeStructureForSave(defaultStructure()));
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()));
const resetForm = () => {
form.name = props.initialData?.name ?? '';
form.code = props.initialData?.code ?? '';
@@ -133,6 +200,21 @@ const resetForm = () => {
form.notes = props.initialData?.notes ?? '';
errors.name = undefined;
errors.code = undefined;
const incomingStructure = props.initialData?.structure;
if (form.category === 'COMPONENT') {
componentStructure.value = normalizeStructureForSave(
incomingStructure && props.initialData?.category === 'COMPONENT'
? incomingStructure
: defaultStructure(),
);
} else {
pieceStructure.value = normalizePieceStructureForSave(
incomingStructure && props.initialData?.category === 'PIECE'
? incomingStructure
: defaultPieceStructure(),
);
}
};
const close = () => {
@@ -146,6 +228,7 @@ const modalTitle = computed(() =>
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'));
const saving = computed(() => props.saving ?? false);
const isSubmitDisabled = computed(() => saving.value || structureLoading.value);
const onEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -179,11 +262,25 @@ const handleSubmit = () => {
return;
}
emit('save', {
const common = {
name: form.name.trim(),
code: form.code.trim(),
category: form.category,
notes: form.notes?.trim() ? form.notes.trim() : undefined,
};
if (form.category === 'COMPONENT') {
emit('save', {
...common,
category: 'COMPONENT',
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
});
return;
}
emit('save', {
...common,
category: 'PIECE',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
});
};
@@ -200,6 +297,53 @@ watch(
},
);
watch(
() => form.category,
(category, previous) => {
if (category === previous) {
return;
}
if (category === 'COMPONENT' && previous !== 'COMPONENT') {
componentStructure.value = normalizeStructureForSave(defaultStructure());
}
if (category === 'PIECE' && previous !== 'PIECE') {
pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure());
}
},
);
watch(
() => props.initialData?.structure,
(value) => {
if (!props.visible) {
return;
}
if (form.category === 'COMPONENT') {
componentStructure.value = normalizeStructureForSave(
props.initialData?.category === 'COMPONENT' && value
? value
: componentStructure.value,
);
} else if (form.category === 'PIECE') {
pieceStructure.value = normalizePieceStructureForSave(
props.initialData?.category === 'PIECE' && value
? value
: pieceStructure.value,
);
}
},
);
const componentStructurePreview = computed(() =>
formatStructurePreview(componentStructure.value),
);
const pieceStructurePreview = computed(() =>
formatPieceStructurePreview(pieceStructure.value),
);
watch(
() => props.initialData,
() => {

View File

@@ -41,6 +41,7 @@
:initial-data="modalInitialData"
:saving="saving"
:lock-category="!allowCategorySwitch"
:structure-loading="structureLoading"
@close="closeModal"
@save="handleSave"
/>
@@ -56,6 +57,7 @@ import EditModal from "~/components/model-types/EditModal.vue";
import {
createModelType,
deleteModelType,
getModelType,
listModelTypes,
updateModelType,
type ModelCategory,
@@ -97,6 +99,7 @@ const isModalOpen = ref(false);
const modalMode = ref<"create" | "edit">("create");
const modalInitialData = ref<Partial<ModelTypePayload> | null>(null);
const editingId = ref<string | null>(null);
const structureLoading = ref(false);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let activeController: AbortController | null = null;
@@ -234,10 +237,11 @@ const openCreateModal = () => {
modalMode.value = "create";
modalInitialData.value = null;
editingId.value = null;
structureLoading.value = false;
isModalOpen.value = true;
};
const openEditModal = (item: ModelType) => {
const openEditModal = async (item: ModelType) => {
modalMode.value = "edit";
editingId.value = item.id;
modalInitialData.value = {
@@ -245,8 +249,26 @@ const openEditModal = (item: ModelType) => {
code: item.code,
category: item.category,
notes: item.notes ?? item.description ?? "",
structure: item.structure,
};
isModalOpen.value = true;
structureLoading.value = true;
try {
const response = await getModelType(item.id);
modalInitialData.value = {
name: response.name,
code: response.code,
category: response.category,
notes: response.notes ?? response.description ?? "",
structure: response.structure,
};
} catch (error) {
showError(extractErrorMessage(error));
} finally {
structureLoading.value = false;
}
};
const closeModal = () => {

View File

@@ -1,131 +0,0 @@
import { ref, computed } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
const componentModelsBuckets = ref({})
const loadingComponentModels = ref(false)
export function useComponentModels () {
const { get, post, patch, delete: del } = useApi()
const { showSuccess, showError } = useToast()
const loadComponentModels = async (typeComposantId) => {
loadingComponentModels.value = true
try {
const query = typeComposantId ? `?typeComposantId=${encodeURIComponent(typeComposantId)}` : ''
const result = await get(`/types/composants/models${query}`)
if (result.success) {
const key = typeComposantId || '__all__'
componentModelsBuckets.value = {
...componentModelsBuckets.value,
[key]: result.data
}
}
return result
} catch (error) {
showError(`Impossible de charger les modèles de composant: ${error.message}`)
return { success: false, error: error.message }
} finally {
loadingComponentModels.value = false
}
}
const createComponentModel = async (payload) => {
loadingComponentModels.value = true
try {
const result = await post('/types/composants/models', payload)
if (result.success) {
const key = result.data?.typeComposantId || '__all__'
const bucket = componentModelsBuckets.value[key] || []
componentModelsBuckets.value = {
...componentModelsBuckets.value,
[key]: [...bucket, result.data]
}
showSuccess(`Modèle de composant "${result.data.name}" créé`)
}
return result
} catch (error) {
showError(`Erreur lors de la création du modèle de composant: ${error.message}`)
return { success: false, error: error.message }
} finally {
loadingComponentModels.value = false
}
}
const updateComponentModel = async (id, payload) => {
loadingComponentModels.value = true
try {
const result = await patch(`/types/composants/models/${id}`, payload)
if (result.success) {
const key = result.data?.typeComposantId || '__all__'
const bucket = componentModelsBuckets.value[key] || []
const updatedBucket = bucket.map(model =>
model.id === id ? result.data : model
)
componentModelsBuckets.value = {
...componentModelsBuckets.value,
[key]: updatedBucket
}
showSuccess(`Modèle de composant "${result.data.name}" mis à jour`)
}
return result
} catch (error) {
showError(`Erreur lors de la mise à jour du modèle de composant: ${error.message}`)
return { success: false, error: error.message }
} finally {
loadingComponentModels.value = false
}
}
const deleteComponentModel = async (id) => {
loadingComponentModels.value = true
try {
const result = await del(`/types/composants/models/${id}`)
if (result.success) {
const updatedBuckets = {}
for (const [key, bucket] of Object.entries(componentModelsBuckets.value)) {
updatedBuckets[key] = bucket.filter(model => model.id !== id)
}
componentModelsBuckets.value = updatedBuckets
showSuccess('Modèle de composant supprimé')
}
return result
} catch (error) {
showError(`Erreur lors de la suppression du modèle de composant: ${error.message}`)
return { success: false, error: error.message }
} finally {
loadingComponentModels.value = false
}
}
const allComponentModels = computed(() => {
return Object.values(componentModelsBuckets.value).reduce((acc, bucket) => {
bucket.forEach((model) => {
if (!acc.find(existing => existing.id === model.id)) {
acc.push(model)
}
})
return acc
}, [])
})
const getComponentModelsForType = (typeComposantId) => {
return componentModelsBuckets.value[typeComposantId] || []
}
const getComponentModels = () => allComponentModels.value
const isComponentModelLoading = () => loadingComponentModels.value
return {
componentModels: allComponentModels,
componentModelsBuckets,
loadingComponentModels,
loadComponentModels,
createComponentModel,
updateComponentModel,
deleteComponentModel,
getComponentModels,
getComponentModelsForType,
isComponentModelLoading
}
}

View File

@@ -51,7 +51,8 @@ export function useComponentTypes () {
code: payload.code || generateCodeFromName(payload.name),
category: 'COMPONENT',
notes: payload.description ?? payload.notes,
description: payload.description ?? null
description: payload.description ?? null,
structure: payload.structure
})
const normalized = {
@@ -79,7 +80,8 @@ export function useComponentTypes () {
name: payload.name,
description: payload.description,
notes: payload.notes,
code: payload.code
code: payload.code,
structure: payload.structure
})
const normalized = {

View File

@@ -1,131 +0,0 @@
import { ref, computed } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
const pieceModelsBuckets = ref({})
const loadingPieceModels = ref(false)
export function usePieceModels () {
const { get, post, patch, delete: del } = useApi()
const { showSuccess, showError } = useToast()
const loadPieceModels = async (typePieceId) => {
loadingPieceModels.value = true
try {
const query = typePieceId ? `?typePieceId=${encodeURIComponent(typePieceId)}` : ''
const result = await get(`/types/pieces/models${query}`)
if (result.success) {
const key = typePieceId || '__all__'
pieceModelsBuckets.value = {
...pieceModelsBuckets.value,
[key]: result.data
}
}
return result
} catch (error) {
showError(`Impossible de charger les modèles de pièce: ${error.message}`)
return { success: false, error: error.message }
} finally {
loadingPieceModels.value = false
}
}
const createPieceModel = async (payload) => {
loadingPieceModels.value = true
try {
const result = await post('/types/pieces/models', payload)
if (result.success) {
const key = result.data?.typePieceId || '__all__'
const bucket = pieceModelsBuckets.value[key] || []
pieceModelsBuckets.value = {
...pieceModelsBuckets.value,
[key]: [...bucket, result.data]
}
showSuccess(`Modèle de pièce "${result.data.name}" créé`)
}
return result
} catch (error) {
showError(`Erreur lors de la création du modèle de pièce: ${error.message}`)
return { success: false, error: error.message }
} finally {
loadingPieceModels.value = false
}
}
const updatePieceModel = async (id, payload) => {
loadingPieceModels.value = true
try {
const result = await patch(`/types/pieces/models/${id}`, payload)
if (result.success) {
const key = result.data?.typePieceId || '__all__'
const bucket = pieceModelsBuckets.value[key] || []
const updatedBucket = bucket.map(model =>
model.id === id ? result.data : model
)
pieceModelsBuckets.value = {
...pieceModelsBuckets.value,
[key]: updatedBucket
}
showSuccess(`Modèle de pièce "${result.data.name}" mis à jour`)
}
return result
} catch (error) {
showError(`Erreur lors de la mise à jour du modèle de pièce: ${error.message}`)
return { success: false, error: error.message }
} finally {
loadingPieceModels.value = false
}
}
const deletePieceModel = async (id) => {
loadingPieceModels.value = true
try {
const result = await del(`/types/pieces/models/${id}`)
if (result.success) {
const updatedBuckets = {}
for (const [key, bucket] of Object.entries(pieceModelsBuckets.value)) {
updatedBuckets[key] = bucket.filter(model => model.id !== id)
}
pieceModelsBuckets.value = updatedBuckets
showSuccess('Modèle de pièce supprimé')
}
return result
} catch (error) {
showError(`Erreur lors de la suppression du modèle de pièce: ${error.message}`)
return { success: false, error: error.message }
} finally {
loadingPieceModels.value = false
}
}
const allPieceModels = computed(() => {
return Object.values(pieceModelsBuckets.value).reduce((acc, bucket) => {
bucket.forEach((model) => {
if (!acc.find(existing => existing.id === model.id)) {
acc.push(model)
}
})
return acc
}, [])
})
const getPieceModelsForType = (typePieceId) => {
return pieceModelsBuckets.value[typePieceId] || []
}
const getPieceModels = () => allPieceModels.value
const isPieceModelLoading = () => loadingPieceModels.value
return {
pieceModels: allPieceModels,
pieceModelsBuckets,
loadingPieceModels,
loadPieceModels,
createPieceModel,
updatePieceModel,
deletePieceModel,
getPieceModels,
getPieceModelsForType,
isPieceModelLoading
}
}

View File

@@ -51,7 +51,8 @@ export function usePieceTypes () {
code: payload.code || generateCodeFromName(payload.name),
category: 'PIECE',
notes: payload.description ?? payload.notes,
description: payload.description ?? null
description: payload.description ?? null,
structure: payload.structure
})
const normalized = {
@@ -79,7 +80,8 @@ export function usePieceTypes () {
name: payload.name,
description: payload.description,
notes: payload.notes,
code: payload.code
code: payload.code,
structure: payload.structure
})
const normalized = {

View File

@@ -1,398 +1,29 @@
<template>
<main class="container mx-auto px-6 py-8 space-y-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-bold text-gray-800">
Catalogue de composant
</h1>
<p class="text-sm text-gray-500">
Gérez les modèles disponibles pour chaque famille de composant.
</p>
</div>
<div class="tabs tabs-boxed">
<NuxtLink to="/component-catalog" class="tab tab-active">
Composants
<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>
<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.
</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="/pieces-catalog" class="tab">
Pièces
<NuxtLink to="/piece-category" class="btn btn-outline">
Gérer les catégories de pièces
</NuxtLink>
</div>
</header>
<div class="grid grid-cols-1 gap-8 xl:grid-cols-[2fr,1fr]">
<section class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:gap-4">
<label class="form-control w-full md:w-64">
<span class="label-text text-sm">Type de composant</span>
<select v-model="selectedType" class="select select-bordered select-sm">
<option value="all">Tous les types</option>
<option
v-for="type in componentTypes"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
</label>
<label class="form-control">
<span class="label-text text-sm">Filtrer</span>
<input
v-model="searchQuery"
type="search"
placeholder="Rechercher un modèle..."
class="input input-bordered input-sm"
>
</label>
<span class="text-xs text-gray-500">{{ filteredModels.length }} modèle(s)</span>
</div>
<button type="button" class="btn btn-primary btn-sm w-full md:w-auto" @click="startCreate">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Nouveau modèle
</button>
</div>
<div v-if="loadingComponentModels" class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg" />
</div>
<div v-else-if="filteredModels.length === 0" class="py-16 text-center text-sm text-gray-500">
Aucun modèle ne correspond à ce filtre.
</div>
<div v-else class="overflow-x-auto">
<table class="table">
<thead>
<tr class="text-sm text-gray-500">
<th>Nom</th>
<th class="hidden md:table-cell">
Description
</th>
<th>Type</th>
<th class="hidden xl:table-cell">
Structure
</th>
<th class="hidden lg:table-cell">
Modifié
</th>
<th class="text-right">
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="model in filteredModels"
:key="model.id"
:class="{
'bg-base-200/60': form.mode === 'edit' && form.data.id === model.id
}"
>
<td>
<div class="flex items-center gap-2">
<IconLucideLayers class="w-4 h-4 text-primary" aria-hidden="true" />
<span class="font-medium">{{ model.name }}</span>
</div>
</td>
<td class="hidden md:table-cell">
{{ model.description || '—' }}
</td>
<td>{{ model.typeComposant?.name || 'Non défini' }}</td>
<td class="hidden xl:table-cell text-xs text-gray-500">
{{ formatStructurePreview(model.structure) }}
</td>
<td class="hidden lg:table-cell text-xs text-gray-500">
{{ formatFrenchDate(model.updatedAt || model.createdAt) }}
</td>
<td class="text-right space-x-2">
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
Éditer
</button>
<button type="button" class="btn btn-sm btn-error" @click="confirmDelete(model)">
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<aside class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<h2 class="card-title text-lg">
{{ form.mode === 'edit' ? 'Modifier le modèle' : 'Nouveau modèle' }}
</h2>
<button
v-if="form.mode === 'edit'"
type="button"
class="btn btn-ghost btn-xs"
@click="startCreate"
>
Réinitialiser
</button>
</div>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="form-control">
<label class="label"><span class="label-text">Nom</span></label>
<input
v-model="form.data.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du modèle"
required
>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Description</span></label>
<textarea
v-model="form.data.description"
class="textarea textarea-bordered textarea-sm"
rows="3"
placeholder="Notes optionnelles"
/>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Type de composant</span></label>
<select
v-model="form.data.typeComposantId"
class="select select-bordered select-sm"
required
>
<option value="" disabled>
Sélectionner un type
</option>
<option
v-for="type in componentTypes"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
</div>
<div class="divider my-0">
Structure
</div>
<ComponentModelStructureEditor
v-model="form.data.structure"
:root-type-id="form.data.typeComposantId"
:root-type-label="selectedComponentTypeLabel"
:lock-root-type="!!form.data.typeComposantId"
/>
<div class="rounded-lg border border-base-200 bg-base-200/60 p-3 text-xs text-gray-500">
Aperçu : {{ formatStructurePreview(form.data.structure) }}
</div>
<div class="card-actions justify-end">
<button type="submit" class="btn btn-primary" :class="{ loading: form.submitting }">
{{ form.mode === 'edit' ? 'Enregistrer' : 'Créer' }}
</button>
</div>
</form>
</div>
</aside>
</div>
</section>
</main>
</template>
<script setup>
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { useComponentModels } from '~/composables/useComponentModels'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue'
import {
formatStructurePreview,
defaultStructure,
cloneStructure,
normalizeStructureForSave,
} from '~/shared/modelUtils'
import { componentModelStructureValidator } from '~/shared/types/inventory'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideLayers from '~icons/lucide/layers'
<script setup lang="ts">
import { onMounted } from 'vue'
const { componentTypes, loadComponentTypes } = useComponentTypes()
const {
componentModels,
loadComponentModels,
createComponentModel,
updateComponentModel,
deleteComponentModel,
loadingComponentModels,
getComponentModelsForType
} = useComponentModels()
const { showError, showSuccess } = useToast()
const selectedType = ref('all')
const searchQuery = ref('')
const form = reactive({
mode: 'create',
submitting: false,
data: {
id: null,
name: '',
description: '',
typeComposantId: '',
structure: defaultStructure(),
}
})
const ensureTypeSelected = () => {
if (form.data.typeComposantId && componentTypes.value.some(type => type.id === form.data.typeComposantId)) {
return
}
if (selectedType.value !== 'all' && componentTypes.value.some(type => type.id === selectedType.value)) {
form.data.typeComposantId = selectedType.value
return
}
form.data.typeComposantId = componentTypes.value[0]?.id || ''
}
const startCreate = () => {
form.mode = 'create'
form.data = {
id: null,
name: '',
description: '',
typeComposantId: selectedType.value !== 'all' ? selectedType.value : '',
structure: defaultStructure(),
}
ensureTypeSelected()
}
const startEdit = (model) => {
form.mode = 'edit'
form.data = {
id: model.id,
name: model.name,
description: model.description || '',
typeComposantId: model.typeComposantId || model.typeComposant?.id || '',
structure: cloneStructure(model.structure || defaultStructure()),
}
}
const filteredModels = computed(() => {
const list = selectedType.value === 'all'
? componentModels.value
: getComponentModelsForType(selectedType.value) || []
if (!searchQuery.value.trim()) {
return list
}
const term = searchQuery.value.toLowerCase()
return list.filter((model) => {
return (
model.name?.toLowerCase().includes(term) ||
model.description?.toLowerCase().includes(term) ||
model.typeComposant?.name?.toLowerCase().includes(term)
)
})
})
const selectedComponentType = computed(() => {
if (!form.data.typeComposantId) {
return null
}
return componentTypes.value.find((type) => type.id === form.data.typeComposantId) || null
})
const selectedComponentTypeLabel = computed(() => {
const type = selectedComponentType.value
if (!type) {
return ''
}
return type.code ? `${type.name} (${type.code})` : type.name
})
const refreshModels = async () => {
if (selectedType.value === 'all') {
await loadComponentModels()
} else {
await loadComponentModels(selectedType.value)
}
}
const handleSubmit = async () => {
if (!form.data.typeComposantId) {
showError('Veuillez sélectionner un type de composant')
return
}
form.submitting = true
try {
const normalizedStructure = normalizeStructureForSave(form.data.structure)
const validationResult = componentModelStructureValidator.safeParse(normalizedStructure)
if (!validationResult.success) {
showError(`Structure invalide: ${validationResult.issues.join(', ')}`)
return
}
if (form.mode === 'create') {
const result = await createComponentModel({
name: form.data.name.trim(),
description: form.data.description.trim() || undefined,
typeComposantId: form.data.typeComposantId,
structure: normalizedStructure,
})
if (!result.success) {
showError(result.error || 'Impossible de créer le modèle')
return
}
showSuccess(`Modèle "${result.data.name}" créé`)
} else if (form.data.id) {
const result = await updateComponentModel(form.data.id, {
name: form.data.name.trim(),
description: form.data.description.trim() || undefined,
typeComposantId: form.data.typeComposantId,
structure: normalizedStructure,
})
if (!result.success) {
showError(result.error || 'Impossible de mettre à jour le modèle')
return
}
showSuccess(`Modèle "${result.data.name}" mis à jour`)
}
await refreshModels()
startCreate()
} finally {
form.submitting = false
}
}
const confirmDelete = async (model) => {
const ok = confirm(`Supprimer le modèle "${model.name}" ?`)
if (!ok) { return }
const result = await deleteComponentModel(model.id)
if (!result.success) {
showError(result.error || 'Impossible de supprimer ce modèle')
return
}
if (form.mode === 'edit' && form.data.id === model.id) {
startCreate()
}
showSuccess('Modèle supprimé')
await refreshModels()
}
onMounted(async () => {
await Promise.all([loadComponentTypes(), loadComponentModels()])
ensureTypeSelected()
})
watch(selectedType, async () => {
await refreshModels()
if (form.mode === 'create') {
ensureTypeSelected()
}
onMounted(() => {
navigateTo('/component-category', { replace: true })
})
</script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,27 @@
<template>
<div class="min-h-[60vh] flex flex-col items-center justify-center gap-6 text-center">
<div class="space-y-2">
<h1 class="text-3xl font-semibold">
Gestion des catalogues
</h1>
<div class="min-h-[60vh] flex flex-col items-center justify-center gap-6 text-center px-6">
<div class="space-y-2 max-w-xl">
<h1 class="text-3xl font-semibold">Catalogue retiré</h1>
<p class="text-sm text-gray-500">
Administrez les modèles de composants et de pièces utilisés lors de la configuration des machines.
La gestion des modèles s'effectue désormais directement depuis les catégories de composants et de pièces.
Retrouvez l'intégralité des squelettes et champs personnalisés dans ces écrans dédiés.
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-3">
<NuxtLink to="/component-catalog" class="btn btn-primary">
Catalogue de composant
<NuxtLink to="/component-category" class="btn btn-primary">
Gestion des catégories de composants
</NuxtLink>
<NuxtLink to="/pieces-catalog" class="btn btn-outline">
Catalogue de pièce
<NuxtLink to="/piece-category" class="btn btn-outline">
Gestion des catégories de pièces
</NuxtLink>
</div>
</div>
</template>
<script setup>
const route = useRoute()
if (route.fullPath === '/models') {
navigateTo('/component-catalog', { replace: true })
}
<script setup lang="ts">
import { onMounted } from 'vue'
onMounted(() => {
navigateTo('/component-category', { replace: true })
})
</script>

View File

@@ -1,363 +1,29 @@
<template>
<main class="container mx-auto px-6 py-8 space-y-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-bold text-gray-800">
Catalogue de pièce
</h1>
<p class="text-sm text-gray-500">
Gérez les modèles disponibles pour chaque groupe de pièces.
</p>
</div>
<div class="tabs tabs-boxed">
<NuxtLink to="/component-catalog" class="tab">
Composants
<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 pièces a été archivé
</h1>
<p class="text-sm text-gray-500">
La configuration des pièces repose désormais sur les catégories enrichies de squelettes.
Utilisez la gestion des catégories pour définir les structures et champs personnalisés de référence.
</p>
<div class="flex flex-wrap items-center justify-center gap-3 pt-2">
<NuxtLink to="/piece-category" class="btn btn-primary">
Gérer les catégories de pièces
</NuxtLink>
<NuxtLink to="/pieces-catalog" class="tab tab-active">
Pièces
<NuxtLink to="/component-category" class="btn btn-outline">
Accéder aux catégories de composants
</NuxtLink>
</div>
</header>
<div class="grid grid-cols-1 gap-8 xl:grid-cols-[2fr,1fr]">
<section class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:gap-4">
<label class="form-control w-full md:w-64">
<span class="label-text text-sm">Type de pièce</span>
<select v-model="selectedType" class="select select-bordered select-sm">
<option value="all">Tous les types</option>
<option v-for="type in pieceTypes" :key="type.id" :value="type.id">
{{ type.name }}
</option>
</select>
</label>
<label class="form-control">
<span class="label-text text-sm">Filtrer</span>
<input
v-model="searchQuery"
type="search"
placeholder="Rechercher un modèle..."
class="input input-bordered input-sm"
>
</label>
<span class="text-xs text-gray-500">{{ filteredModels.length }} modèle(s)</span>
</div>
<button type="button" class="btn btn-primary btn-sm w-full md:w-auto" @click="startCreate">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Nouveau modèle
</button>
</div>
<div v-if="loadingPieceModels" class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg" />
</div>
<div v-else-if="filteredModels.length === 0" class="py-16 text-center text-sm text-gray-500">
Aucun modèle ne correspond à ce filtre.
</div>
<div v-else class="overflow-x-auto">
<table class="table">
<thead>
<tr class="text-sm text-gray-500">
<th>Nom</th>
<th class="hidden md:table-cell">
Description
</th>
<th>Type</th>
<th class="hidden lg:table-cell">
Modifié
</th>
<th class="text-right">
Actions
</th>
</tr>
</thead>
<tbody>
<tr
v-for="model in filteredModels"
:key="model.id"
:class="{
'bg-base-200/60': form.mode === 'edit' && form.data.id === model.id
}"
>
<td>
<div class="flex items-center gap-2">
<IconLucidePackage class="w-4 h-4 text-secondary" aria-hidden="true" />
<span class="font-medium">{{ model.name }}</span>
</div>
</td>
<td class="hidden md:table-cell">
{{ model.description || '—' }}
</td>
<td>{{ model.typePiece?.name || 'Non défini' }}</td>
<td class="hidden lg:table-cell text-xs text-gray-500">
{{ formatFrenchDate(model.updatedAt || model.createdAt) }}
</td>
<td class="text-right space-x-2">
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
Éditer
</button>
<button type="button" class="btn btn-sm btn-error" @click="confirmDelete(model)">
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<aside class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<h2 class="card-title text-lg">
{{ form.mode === 'edit' ? 'Modifier le modèle' : 'Nouveau modèle' }}
</h2>
<button
v-if="form.mode === 'edit'"
type="button"
class="btn btn-ghost btn-xs"
@click="startCreate"
>
Réinitialiser
</button>
</div>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="form-control">
<label class="label"><span class="label-text">Nom</span></label>
<input
v-model="form.data.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du modèle"
required
>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Description</span></label>
<textarea
v-model="form.data.description"
class="textarea textarea-bordered textarea-sm"
rows="3"
placeholder="Notes optionnelles"
/>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Type de pièce</span></label>
<select
v-model="form.data.typePieceId"
class="select select-bordered select-sm"
required
>
<option value="" disabled>
Sélectionner un type
</option>
<option v-for="type in pieceTypes" :key="type.id" :value="type.id">
{{ type.name }}
</option>
</select>
</div>
<div class="divider my-0">
Structure
</div>
<PieceModelStructureEditor v-model="form.data.structure" />
<div class="rounded-lg border border-base-200 bg-base-200/60 p-3 text-xs text-gray-500">
Aperçu : {{ formatStructurePreview(form.data.structure) }}
</div>
<div class="card-actions justify-end">
<button type="submit" class="btn btn-primary" :class="{ loading: form.submitting }">
{{ form.mode === 'edit' ? 'Enregistrer' : 'Créer' }}
</button>
</div>
</form>
</div>
</aside>
</div>
</section>
</main>
</template>
<script setup>
import { ref, computed, reactive, onMounted, watch } from 'vue'
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
import { usePieceModels } from '~/composables/usePieceModels'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
import { formatStructurePreview } from '~/shared/modelUtils'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucidePackage from '~icons/lucide/package'
<script setup lang="ts">
import { onMounted } from 'vue'
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const {
pieceModels,
loadPieceModels,
createPieceModel,
updatePieceModel,
deletePieceModel,
loadingPieceModels,
getPieceModelsForType
} = usePieceModels()
const { showError, showSuccess } = useToast()
const selectedType = ref('all')
const searchQuery = ref('')
const defaultStructure = () => ({ customFields: [] })
const cloneStructure = (value) => {
try {
return JSON.parse(JSON.stringify(value ?? defaultStructure()))
} catch (error) {
return defaultStructure()
}
}
const form = reactive({
mode: 'create',
submitting: false,
data: {
id: null,
name: '',
description: '',
typePieceId: '',
structure: defaultStructure()
}
})
const ensureTypeSelected = () => {
if (form.data.typePieceId && pieceTypes.value.some(type => type.id === form.data.typePieceId)) {
return
}
if (selectedType.value !== 'all' && pieceTypes.value.some(type => type.id === selectedType.value)) {
form.data.typePieceId = selectedType.value
return
}
form.data.typePieceId = pieceTypes.value[0]?.id || ''
}
const startCreate = () => {
form.mode = 'create'
form.data = {
id: null,
name: '',
description: '',
typePieceId: selectedType.value !== 'all' ? selectedType.value : '',
structure: defaultStructure()
}
ensureTypeSelected()
}
const startEdit = (model) => {
form.mode = 'edit'
form.data = {
id: model.id,
name: model.name,
description: model.description || '',
typePieceId: model.typePieceId || model.typePiece?.id || '',
structure: cloneStructure(model.structure || defaultStructure())
}
}
const filteredModels = computed(() => {
const list = selectedType.value === 'all'
? pieceModels.value
: getPieceModelsForType(selectedType.value) || []
if (!searchQuery.value.trim()) {
return list
}
const term = searchQuery.value.toLowerCase()
return list.filter((model) => {
return (
model.name?.toLowerCase().includes(term) ||
model.description?.toLowerCase().includes(term) ||
model.typePiece?.name?.toLowerCase().includes(term)
)
})
})
const refreshModels = async () => {
if (selectedType.value === 'all') {
await loadPieceModels()
} else {
await loadPieceModels(selectedType.value)
}
}
const handleSubmit = async () => {
if (!form.data.typePieceId) {
showError('Veuillez sélectionner un type de pièce')
return
}
form.submitting = true
try {
const structure = cloneStructure(form.data.structure)
if (form.mode === 'create') {
const result = await createPieceModel({
name: form.data.name.trim(),
description: form.data.description.trim() || undefined,
typePieceId: form.data.typePieceId,
structure
})
if (!result.success) {
showError(result.error || 'Impossible de créer le modèle')
return
}
showSuccess(`Modèle "${result.data.name}" créé`)
} else if (form.data.id) {
const result = await updatePieceModel(form.data.id, {
name: form.data.name.trim(),
description: form.data.description.trim() || undefined,
typePieceId: form.data.typePieceId,
structure
})
if (!result.success) {
showError(result.error || 'Impossible de mettre à jour le modèle')
return
}
showSuccess(`Modèle "${result.data.name}" mis à jour`)
}
await refreshModels()
startCreate()
} finally {
form.submitting = false
}
}
const confirmDelete = async (model) => {
const ok = confirm(`Supprimer le modèle "${model.name}" ?`)
if (!ok) { return }
const result = await deletePieceModel(model.id)
if (!result.success) {
showError(result.error || 'Impossible de supprimer ce modèle')
return
}
if (form.mode === 'edit' && form.data.id === model.id) {
startCreate()
}
showSuccess('Modèle supprimé')
await refreshModels()
}
onMounted(async () => {
await Promise.all([loadPieceTypes(), loadPieceModels()])
ensureTypeSelected()
})
watch(selectedType, async () => {
await refreshModels()
if (form.mode === 'create') {
ensureTypeSelected()
}
onMounted(() => {
navigateTo('/piece-category', { replace: true })
})
</script>

View File

@@ -1,20 +1,39 @@
import { useRequestFetch } from '#imports';
import type { FetchOptions } from 'ofetch';
import type {
ComponentModelStructure,
PieceModelStructure,
} from '~/shared/types/inventory';
export type ModelCategory = 'COMPONENT' | 'PIECE';
export interface ModelTypePayload {
export type ModelTypeStructure = ComponentModelStructure | PieceModelStructure | null;
export interface BaseModelTypePayload {
name: string;
code: string;
category: ModelCategory;
notes?: string | null;
description?: string | null;
}
export interface ModelType extends ModelTypePayload {
export interface ComponentModelTypePayload extends BaseModelTypePayload {
category: 'COMPONENT';
structure?: ComponentModelStructure | null;
}
export interface PieceModelTypePayload extends BaseModelTypePayload {
category: 'PIECE';
structure?: PieceModelStructure | null;
}
export type ModelTypePayload = ComponentModelTypePayload | PieceModelTypePayload;
export interface ModelType extends BaseModelTypePayload {
id: string;
createdAt: string;
updatedAt: string;
category: ModelCategory;
structure: ModelTypeStructure;
}
export interface ModelTypeListParams {

View File

@@ -4,6 +4,11 @@ import {
type ComponentModelPiece,
type ComponentModelStructure,
type ComponentModelStructureNode,
type PieceModelCustomField,
type PieceModelStructure,
type PieceModelStructureEditorField,
type PieceModelStructureForEditor,
createEmptyPieceModelStructure,
} from './types/inventory'
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
@@ -381,3 +386,126 @@ export const formatStructurePreview = (structure: any) => {
if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`)
return segments.join(' • ')
}
export const defaultPieceStructure = (): PieceModelStructure => ({
...createEmptyPieceModelStructure(),
})
const ensurePieceStructureShape = (input: any): PieceModelStructure => {
const base = createEmptyPieceModelStructure()
if (!isPlainObject(input)) {
return base
}
const clone: PieceModelStructure = {
...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
}
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (key === 'customFields') {
continue
}
clone[key] = value
}
return clone
}
export const clonePieceStructure = (input: any): PieceModelStructure => {
try {
const cloned = JSON.parse(JSON.stringify(input ?? defaultPieceStructure()))
return ensurePieceStructureShape(cloned)
} catch (error) {
return defaultPieceStructure()
}
}
const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
if (!Array.isArray(fields)) {
return []
}
return fields
.map((field) => {
const name = typeof field?.name === 'string' ? field.name.trim() : ''
if (!name) {
return null
}
const type = typeof field?.type === 'string' && field.type ? field.type : 'text'
const required = !!field?.required
let options: string[] | undefined
if (type === 'select') {
const rawOptions = typeof field?.optionsText === 'string'
? field.optionsText
: Array.isArray(field?.options)
? field.options.join('\n')
: ''
const parsed = rawOptions
.split(/\r?\n/)
.map((option) => option.trim())
.filter((option) => option.length > 0)
options = parsed.length > 0 ? parsed : undefined
}
const result: PieceModelCustomField = { name, type, required }
if (options) {
result.options = options
}
return result
})
.filter((field): field is PieceModelCustomField => !!field)
}
export const normalizePieceStructureForSave = (input: any): PieceModelStructure => {
const source = clonePieceStructure(input)
return {
...Object.fromEntries(
Object.entries(source).filter(([key]) => key !== 'customFields'),
),
customFields: sanitizePieceCustomFields(source.customFields),
}
}
const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField[] => {
if (!Array.isArray(fields)) {
return []
}
return fields.map((field) => ({
name: field?.name ?? '',
type: field?.type ?? 'text',
required: !!field?.required,
options: Array.isArray(field?.options) ? field.options : undefined,
optionsText: Array.isArray(field?.options) ? field.options.join('\n') : (field?.optionsText ?? ''),
}))
}
export const hydratePieceStructureForEditor = (input: any): PieceModelStructureForEditor => {
const source = clonePieceStructure(input)
const payload: PieceModelStructureForEditor = {
...Object.fromEntries(
Object.entries(source).filter(([key]) => key !== 'customFields'),
),
customFields: hydratePieceCustomFields(source.customFields),
}
return payload
}
export const formatPieceStructurePreview = (structure: any) => {
if (!structure || typeof structure !== 'object') {
return 'Aucun champ personnalisé'
}
const customFields = Array.isArray((structure as any).customFields)
? (structure as any).customFields.length
: 0
if (!customFields) {
return 'Aucun champ personnalisé'
}
return `${customFields} champ(s) personnalisé(s)`
}

View File

@@ -27,6 +27,29 @@ export interface ComponentModelStructure extends ComponentModelStructureNode {
pieces: ComponentModelPiece[]
}
export type PieceModelCustomFieldType = ComponentModelCustomFieldType
export interface PieceModelCustomField {
name: string
type: PieceModelCustomFieldType
required: boolean
options?: string[]
}
export interface PieceModelStructure {
customFields: PieceModelCustomField[]
[key: string]: unknown
}
export interface PieceModelStructureEditorField extends PieceModelCustomField {
optionsText: string
}
export interface PieceModelStructureForEditor {
customFields: PieceModelStructureEditorField[]
[key: string]: unknown
}
const FIELD_TYPES: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
@@ -219,3 +242,7 @@ export const createEmptyComponentModelStructure = (): ComponentModelStructure =>
pieces: [],
subcomponents: [],
})
export const createEmptyPieceModelStructure = (): PieceModelStructure => ({
customFields: [],
})