feat: Add model feature for piece and component

This commit is contained in:
Matthieu
2025-09-23 15:06:19 +02:00
parent c1e170b088
commit 83b3e33b1e
15 changed files with 2018 additions and 851 deletions

View File

@@ -295,30 +295,8 @@
:key="`${requirement.id}-piece-${entryIndex}`"
class="bg-base-200/60 rounded-md p-3 space-y-3"
>
<div class="flex flex-wrap items-center gap-2 text-xs">
<label class="inline-flex items-center gap-1">
<input
type="radio"
class="radio radio-xs"
:checked="entry.mode === 'model'"
@change="setPieceSelectionMode(requirement.id, entryIndex, 'model')"
/>
Modèle existant
</label>
<label class="inline-flex items-center gap-1">
<input
type="radio"
class="radio radio-xs"
:checked="entry.mode === 'manual'"
@change="setPieceSelectionMode(requirement.id, entryIndex, 'manual')"
:disabled="!requirement.allowNewModels"
/>
Définir manuellement
</label>
</div>
<div v-if="entry.mode === 'model'" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Modèle de pièce</span>
</label>
@@ -343,32 +321,12 @@
>
Aucun modèle disponible pour ce type.
</p>
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Nom de la pièce</span>
</label>
<input
type="text"
class="input input-bordered input-sm"
:value="entry.name"
placeholder="Nom de la pièce"
@input="updatePieceSelectionEntry(requirement.id, entryIndex, { name: $event.target.value })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Référence (optionnel)</span>
</label>
<input
type="text"
class="input input-bordered input-sm"
placeholder="(Non géré pour l'instant)"
disabled
/>
<p
v-else-if="!entry.pieceModelId && entry.legacyName"
class="text-[10px] text-warning mt-1"
>
Ancienne pièce : {{ entry.legacyName }} sélectionner un modèle.
</p>
</div>
</div>
@@ -1055,9 +1013,7 @@ const createComponentSelectionEntry = () => ({
})
const createPieceSelectionEntry = () => ({
mode: 'model',
pieceModelId: '',
name: '',
})
const resetSkeletonRequirementSelections = () => {
@@ -1121,24 +1077,20 @@ const removePieceSelectionEntry = (requirementId, index) => {
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
}
const setPieceSelectionMode = (requirementId, index, mode) => {
const updatePieceSelectionEntry = (requirementId, index, patch) => {
const entries = getPieceRequirementEntries(requirementId)
pieceRequirementSelections[requirementId] = entries.map((entry, i) => {
if (i !== index) return entry
if (mode === 'model') {
return { ...entry, mode: 'model', pieceModelId: entry.pieceModelId || '', name: '' }
const updated = { ...entry, ...patch }
if (Object.prototype.hasOwnProperty.call(patch, 'pieceModelId')) {
if (patch.pieceModelId) {
delete updated.legacyName
}
}
return { ...entry, mode: 'manual', pieceModelId: '', name: entry.name || '' }
return updated
})
}
const updatePieceSelectionEntry = (requirementId, index, patch) => {
const entries = getPieceRequirementEntries(requirementId)
pieceRequirementSelections[requirementId] = entries.map((entry, i) =>
i === index ? { ...entry, ...patch } : entry
)
}
const collectPiecesForSkeleton = () => {
const aggregated = []
machinePieces.value.forEach((piece) => {
@@ -1195,9 +1147,12 @@ const initializeSkeletonRequirementSelections = async () => {
const entries = existingPieces.map((piece) => {
const modelId = piece.pieceModelId || piece.pieceModel?.id || null
if (modelId) {
return { mode: 'model', pieceModelId: modelId, name: '' }
return { pieceModelId: modelId }
}
return {
pieceModelId: '',
legacyName: piece.name || piece.reference || '',
}
return { mode: 'manual', pieceModelId: '', name: piece.name || '' }
})
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
while (entries.length < min) {
@@ -1287,12 +1242,7 @@ const validateSkeletonSelections = (type) => {
for (const requirement of type.pieceRequirements || []) {
const entries = getPieceRequirementEntries(requirement.id)
const usableEntries = entries.filter((entry) => {
if (entry.mode === 'model') {
return !!entry.pieceModelId
}
return !!entry.name && entry.name.trim().length > 0
})
const usableEntries = entries.filter((entry) => !!entry.pieceModelId)
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
const max = requirement.maxCount ?? null
@@ -1309,26 +1259,11 @@ const validateSkeletonSelections = (type) => {
)
}
if (!requirement.allowNewModels && usableEntries.some((entry) => entry.mode === 'manual')) {
errors.push(
`Le groupe "${requirement.label || requirement.typePiece?.name || 'Pièces'}" n'autorise que les modèles existants.`
)
}
usableEntries.forEach((entry) => {
if (entry.mode === 'model') {
pieceSelectionsPayload.push({
requirementId: requirement.id,
pieceModelId: entry.pieceModelId,
})
} else {
pieceSelectionsPayload.push({
requirementId: requirement.id,
definition: {
name: entry.name.trim(),
},
})
}
pieceSelectionsPayload.push({
requirementId: requirement.id,
pieceModelId: entry.pieceModelId,
})
})
}

View File

@@ -293,29 +293,7 @@
:key="`${requirement.id}-piece-${entryIndex}`"
class="bg-base-200/60 rounded-md p-3 space-y-3"
>
<div class="flex flex-wrap items-center gap-2 text-xs">
<label class="inline-flex items-center gap-1">
<input
type="radio"
class="radio radio-xs"
:checked="entry.mode === 'model'"
@change="setPieceSelectionMode(requirement.id, entryIndex, 'model')"
/>
Modèle existant
</label>
<label class="inline-flex items-center gap-1">
<input
type="radio"
class="radio radio-xs"
:checked="entry.mode === 'manual'"
@change="setPieceSelectionMode(requirement.id, entryIndex, 'manual')"
:disabled="!requirement.allowNewModels"
/>
Définir manuellement
</label>
</div>
<div v-if="entry.mode === 'model'" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Modèle de pièce</span>
@@ -344,32 +322,6 @@
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Nom de la pièce</span>
</label>
<input
type="text"
class="input input-bordered input-sm"
:value="entry.name"
placeholder="Nom de la pièce"
@input="updatePieceSelectionEntry(requirement.id, entryIndex, { name: $event.target.value })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Référence (optionnel)</span>
</label>
<input
type="text"
class="input input-bordered input-sm"
placeholder="(Non géré pour l'instant)"
disabled
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
@@ -482,7 +434,6 @@
</p>
<p v-if="entry.subtitle" class="text-xs text-gray-500">{{ entry.subtitle }}</p>
</div>
<span v-if="entry.mode === 'manual'" class="badge badge-ghost badge-xs">manuel</span>
</li>
</ul>
</div>
@@ -903,23 +854,12 @@ const machinePreview = computed(() => {
const entriesSource = getPieceRequirementEntries(requirement.id)
const entriesList = entriesSource ? [...entriesSource] : []
const normalizedEntries = entriesList.map((entry, index) => {
if (entry.mode === 'model') {
const model = resolvePieceModel(requirement, entry.pieceModelId)
return {
key: `${requirement.id}-${index}`,
mode: 'model',
status: model ? 'complete' : 'pending',
title: model ? model.name : 'Sélectionner un modèle',
subtitle: model?.description || null,
}
}
const manualName = (entry.name || '').trim()
const model = resolvePieceModel(requirement, entry.pieceModelId)
return {
key: `${requirement.id}-${index}`,
mode: 'manual',
status: manualName ? 'complete' : 'pending',
title: manualName || 'Nom à renseigner',
subtitle: manualName ? null : null,
status: model ? 'complete' : 'pending',
title: model ? model.name : 'Sélectionner un modèle',
subtitle: model?.description || null,
}
})
@@ -936,10 +876,6 @@ const machinePreview = computed(() => {
issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `piece-group-${requirement.id}` })
}
if (!requirement.allowNewModels && normalizedEntries.some((entry) => entry.mode === 'manual' && entry.status === 'complete')) {
issues.push({ message: "Ce groupe n'autorise que les modèles existants.", kind: 'error', anchor: `piece-group-${requirement.id}` })
}
if (normalizedEntries.some((entry) => entry.status !== 'complete')) {
issues.push({ message: 'Compléter les sélections restantes.', kind: 'warning', anchor: `piece-group-${requirement.id}` })
}
@@ -1056,9 +992,7 @@ const createComponentSelectionEntry = () => ({
})
const createPieceSelectionEntry = () => ({
mode: 'model',
pieceModelId: '',
name: '',
})
const addComponentSelectionEntry = (requirement) => {
@@ -1109,17 +1043,6 @@ const removePieceSelectionEntry = (requirementId, index) => {
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
}
const setPieceSelectionMode = (requirementId, index, mode) => {
const entries = getPieceRequirementEntries(requirementId)
pieceRequirementSelections[requirementId] = entries.map((entry, i) => {
if (i !== index) return entry
if (mode === 'model') {
return { ...entry, mode: 'model', pieceModelId: entry.pieceModelId || '', name: '' }
}
return { ...entry, mode: 'manual', pieceModelId: '', name: entry.name || '' }
})
}
const updatePieceSelectionEntry = (requirementId, index, patch) => {
const entries = getPieceRequirementEntries(requirementId)
pieceRequirementSelections[requirementId] = entries.map((entry, i) =>
@@ -1175,12 +1098,7 @@ const validateRequirementSelections = (type) => {
for (const requirement of type.pieceRequirements || []) {
const entries = getPieceRequirementEntries(requirement.id)
const usableEntries = entries.filter((entry) => {
if (entry.mode === 'model') {
return !!entry.pieceModelId
}
return !!entry.name && entry.name.trim().length > 0
})
const usableEntries = entries.filter((entry) => !!entry.pieceModelId)
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
const max = requirement.maxCount ?? null
@@ -1193,24 +1111,11 @@ const validateRequirementSelections = (type) => {
errors.push(`Le groupe "${requirement.label || requirement.typePiece?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
}
if (!requirement.allowNewModels && usableEntries.some((entry) => entry.mode === 'manual')) {
errors.push(`Le groupe "${requirement.label || requirement.typePiece?.name || 'Pièces'}" n'autorise que les modèles existants.`)
}
usableEntries.forEach((entry) => {
if (entry.mode === 'model') {
pieceSelectionsPayload.push({
requirementId: requirement.id,
pieceModelId: entry.pieceModelId,
})
} else {
pieceSelectionsPayload.push({
requirementId: requirement.id,
definition: {
name: entry.name.trim(),
},
})
}
pieceSelectionsPayload.push({
requirementId: requirement.id,
pieceModelId: entry.pieceModelId,
})
})
}
@@ -1322,10 +1227,9 @@ const submitCreatePieceModel = async () => {
if (result.success) {
await loadPieceModels(createPieceModelModal.requirement.typePieceId)
const entries = getPieceRequirementEntries(createPieceModelModal.requirement.id)
const targetIndex = entries.findIndex((entry) => entry.mode === 'model' && !entry.pieceModelId)
const targetIndex = entries.findIndex((entry) => !entry.pieceModelId)
if (targetIndex !== -1) {
updatePieceSelectionEntry(createPieceModelModal.requirement.id, targetIndex, {
mode: 'model',
pieceModelId: result.data.id,
})
} else {
@@ -1334,7 +1238,6 @@ const submitCreatePieceModel = async () => {
createPieceModelModal.requirement.id,
getPieceRequirementEntries(createPieceModelModal.requirement.id).length - 1,
{
mode: 'model',
pieceModelId: result.data.id,
},
)

284
app/pages/model-types.vue Normal file
View File

@@ -0,0 +1,284 @@
<template>
<main class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="space-y-2">
<p class="text-sm uppercase tracking-wide text-primary">Administration</p>
<h1 class="text-3xl font-bold text-base-content">Types de modèles</h1>
<p class="text-base text-base-content/70">
Gérez les types de modèles pour les composants et les pièces. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.
</p>
</header>
<ModelTypesToolbar
:category="category"
:search="searchInput"
:sort="sort"
:dir="dir"
:loading="loading || saving"
@update:category="onCategoryChange"
@update:search="onSearchInput"
@update:sort="onSortChange"
@update:dir="onDirChange"
@create="openCreateModal"
/>
<ModelTypesTable
:items="items"
:loading="loading"
:total="total"
:limit="limit"
:offset="offset"
@edit="openEditModal"
@delete="confirmDelete"
@update:offset="onOffsetChange"
/>
<EditModal
:visible="isModalOpen"
:mode="modalMode"
:initial-category="category"
:initial-data="modalInitialData"
:saving="saving"
@close="closeModal"
@save="handleSave"
/>
</main>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useHead } from '#imports';
import ModelTypesToolbar from '~/components/model-types/Toolbar.vue';
import ModelTypesTable from '~/components/model-types/Table.vue';
import EditModal from '~/components/model-types/EditModal.vue';
import {
createModelType,
deleteModelType,
listModelTypes,
updateModelType,
type ModelCategory,
type ModelType,
type ModelTypeListResponse,
type ModelTypePayload,
} from '~/services/modelTypes';
import { useToast } from '~/composables/useToast';
const category = ref<ModelCategory>('COMPONENT');
const searchInput = ref('');
const searchTerm = ref('');
const sort = ref<'name' | 'code' | 'createdAt'>('createdAt');
const dir = ref<'asc' | 'desc'>('desc');
const limit = ref(20);
const offset = ref(0);
const items = ref<ModelType[]>([]);
const total = ref(0);
const loading = ref(false);
const saving = ref(false);
const isModalOpen = ref(false);
const modalMode = ref<'create' | 'edit'>('create');
const modalInitialData = ref<Partial<ModelTypePayload> | null>(null);
const editingId = ref<string | null>(null);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let activeController: AbortController | null = null;
const { showError, showSuccess } = useToast();
useHead({
title: 'Types de modèles',
});
const extractErrorMessage = (error: unknown) => {
if (error && typeof error === 'object') {
const maybeFetchError = error as { data?: any; statusMessage?: string; message?: string };
if (maybeFetchError.data) {
const data = maybeFetchError.data;
if (typeof data.message === 'string') {
return data.message;
}
if (Array.isArray(data.message) && data.message.length > 0) {
return data.message[0];
}
}
if (typeof maybeFetchError.statusMessage === 'string') {
return maybeFetchError.statusMessage;
}
if (typeof maybeFetchError.message === 'string') {
return maybeFetchError.message;
}
}
return 'Une erreur est survenue lors de la communication avec le serveur.';
};
const refresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}) => {
if (resetOffset) {
offset.value = 0;
}
if (activeController) {
activeController.abort();
}
const controller = new AbortController();
activeController = controller;
loading.value = true;
try {
const response: ModelTypeListResponse = await listModelTypes(
{
q: searchTerm.value || undefined,
category: category.value,
sort: sort.value,
dir: dir.value,
limit: limit.value,
offset: offset.value,
},
{ signal: controller.signal },
);
items.value = response.items;
total.value = response.total;
offset.value = response.offset;
limit.value = response.limit;
} catch (error: any) {
if (error?.name === 'AbortError') {
return;
}
showError(extractErrorMessage(error));
} finally {
if (activeController === controller) {
loading.value = false;
activeController = null;
}
}
};
const onSearchInput = (value: string) => {
searchInput.value = value;
};
const onCategoryChange = (value: ModelCategory) => {
if (category.value !== value) {
category.value = value;
refresh({ resetOffset: true });
}
};
const onSortChange = (value: 'name' | 'code' | 'createdAt') => {
if (sort.value !== value) {
sort.value = value;
refresh({ resetOffset: true });
}
};
const onDirChange = (value: 'asc' | 'desc') => {
if (dir.value !== value) {
dir.value = value;
refresh({ resetOffset: true });
}
};
const onOffsetChange = (value: number) => {
const nextOffset = Math.max(0, value);
if (nextOffset !== offset.value) {
offset.value = nextOffset;
refresh();
}
};
const openCreateModal = () => {
modalMode.value = 'create';
modalInitialData.value = null;
editingId.value = null;
isModalOpen.value = true;
};
const openEditModal = (item: ModelType) => {
modalMode.value = 'edit';
editingId.value = item.id;
modalInitialData.value = {
name: item.name,
code: item.code,
category: item.category,
notes: item.notes ?? item.description ?? '',
};
isModalOpen.value = true;
};
const closeModal = () => {
isModalOpen.value = false;
};
const handleSave = async (payload: ModelTypePayload) => {
saving.value = true;
try {
const enrichedPayload = {
...payload,
description: payload.notes ?? null,
};
if (modalMode.value === 'create') {
await createModelType(enrichedPayload);
showSuccess('Type de modèle créé avec succès.');
} else if (modalMode.value === 'edit' && editingId.value) {
await updateModelType(editingId.value, enrichedPayload);
showSuccess('Type de modèle mis à jour avec succès.');
}
isModalOpen.value = false;
await refresh();
} catch (error) {
showError(extractErrorMessage(error));
} finally {
saving.value = false;
}
};
const confirmDelete = async (item: ModelType) => {
const confirmed = window.confirm('Supprimer ce type ? Cette action est irréversible.');
if (!confirmed) {
return;
}
try {
await deleteModelType(item.id);
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
if (items.value.length === 1 && offset.value >= limit.value) {
offset.value = Math.max(0, offset.value - limit.value);
}
await refresh();
} catch (error) {
showError(extractErrorMessage(error));
}
};
watch(
() => searchInput.value,
(value) => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
searchTerm.value = value.trim();
refresh({ resetOffset: true });
}, 300);
},
);
onMounted(() => {
refresh();
});
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
if (activeController) {
activeController.abort();
}
});
</script>

View File

@@ -1,585 +0,0 @@
<template>
<main class="container mx-auto px-6 py-8 space-y-8">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-800">Catalogue de modèles</h1>
<p class="text-sm text-gray-500">
Administrez les modèles de composants et de pièces disponibles lors de la création des machines.
</p>
</div>
<div class="tabs tabs-boxed w-full md:w-auto">
<button
type="button"
class="tab"
:class="{ 'tab-active': activeTab === 'components' }"
@click="activeTab = 'components'"
>
Modèles de composants
</button>
<button
type="button"
class="tab"
:class="{ 'tab-active': activeTab === 'pieces' }"
@click="activeTab = 'pieces'"
>
Modèles de pièces
</button>
</div>
</div>
<!-- Component Models -->
<section v-if="activeTab === 'components'" class="space-y-4">
<div 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="selectedComponentType" 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>
<span class="text-xs text-gray-500">
{{ componentModelsList.length }} modèle(s)
</span>
</div>
<button type="button" class="btn btn-primary btn-sm w-full md:w-auto" @click="openComponentModal('create')">
<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-12">
<span class="loading loading-spinner loading-md"></span>
</div>
<div v-else-if="componentModelsList.length === 0" class="py-12 text-center text-sm text-gray-500">
Aucun modèle trouvé pour 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">Structure</th>
<th class="hidden lg:table-cell">Dernière modification</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="model in componentModelsList" :key="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 lg:table-cell">{{ formatStructurePreview(model.structure) }}</td>
<td class="hidden lg:table-cell">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
<td class="text-right space-x-2">
<button type="button" class="btn btn-sm btn-outline" @click="openComponentModal('edit', model)">
Éditer
</button>
<button type="button" class="btn btn-sm btn-error" @click="confirmDeleteComponentModel(model)">
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Piece Models -->
<section v-else class="space-y-4">
<div 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="selectedPieceType" 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>
<span class="text-xs text-gray-500">
{{ pieceModelsList.length }} modèle(s)
</span>
</div>
<button type="button" class="btn btn-primary btn-sm w-full md:w-auto" @click="openPieceModal('create')">
<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-12">
<span class="loading loading-spinner loading-md"></span>
</div>
<div v-else-if="pieceModelsList.length === 0" class="py-12 text-center text-sm text-gray-500">
Aucun modèle trouvé pour 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">Dernière modification</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="model in pieceModelsList" :key="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">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
<td class="text-right space-x-2">
<button type="button" class="btn btn-sm btn-outline" @click="openPieceModal('edit', model)">
Éditer
</button>
<button type="button" class="btn btn-sm btn-error" @click="confirmDeletePieceModel(model)">
Supprimer
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Component Model Modal -->
<div v-if="componentModal.open" class="modal modal-open">
<div class="modal-box max-w-4xl">
<h3 class="font-bold text-lg mb-1">
{{ componentModal.mode === 'create' ? 'Nouveau modèle de composant' : 'Modifier le modèle de composant' }}
</h3>
<p class="text-xs text-gray-500 mb-4">
Définissez le modèle de composant ainsi que sa structure par défaut (sous-composants, pièces et champs personnalisés).
</p>
<form class="space-y-5" @submit.prevent="submitComponentModal">
<div class="form-control">
<label class="label"><span class="label-text">Nom</span></label>
<input v-model="componentModal.form.name" type="text" class="input input-bordered input-sm" required />
</div>
<div class="form-control">
<label class="label"><span class="label-text">Description</span></label>
<textarea
v-model="componentModal.form.description"
class="textarea textarea-bordered textarea-sm"
rows="3"
placeholder="Notes sur ce modèle"
></textarea>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Type de composant</span></label>
<select v-model="componentModal.form.typeComposantId" class="select select-bordered select-sm" required>
<option value="" disabled>Choisir 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="componentModal.form.structure" />
<div class="rounded-lg border border-base-200 bg-base-200/60 p-3">
<ModelStructureViewer :structure="componentModal.form.structure" />
</div>
<div class="modal-action">
<button type="button" class="btn btn-outline" @click="closeComponentModal">Annuler</button>
<button type="submit" class="btn btn-primary" :class="{ loading: componentModal.submitting }">
{{ componentModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</form>
</div>
</div>
<!-- Piece Model Modal -->
<div v-if="pieceModal.open" class="modal modal-open">
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-1">
{{ pieceModal.mode === 'create' ? 'Nouveau modèle de pièce' : 'Modifier le modèle de pièce' }}
</h3>
<form class="space-y-4" @submit.prevent="submitPieceModal">
<div class="form-control">
<label class="label"><span class="label-text">Nom</span></label>
<input v-model="pieceModal.form.name" type="text" class="input input-bordered input-sm" required />
</div>
<div class="form-control">
<label class="label"><span class="label-text">Description</span></label>
<textarea
v-model="pieceModal.form.description"
class="textarea textarea-bordered textarea-sm"
rows="3"
placeholder="Notes sur ce modèle"
></textarea>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Type de pièce</span></label>
<select v-model="pieceModal.form.typePieceId" class="select select-bordered select-sm" required>
<option value="" disabled>Choisir un type</option>
<option
v-for="type in pieceTypes"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
</div>
<div class="modal-action">
<button type="button" class="btn btn-outline" @click="closePieceModal">Annuler</button>
<button type="submit" class="btn btn-primary" :class="{ loading: pieceModal.submitting }">
{{ pieceModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</form>
</div>
</div>
</main>
</template>
<script setup>
import { ref, computed, reactive, watch, onMounted } from 'vue'
import { useComponentModels } from '~/composables/useComponentModels'
import { usePieceModels } from '~/composables/usePieceModels'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideLayers from '~icons/lucide/layers'
import IconLucidePackage from '~icons/lucide/package'
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue'
import ModelStructureViewer from '~/components/ModelStructureViewer.vue'
import {
defaultStructure,
cloneStructure,
formatStructurePreview,
normalizeStructureForSave,
} from '~/shared/modelUtils'
const activeTab = ref('components')
const selectedComponentType = ref('all')
const selectedPieceType = ref('all')
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const {
componentModels,
loadComponentModels,
createComponentModel,
updateComponentModel,
deleteComponentModel,
loadingComponentModels,
getComponentModelsForType,
} = useComponentModels()
const {
pieceModels,
loadPieceModels,
createPieceModel,
updatePieceModel,
deletePieceModel,
loadingPieceModels,
getPieceModelsForType,
} = usePieceModels()
const toast = useToast()
const componentModal = reactive({
open: false,
mode: 'create',
submitting: false,
previousTypeId: null,
form: {
id: null,
name: '',
description: '',
typeComposantId: '',
structure: defaultStructure(),
},
})
const pieceModal = reactive({
open: false,
mode: 'create',
submitting: false,
previousTypeId: null,
form: {
id: null,
name: '',
description: '',
typePieceId: '',
},
})
const componentModelsList = computed(() => {
if (selectedComponentType.value === 'all') {
return componentModels.value
}
return getComponentModelsForType(selectedComponentType.value) || []
})
const pieceModelsList = computed(() => {
if (selectedPieceType.value === 'all') {
return pieceModels.value
}
return getPieceModelsForType(selectedPieceType.value) || []
})
const formatDate = (value) => {
if (!value) return '—'
const date = new Date(value)
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
const ensureTypeSelected = (typeId, list) => {
if (typeId && list.some((type) => type.id === typeId)) {
return typeId
}
return list[0]?.id || ''
}
const openComponentModal = (mode, model) => {
componentModal.mode = mode
componentModal.open = true
componentModal.submitting = false
componentModal.previousTypeId = model?.typeComposantId || null
if (mode === 'edit' && model) {
componentModal.form = {
id: model.id,
name: model.name,
description: model.description || '',
typeComposantId: model.typeComposantId || model.typeComposant?.id || '',
structure: cloneStructure(model.structure || defaultStructure()),
}
} else {
componentModal.form = {
id: null,
name: '',
description: '',
typeComposantId: ensureTypeSelected(selectedComponentType.value !== 'all' ? selectedComponentType.value : '', componentTypes.value),
structure: defaultStructure(),
}
}
}
const closeComponentModal = () => {
componentModal.open = false
}
const submitComponentModal = async () => {
if (!componentModal.form.typeComposantId) {
toast.showError('Veuillez sélectionner un type de composant')
return
}
componentModal.submitting = true
try {
if (componentModal.mode === 'create') {
await createComponentModel({
name: componentModal.form.name.trim(),
description: componentModal.form.description.trim() || undefined,
typeComposantId: componentModal.form.typeComposantId,
structure: normalizeStructureForSave(componentModal.form.structure),
})
} else {
await updateComponentModel(componentModal.form.id, {
name: componentModal.form.name.trim(),
description: componentModal.form.description.trim() || undefined,
typeComposantId: componentModal.form.typeComposantId,
structure: normalizeStructureForSave(componentModal.form.structure),
})
}
await refreshComponentModels(componentModal.form.typeComposantId)
if (selectedComponentType.value === 'all') {
await refreshComponentModels()
}
if (
componentModal.mode === 'edit' &&
componentModal.previousTypeId &&
componentModal.previousTypeId !== componentModal.form.typeComposantId
) {
await refreshComponentModels(componentModal.previousTypeId)
}
closeComponentModal()
} catch (error) {
toast.showError('Impossible d\'enregistrer le modèle de composant')
} finally {
componentModal.submitting = false
}
}
const confirmDeleteComponentModel = async (model) => {
if (!confirm(`Supprimer le modèle "${model.name}" ?`)) return
try {
const result = await deleteComponentModel(model.id)
if (result.success) {
await refreshComponentModels(model.typeComposantId)
if (selectedComponentType.value === 'all') {
await refreshComponentModels()
}
}
} catch (error) {
toast.showError('Impossible de supprimer ce modèle')
}
}
const openPieceModal = (mode, model) => {
pieceModal.mode = mode
pieceModal.open = true
pieceModal.submitting = false
pieceModal.previousTypeId = model?.typePieceId || null
if (mode === 'edit' && model) {
pieceModal.form = {
id: model.id,
name: model.name,
description: model.description || '',
typePieceId: model.typePieceId || model.typePiece?.id || '',
}
} else {
pieceModal.form = {
id: null,
name: '',
description: '',
typePieceId: ensureTypeSelected(selectedPieceType.value !== 'all' ? selectedPieceType.value : '', pieceTypes.value),
}
}
}
const closePieceModal = () => {
pieceModal.open = false
}
const submitPieceModal = async () => {
if (!pieceModal.form.typePieceId) {
toast.showError('Veuillez sélectionner un type de pièce')
return
}
pieceModal.submitting = true
try {
if (pieceModal.mode === 'create') {
await createPieceModel({
name: pieceModal.form.name.trim(),
description: pieceModal.form.description.trim() || undefined,
typePieceId: pieceModal.form.typePieceId,
structure: {},
})
} else {
await updatePieceModel(pieceModal.form.id, {
name: pieceModal.form.name.trim(),
description: pieceModal.form.description.trim() || undefined,
typePieceId: pieceModal.form.typePieceId,
})
}
await refreshPieceModels(pieceModal.form.typePieceId)
if (selectedPieceType.value === 'all') {
await refreshPieceModels()
}
if (
pieceModal.mode === 'edit' &&
pieceModal.previousTypeId &&
pieceModal.previousTypeId !== pieceModal.form.typePieceId
) {
await refreshPieceModels(pieceModal.previousTypeId)
}
closePieceModal()
} catch (error) {
toast.showError('Impossible d\'enregistrer le modèle de pièce')
} finally {
pieceModal.submitting = false
}
}
const confirmDeletePieceModel = async (model) => {
if (!confirm(`Supprimer le modèle "${model.name}" ?`)) return
try {
const result = await deletePieceModel(model.id)
if (result.success) {
await refreshPieceModels(model.typePieceId)
if (selectedPieceType.value === 'all') {
await refreshPieceModels()
}
}
} catch (error) {
toast.showError('Impossible de supprimer ce modèle')
}
}
const refreshComponentModels = async (typeId) => {
if (typeId) {
await loadComponentModels(typeId)
} else {
await loadComponentModels()
}
}
const refreshPieceModels = async (typeId) => {
if (typeId) {
await loadPieceModels(typeId)
} else {
await loadPieceModels()
}
}
watch(selectedComponentType, async (value) => {
await refreshComponentModels(value === 'all' ? undefined : value)
}, { immediate: true })
watch(selectedPieceType, async (value) => {
await refreshPieceModels(value === 'all' ? undefined : value)
}, { immediate: true })
onMounted(async () => {
await Promise.all([
loadComponentTypes(),
loadPieceTypes(),
])
})
</script>

View File

@@ -0,0 +1,347 @@
<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">Modèles de composants</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="/models/components" class="tab tab-active">Composants</NuxtLink>
<NuxtLink to="/models/pieces" class="tab">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"></span>
</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">{{ formatDate(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"
></textarea>
</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" />
<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>
</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 } from '~/shared/modelUtils'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideLayers from '~icons/lucide/layers'
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: {},
},
})
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: {},
}
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: model.structure || {},
}
}
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 formatDate = (value) => {
if (!value) return '—'
return new Date(value).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
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 {
if (form.mode === 'create') {
const result = await createComponentModel({
name: form.data.name.trim(),
description: form.data.description.trim() || undefined,
typeComposantId: form.data.typeComposantId,
structure: form.data.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 updateComponentModel(form.data.id, {
name: form.data.name.trim(),
description: form.data.description.trim() || undefined,
typeComposantId: form.data.typeComposantId,
structure: form.data.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 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()
}
})
</script>

View File

@@ -0,0 +1,25 @@
<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 modèles</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.
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-3">
<NuxtLink to="/models/components" class="btn btn-primary">
Modèles de composants
</NuxtLink>
<NuxtLink to="/models/pieces" class="btn btn-outline">
Modèles de pièces
</NuxtLink>
</div>
</div>
</template>
<script setup>
const route = useRoute()
if (route.fullPath === '/models') {
navigateTo('/models/components', { replace: true })
}
</script>

349
app/pages/models/pieces.vue Normal file
View File

@@ -0,0 +1,349 @@
<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">Modèles de pièces</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="/models/components" class="tab">Composants</NuxtLink>
<NuxtLink to="/models/pieces" class="tab tab-active">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 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"></span>
</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">{{ formatDate(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"
></textarea>
</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>
</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 IconLucidePlus from '~icons/lucide/plus'
import IconLucidePackage from '~icons/lucide/package'
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 formatDate = (value) => {
if (!value) return '—'
return new Date(value).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
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()
}
})
</script>