Move category editor to full pages and simplify root skeleton

This commit is contained in:
MatthieuTD
2025-10-07 08:30:40 +02:00
parent c5cd75a19f
commit 14e8faf3a1
11 changed files with 726 additions and 460 deletions

View File

@@ -5,10 +5,15 @@
<div class="flex-1 min-w-[220px] space-y-2">
<label class="label">
<span class="label-text text-xs font-semibold">
{{ isRoot ? 'Famille de composant racine' : 'Famille de composant' }}
{{ isRoot ? 'Composant racine de la catégorie' : 'Famille de composant' }}
</span>
</label>
<template v-if="!lockType">
<template v-if="isRoot">
<p class="text-[11px] text-gray-500">
Le composant racine correspond à la catégorie que vous éditez. Sélectionnez uniquement les familles pour les sous-composants.
</p>
</template>
<template v-else-if="!lockType">
<select
v-model="node.typeComposantId"
class="select select-bordered select-sm w-full"
@@ -312,6 +317,15 @@ const syncComponentType = (component: EditableStructureNode) => {
if (!component) {
return
}
if (props.isRoot) {
component.typeComposantId = ''
component.typeComposantLabel = ''
component.familyCode = ''
if (component.alias) {
component.alias = ''
}
return
}
if (props.lockType && props.isRoot) {
if (props.lockedTypeLabel) {
component.typeComposantLabel = props.lockedTypeLabel

View File

@@ -1,360 +0,0 @@
<template>
<Teleport to="body">
<div v-if="visible" class="modal modal-open" role="dialog" aria-modal="true" aria-labelledby="model-type-modal-title">
<div class="modal-box max-w-2xl">
<h2 id="model-type-modal-title" class="text-2xl font-semibold text-base-content">
{{ modalTitle }}
</h2>
<p class="mt-1 text-sm text-base-content/70">
Les champs marqués d'un astérisque sont obligatoires.
</p>
<form class="mt-6 space-y-6" @submit.prevent="handleSubmit">
<div>
<label class="label" for="model-type-name">
<span class="label-text">Nom *</span>
</label>
<input
id="model-type-name"
ref="nameInput"
v-model.trim="form.name"
type="text"
class="input input-bordered w-full"
name="name"
minlength="2"
maxlength="120"
required
/>
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
</div>
<div>
<label class="label" for="model-type-code">
<span class="label-text">Code *</span>
</label>
<input
id="model-type-code"
v-model.trim="form.code"
type="text"
class="input input-bordered w-full"
name="code"
minlength="2"
maxlength="60"
required
autocomplete="off"
/>
<p class="mt-1 text-xs text-base-content/70">Caractères autorisés : lettres, chiffres, -, _ et .</p>
<p v-if="errors.code" class="mt-1 text-sm text-error">{{ errors.code }}</p>
</div>
<div>
<label class="label" for="model-type-category">
<span class="label-text">Catégorie *</span>
</label>
<select
id="model-type-category"
v-model="form.category"
class="select select-bordered w-full"
name="category"
required
:disabled="lockCategory"
>
<option value="COMPONENT">Composants</option>
<option value="PIECE">Pièces</option>
</select>
</div>
<div>
<label class="label" for="model-type-notes">
<span class="label-text">Notes</span>
</label>
<textarea
id="model-type-notes"
v-model.trim="form.notes"
class="textarea textarea-bordered w-full"
rows="4"
name="notes"
maxlength="2000"
></textarea>
<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="isSubmitDisabled">
<span
v-if="saving"
class="loading loading-spinner loading-sm"
aria-hidden="true"
></span>
{{ submitLabel }}
</button>
</div>
</form>
</div>
<button type="button" class="modal-backdrop" aria-label="Fermer la modale" @click="close"></button>
</div>
</Teleport>
</template>
<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<{
visible: boolean;
mode: 'create' | 'edit';
initialCategory: ModelCategory;
initialData?: Partial<ModelTypePayload> | null;
saving?: boolean;
lockCategory?: boolean;
structureLoading?: boolean;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'save', payload: ModelTypePayload): void;
}>();
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 }>({});
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 ?? '';
form.category = props.initialData?.category ?? props.initialCategory;
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 = () => {
emit('close');
};
const modalTitle = computed(() =>
props.mode === 'edit' ? 'Modifier le type de modèle' : 'Créer un type de modèle',
);
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') {
event.preventDefault();
close();
}
};
const validate = () => {
errors.name = undefined;
errors.code = undefined;
if (form.name.trim().length < 2) {
errors.name = 'Le nom doit contenir au moins 2 caractères.';
}
if (form.name.trim().length > 120) {
errors.name = 'Le nom ne peut pas dépasser 120 caractères.';
}
if (!codePattern.test(form.code.trim())) {
errors.code = 'Le code doit respecter le format demandé.';
} else if (form.code.trim().length < 2 || form.code.trim().length > 60) {
errors.code = 'Le code doit contenir entre 2 et 60 caractères.';
}
return !errors.name && !errors.code;
};
const handleSubmit = () => {
if (!validate()) {
return;
}
const common = {
name: form.name.trim(),
code: form.code.trim(),
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)),
});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
resetForm();
document.addEventListener('keydown', onEscape);
nextTick(() => nameInput.value?.focus());
} else {
document.removeEventListener('keydown', onEscape);
}
},
);
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,
() => {
if (props.visible) {
resetForm();
}
},
{ deep: true },
);
onBeforeUnmount(() => {
document.removeEventListener('keydown', onEscape);
});
</script>

