chore: retire legacy model catalogs
This commit is contained in:
82
app/app.vue
82
app/app.vue
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
() => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)`
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user