frontend: refactor model type management and catalog routes
This commit is contained in:
@@ -57,6 +57,7 @@
|
|||||||
class="select select-bordered w-full"
|
class="select select-bordered w-full"
|
||||||
name="category"
|
name="category"
|
||||||
required
|
required
|
||||||
|
:disabled="lockCategory"
|
||||||
>
|
>
|
||||||
<option value="COMPONENT">Composants</option>
|
<option value="COMPONENT">Composants</option>
|
||||||
<option value="PIECE">Pièces</option>
|
<option value="PIECE">Pièces</option>
|
||||||
@@ -102,6 +103,7 @@ const props = defineProps<{
|
|||||||
initialCategory: ModelCategory;
|
initialCategory: ModelCategory;
|
||||||
initialData?: Partial<ModelTypePayload> | null;
|
initialData?: Partial<ModelTypePayload> | null;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
|
lockCategory?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -109,6 +111,8 @@ const emit = defineEmits<{
|
|||||||
(e: 'save', payload: ModelTypePayload): void;
|
(e: 'save', payload: ModelTypePayload): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const lockCategory = computed(() => props.lockCategory ?? false);
|
||||||
|
|
||||||
const form = reactive<ModelTypePayload>({
|
const form = reactive<ModelTypePayload>({
|
||||||
name: '',
|
name: '',
|
||||||
code: '',
|
code: '',
|
||||||
|
|||||||
329
app/components/model-types/ManagementView.vue
Normal file
329
app/components/model-types/ManagementView.vue
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<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">
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
|
||||||
|
<p class="text-base text-base-content/70">
|
||||||
|
{{ descriptionText }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ModelTypesToolbar
|
||||||
|
:category="selectedCategory"
|
||||||
|
:search="searchInput"
|
||||||
|
:sort="sort"
|
||||||
|
:dir="dir"
|
||||||
|
:loading="loading || saving"
|
||||||
|
:show-category-tabs="allowCategorySwitch"
|
||||||
|
@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="selectedCategory"
|
||||||
|
:initial-data="modalInitialData"
|
||||||
|
:saving="saving"
|
||||||
|
:lock-category="!allowCategorySwitch"
|
||||||
|
@close="closeModal"
|
||||||
|
@save="handleSave"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, 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 DEFAULT_DESCRIPTION =
|
||||||
|
"Gérez les catégories utilisées pour structurer les catalogues de composants et de pièces. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
category: ModelCategory;
|
||||||
|
heading: string;
|
||||||
|
description?: string;
|
||||||
|
allowCategorySwitch?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
allowCategorySwitch: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCategory = ref<ModelCategory>(props.category);
|
||||||
|
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();
|
||||||
|
|
||||||
|
const headingText = computed(() => props.heading);
|
||||||
|
const descriptionText = computed(
|
||||||
|
() => props.description ?? DEFAULT_DESCRIPTION
|
||||||
|
);
|
||||||
|
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false);
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
title: headingText.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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: selectedCategory.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.category,
|
||||||
|
(value) => {
|
||||||
|
if (value !== selectedCategory.value) {
|
||||||
|
selectedCategory.value = value;
|
||||||
|
refresh({ resetOffset: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSearchInput = (value: string) => {
|
||||||
|
searchInput.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCategoryChange = (value: ModelCategory) => {
|
||||||
|
if (!allowCategorySwitch.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedCategory.value !== value) {
|
||||||
|
selectedCategory.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>
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<nav class="tabs tabs-boxed inline-flex" role="tablist" aria-label="Catégories">
|
<nav
|
||||||
|
v-if="displayCategoryTabs"
|
||||||
|
class="tabs tabs-boxed inline-flex"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Catégories"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="option in categories"
|
v-for="option in categories"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
@@ -84,6 +89,7 @@ const props = defineProps<{
|
|||||||
sort: SortField;
|
sort: SortField;
|
||||||
dir: SortDirection;
|
dir: SortDirection;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
showCategoryTabs?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -104,4 +110,5 @@ const onSearch = (event: Event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loading = computed(() => props.loading ?? false);
|
const loading = computed(() => props.loading ?? false);
|
||||||
|
const displayCategoryTabs = computed(() => props.showCategoryTabs ?? true);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<p class="text-sm text-gray-500">Gérez les modèles disponibles pour chaque famille de composant.</p>
|
<p class="text-sm text-gray-500">Gérez les modèles disponibles pour chaque famille de composant.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="tabs tabs-boxed">
|
<div class="tabs tabs-boxed">
|
||||||
<NuxtLink to="/models/components" class="tab tab-active">Composants</NuxtLink>
|
<NuxtLink to="/component-catalog" class="tab tab-active">Composants</NuxtLink>
|
||||||
<NuxtLink to="/models/pieces" class="tab">Pièces</NuxtLink>
|
<NuxtLink to="/pieces-catalog" class="tab">Pièces</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
10
app/pages/component-category.vue
Normal file
10
app/pages/component-category.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<ManagementView
|
||||||
|
category="COMPONENT"
|
||||||
|
heading="Catégories de composant"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ManagementView from '~/components/model-types/ManagementView.vue'
|
||||||
|
</script>
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||||
<NuxtLink to="/models/components" class="btn btn-primary">
|
<NuxtLink to="/component-catalog" class="btn btn-primary">
|
||||||
Catalogue de composant
|
Catalogue de composant
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/models/pieces" class="btn btn-outline">
|
<NuxtLink to="/pieces-catalog" class="btn btn-outline">
|
||||||
Catalogue de pièce
|
Catalogue de pièce
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -20,6 +20,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
if (route.fullPath === '/models') {
|
if (route.fullPath === '/models') {
|
||||||
navigateTo('/models/components', { replace: true })
|
navigateTo('/component-catalog', { replace: true })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
10
app/pages/piece-category.vue
Normal file
10
app/pages/piece-category.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<ManagementView
|
||||||
|
category="PIECE"
|
||||||
|
heading="Catégories de pièce"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ManagementView from '~/components/model-types/ManagementView.vue'
|
||||||
|
</script>
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
<p class="text-sm text-gray-500">Gérez les modèles disponibles pour chaque groupe de pièces.</p>
|
<p class="text-sm text-gray-500">Gérez les modèles disponibles pour chaque groupe de pièces.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="tabs tabs-boxed">
|
<div class="tabs tabs-boxed">
|
||||||
<NuxtLink to="/models/components" class="tab">Composants</NuxtLink>
|
<NuxtLink to="/component-catalog" class="tab">Composants</NuxtLink>
|
||||||
<NuxtLink to="/models/pieces" class="tab tab-active">Pièces</NuxtLink>
|
<NuxtLink to="/pieces-catalog" class="tab tab-active">Pièces</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
## DUP-003 · Score 88 · Formatage de dates UI
|
## DUP-003 · Score 88 · Formatage de dates UI
|
||||||
- **Motif** : fonctions utilitaires de formatage (`toLocaleDateString`/`Intl.DateTimeFormat`) recopiées dans plusieurs pages (catalogues modèles et documents).
|
- **Motif** : fonctions utilitaires de formatage (`toLocaleDateString`/`Intl.DateTimeFormat`) recopiées dans plusieurs pages (catalogues modèles et documents).
|
||||||
- **Occurrences détectées** :
|
- **Occurrences détectées** :
|
||||||
- `app/pages/models/components.vue` — lignes 70-311 (affichage de la colonne « Modifié »).
|
- `app/pages/component-catalog.vue` — lignes 70-311 (affichage de la colonne « Modifié »).
|
||||||
- `app/pages/models/pieces.vue` — lignes 70-310.
|
- `app/pages/pieces-catalog.vue` — lignes 70-310.
|
||||||
- `app/pages/documents.vue` — lignes 90-188.
|
- `app/pages/documents.vue` — lignes 90-188.
|
||||||
- **Extraction** : utilitaire commun `app/utils/date.ts` exposant `formatFrenchDate(value: Date | string | number | null | undefined): string` avec gestion des valeurs nulles/invalides.
|
- **Extraction** : utilitaire commun `app/utils/date.ts` exposant `formatFrenchDate(value: Date | string | number | null | undefined): string` avec gestion des valeurs nulles/invalides.
|
||||||
- **Plan / Statut** : toutes les pages importent `formatFrenchDate` et l’utilisent directement en template. Plus de fonction locale dupliquée.
|
- **Plan / Statut** : toutes les pages importent `formatFrenchDate` et l’utilisent directement en template. Plus de fonction locale dupliquée.
|
||||||
|
|||||||
Reference in New Issue
Block a user