View File

@@ -14,13 +14,13 @@
:search="searchInput"
:sort="sort"
:dir="dir"
:loading="loading || saving"
:loading="loading"
:show-category-tabs="allowCategorySwitch"
@update:category="onCategoryChange"
@update:search="onSearchInput"
@update:sort="onSortChange"
@update:dir="onDirChange"
@create="openCreateModal"
@create="openCreatePage"
/>
<ModelTypesTable
@@ -29,41 +29,24 @@
:total="total"
:limit="limit"
:offset="offset"
@edit="openEditModal"
@edit="openEditPage"
@delete="confirmDelete"
@update:offset="onOffsetChange"
/>
<EditModal
:visible="isModalOpen"
:mode="modalMode"
:initial-category="selectedCategory"
:initial-data="modalInitialData"
:saving="saving"
:lock-category="!allowCategorySwitch"
:structure-loading="structureLoading"
@close="closeModal"
@save="handleSave"
/>
</main>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useHead } from "#imports";
import { useHead, useRouter } 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,
getModelType,
listModelTypes,
updateModelType,
type ModelCategory,
type ModelType,
type ModelTypeListResponse,
type ModelTypePayload,
} from "~/services/modelTypes";
import { useToast } from "~/composables/useToast";
@@ -93,17 +76,11 @@ 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);
const structureLoading = ref(false);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let activeController: AbortController | null = null;
const router = useRouter();
const { showError, showSuccess } = useToast();
const headingText = computed(() => props.heading);
@@ -233,72 +210,22 @@ const onOffsetChange = (value: number) => {
}
};
const openCreateModal = () => {
modalMode.value = "create";
modalInitialData.value = null;
editingId.value = null;
structureLoading.value = false;
isModalOpen.value = true;
const resolveCategoryBasePath = (category: ModelCategory) =>
category === "COMPONENT" ? "/component-category" : "/piece-category";
const openCreatePage = () => {
const basePath = resolveCategoryBasePath(selectedCategory.value);
router.push(`${basePath}/new`).catch(() => {
showError("Navigation impossible vers la page de création.");
});
};
const openEditModal = async (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 ?? "",
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 = () => {
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 openEditPage = (item: ModelType) => {
const category = item.category ?? selectedCategory.value;
const basePath = resolveCategoryBasePath(category);
router.push(`${basePath}/${item.id}/edit`).catch(() => {
showError("Navigation impossible vers la page d'édition.");
});
};
const confirmDelete = async (item: ModelType) => {

View File

@@ -0,0 +1,306 @@
<template>
<form class="space-y-8" @submit.prevent="handleSubmit">
<section class="space-y-4">
<div>
<label class="label" for="model-type-name">
<span class="label-text">Nom *</span>
</label>
<input
id="model-type-name"
ref="nameInput"
v-model.trim="form.name"
type="text"
class="input input-bordered w-full"
name="name"
minlength="2"
maxlength="120"
required
/>
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
</div>
<div>
<label class="label" for="model-type-code">
<span class="label-text">Code *</span>
</label>
<input
id="model-type-code"
v-model.trim="form.code"
type="text"
class="input input-bordered w-full"
name="code"
minlength="2"
maxlength="60"
required
autocomplete="off"
/>
<p class="mt-1 text-xs text-base-content/70">Caractères autorisés : lettres, chiffres, -, _ et .</p>
<p v-if="errors.code" class="mt-1 text-sm text-error">{{ errors.code }}</p>
</div>
<div>
<label class="label" for="model-type-category">
<span class="label-text">Catégorie *</span>
</label>
<select
id="model-type-category"
v-model="form.category"
class="select select-bordered w-full"
name="category"
required
:disabled="lockCategory"
>
<option value="COMPONENT">Composants</option>
<option value="PIECE">Pièces</option>
</select>
</div>
<div>
<label class="label" for="model-type-notes">
<span class="label-text">Notes</span>
</label>
<textarea
id="model-type-notes"
v-model.trim="form.notes"
class="textarea textarea-bordered w-full"
rows="4"
name="notes"
maxlength="2000"
></textarea>
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
</div>
</section>
<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>
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
Annuler
</button>
<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>
</footer>
</form>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } 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 = withDefaults(defineProps<{
mode: 'create' | 'edit'
initialCategory: ModelCategory
initialData?: Partial<ModelTypePayload> | null
saving?: boolean
lockCategory?: boolean
structureLoading?: boolean
}>(), {
initialData: null,
saving: false,
lockCategory: false,
structureLoading: false,
})
const emit = defineEmits<{
(e: 'submit', payload: ModelTypePayload): void
(e: 'cancel'): void
}>()
const lockCategory = computed(() => props.lockCategory ?? false)
const structureLoading = computed(() => props.structureLoading ?? false)
const saving = computed(() => props.saving ?? false)
const form = reactive<ModelTypePayload>({
name: '',
code: '',
category: props.initialCategory,
notes: '',
structure: undefined,
})
const errors = reactive<{ name?: string; code?: string }>({})
const nameInput = ref<HTMLInputElement | null>(null)
const codePattern = /^[a-z0-9\-_.]+$/i
const componentStructure = ref(normalizeStructureForSave(defaultStructure()))
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()))
const resetStructures = (incomingStructure: ModelTypePayload['structure'], category: ModelCategory) => {
if (category === 'COMPONENT') {
componentStructure.value = normalizeStructureForSave(
incomingStructure && props.initialData?.category === 'COMPONENT'
? incomingStructure
: defaultStructure(),
)
return
}
pieceStructure.value = normalizePieceStructureForSave(
incomingStructure && props.initialData?.category === 'PIECE'
? incomingStructure
: defaultPieceStructure(),
)
}
const resetForm = () => {
const incoming = props.initialData ?? {}
form.name = typeof incoming.name === 'string' ? incoming.name : ''
form.code = typeof incoming.code === 'string' ? incoming.code : ''
form.category = incoming.category ?? props.initialCategory
form.notes = typeof incoming.notes === 'string'
? incoming.notes
: typeof (incoming as any).description === 'string'
? (incoming as any).description
: ''
errors.name = undefined
errors.code = undefined
resetStructures(incoming.structure, form.category)
}
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
const isSubmitDisabled = computed(() => saving.value || structureLoading.value)
const validate = () => {
errors.name = undefined
errors.code = undefined
if (form.name.trim().length < 2) {
errors.name = 'Le nom doit contenir au moins 2 caractères.'
}
if (form.name.trim().length > 120) {
errors.name = 'Le nom ne peut pas dépasser 120 caractères.'
}
if (!codePattern.test(form.code.trim())) {
errors.code = 'Le code doit respecter le format demandé.'
} else if (form.code.trim().length < 2 || form.code.trim().length > 60) {
errors.code = 'Le code doit contenir entre 2 et 60 caractères.'
}
return !errors.name && !errors.code
}
const handleSubmit = () => {
if (!validate()) {
return
}
const common = {
name: form.name.trim(),
code: form.code.trim(),
notes: form.notes?.trim() ? form.notes.trim() : undefined,
}
if (form.category === 'COMPONENT') {
emit('submit', {
...common,
category: 'COMPONENT',
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
})
return
}
emit('submit', {
...common,
category: 'PIECE',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
})
}
watch(
() => props.initialData,
() => {
resetForm()
},
{ deep: true, immediate: true },
)
watch(
() => props.initialCategory,
() => {
if (!props.initialData) {
form.category = props.initialCategory
resetStructures(undefined, form.category)
}
},
)
watch(
() => form.category,
(category, previous) => {
if (category === previous) {
return
}
if (category === 'COMPONENT') {
componentStructure.value = normalizeStructureForSave(defaultStructure())
}
if (category === 'PIECE') {
pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure())
}
},
)
const componentStructurePreview = computed(() => formatStructurePreview(componentStructure.value))
const pieceStructurePreview = computed(() => formatPieceStructurePreview(pieceStructure.value))
onMounted(() => {
nextTick(() => nameInput.value?.focus())
})
</script>

View File

@@ -0,0 +1,124 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">{{ title }}</h1>
<p class="text-base text-base-content/70">
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/component-category">
Retour au catalogue
</NuxtLink>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<div v-if="loading" class="flex items-center justify-center py-16">
<span class="loading loading-spinner loading-lg" aria-hidden="true"></span>
<span class="ml-3 text-sm text-base-content/70">Chargement de la catégorie</span>
</div>
<ModelTypeForm
v-else
mode="edit"
initial-category="COMPONENT"
:initial-data="initialData"
:lock-category="true"
:saving="saving"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { useToast } from '~/composables/useToast'
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name
? `Modifier « ${initialData.value.name} »`
: 'Modifier une catégorie de composant',
)
useHead(() => ({
title: title.value,
}))
const navigateBackToList = async () => {
await router.push('/component-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const normalizeError = (error: any) => {
const message = error?.data?.message || error?.message || 'Une erreur est survenue.'
return Array.isArray(message) ? message[0] : message
}
const loadCategory = async () => {
loading.value = true
try {
const id = String(route.params.id)
const response = await getModelType(id)
if (response.category !== 'COMPONENT') {
showError("Cette catégorie n'est pas un composant." )
await navigateBackToList()
return
}
initialData.value = {
name: response.name,
code: response.code,
category: response.category,
notes: response.notes ?? response.description ?? '',
structure: response.structure ?? undefined,
}
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
} finally {
loading.value = false
}
}
const handleCancel = () => {
navigateBackToList()
}
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
const id = String(route.params.id)
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList()
} catch (error) {
showError(normalizeError(error))
} finally {
saving.value = false
}
}
onMounted(() => {
loadCategory()
})
</script>

View File

@@ -0,0 +1,68 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">Nouvelle catégorie de composant</h1>
<p class="text-base text-base-content/70">
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/component-category">
Retour au catalogue
</NuxtLink>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<ModelTypeForm
mode="create"
initial-category="COMPONENT"
:lock-category="true"
:saving="saving"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { useToast } from '~/composables/useToast'
useHead(() => ({
title: 'Nouvelle catégorie de composant',
}))
const router = useRouter()
const { showError, showSuccess } = useToast()
const saving = ref(false)
const handleCancel = () => {
router.push('/component-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
showSuccess('Catégorie de composant créée avec succès.')
await router.push('/component-category')
} catch (error: any) {
const message = error?.data?.message || error?.message || 'Une erreur est survenue lors de la création.'
showError(Array.isArray(message) ? message[0] : message)
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,122 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">{{ title }}</h1>
<p class="text-base text-base-content/70">
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/piece-category">
Retour au catalogue
</NuxtLink>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<div v-if="loading" class="flex items-center justify-center py-16">
<span class="loading loading-spinner loading-lg" aria-hidden="true"></span>
<span class="ml-3 text-sm text-base-content/70">Chargement de la catégorie</span>
</div>
<ModelTypeForm
v-else
mode="edit"
initial-category="PIECE"
:initial-data="initialData"
:lock-category="true"
:saving="saving"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { useToast } from '~/composables/useToast'
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
)
useHead(() => ({
title: title.value,
}))
const navigateBackToList = async () => {
await router.push('/piece-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const normalizeError = (error: any) => {
const message = error?.data?.message || error?.message || 'Une erreur est survenue.'
return Array.isArray(message) ? message[0] : message
}
const loadCategory = async () => {
loading.value = true
try {
const id = String(route.params.id)
const response = await getModelType(id)
if (response.category !== 'PIECE') {
showError("Cette catégorie n'est pas une pièce.")
await navigateBackToList()
return
}
initialData.value = {
name: response.name,
code: response.code,
category: response.category,
notes: response.notes ?? response.description ?? '',
structure: response.structure ?? undefined,
}
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
} finally {
loading.value = false
}
}
const handleCancel = () => {
navigateBackToList()
}
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
const id = String(route.params.id)
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList()
} catch (error) {
showError(normalizeError(error))
} finally {
saving.value = false
}
}
onMounted(() => {
loadCategory()
})
</script>

View File

@@ -0,0 +1,68 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">Nouvelle catégorie de pièce</h1>
<p class="text-base text-base-content/70">
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/piece-category">
Retour au catalogue
</NuxtLink>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<ModelTypeForm
mode="create"
initial-category="PIECE"
:lock-category="true"
:saving="saving"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { useToast } from '~/composables/useToast'
useHead(() => ({
title: 'Nouvelle catégorie de pièce',
}))
const router = useRouter()
const { showError, showSuccess } = useToast()
const saving = ref(false)
const handleCancel = () => {
router.push('/piece-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
showSuccess('Catégorie de pièce créée avec succès.')
await router.push('/piece-category')
} catch (error: any) {
const message = error?.data?.message || error?.message || 'Une erreur est survenue lors de la création.'
showError(Array.isArray(message) ? message[0] : message)
} finally {
saving.value = false
}
}
</script>

View File

@@ -210,16 +210,13 @@ const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[]
export const normalizeStructureForSave = (input: any): ComponentModelStructure => {
const source = cloneStructure(input)
return {
const result: ComponentModelStructure = {
customFields: sanitizeCustomFields(source.customFields),
pieces: sanitizePieces(source.pieces),
subcomponents: sanitizeSubcomponents(source.subcomponents),
typeComposantId: source.typeComposantId,
typeComposantLabel: source.typeComposantLabel,
modelId: source.modelId,
familyCode: source.familyCode,
alias: source.alias,
}
return result
}
const hydrateCustomFields = (fields: any[]): any[] => {