Move category editor to full pages and simplify root skeleton
This commit is contained in:
@@ -5,10 +5,15 @@
|
|||||||
<div class="flex-1 min-w-[220px] space-y-2">
|
<div class="flex-1 min-w-[220px] space-y-2">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs font-semibold">
|
<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>
|
</span>
|
||||||
</label>
|
</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
|
<select
|
||||||
v-model="node.typeComposantId"
|
v-model="node.typeComposantId"
|
||||||
class="select select-bordered select-sm w-full"
|
class="select select-bordered select-sm w-full"
|
||||||
@@ -312,6 +317,15 @@ const syncComponentType = (component: EditableStructureNode) => {
|
|||||||
if (!component) {
|
if (!component) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (props.isRoot) {
|
||||||
|
component.typeComposantId = ''
|
||||||
|
component.typeComposantLabel = ''
|
||||||
|
component.familyCode = ''
|
||||||
|
if (component.alias) {
|
||||||
|
component.alias = ''
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (props.lockType && props.isRoot) {
|
if (props.lockType && props.isRoot) {
|
||||||
if (props.lockedTypeLabel) {
|
if (props.lockedTypeLabel) {
|
||||||
component.typeComposantLabel = props.lockedTypeLabel
|
component.typeComposantLabel = props.lockedTypeLabel
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -14,13 +14,13 @@
|
|||||||
:search="searchInput"
|
:search="searchInput"
|
||||||
:sort="sort"
|
:sort="sort"
|
||||||
:dir="dir"
|
:dir="dir"
|
||||||
:loading="loading || saving"
|
:loading="loading"
|
||||||
:show-category-tabs="allowCategorySwitch"
|
:show-category-tabs="allowCategorySwitch"
|
||||||
@update:category="onCategoryChange"
|
@update:category="onCategoryChange"
|
||||||
@update:search="onSearchInput"
|
@update:search="onSearchInput"
|
||||||
@update:sort="onSortChange"
|
@update:sort="onSortChange"
|
||||||
@update:dir="onDirChange"
|
@update:dir="onDirChange"
|
||||||
@create="openCreateModal"
|
@create="openCreatePage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModelTypesTable
|
<ModelTypesTable
|
||||||
@@ -29,41 +29,24 @@
|
|||||||
:total="total"
|
:total="total"
|
||||||
:limit="limit"
|
:limit="limit"
|
||||||
:offset="offset"
|
:offset="offset"
|
||||||
@edit="openEditModal"
|
@edit="openEditPage"
|
||||||
@delete="confirmDelete"
|
@delete="confirmDelete"
|
||||||
@update:offset="onOffsetChange"
|
@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>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
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 ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||||
import EditModal from "~/components/model-types/EditModal.vue";
|
|
||||||
import {
|
import {
|
||||||
createModelType,
|
|
||||||
deleteModelType,
|
deleteModelType,
|
||||||
getModelType,
|
|
||||||
listModelTypes,
|
listModelTypes,
|
||||||
updateModelType,
|
|
||||||
type ModelCategory,
|
type ModelCategory,
|
||||||
type ModelType,
|
type ModelType,
|
||||||
type ModelTypeListResponse,
|
type ModelTypeListResponse,
|
||||||
type ModelTypePayload,
|
|
||||||
} from "~/services/modelTypes";
|
} from "~/services/modelTypes";
|
||||||
import { useToast } from "~/composables/useToast";
|
import { useToast } from "~/composables/useToast";
|
||||||
|
|
||||||
@@ -93,17 +76,11 @@ const offset = ref(0);
|
|||||||
const items = ref<ModelType[]>([]);
|
const items = ref<ModelType[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const loading = ref(false);
|
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 debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let activeController: AbortController | null = null;
|
let activeController: AbortController | null = null;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const { showError, showSuccess } = useToast();
|
const { showError, showSuccess } = useToast();
|
||||||
|
|
||||||
const headingText = computed(() => props.heading);
|
const headingText = computed(() => props.heading);
|
||||||
@@ -233,72 +210,22 @@ const onOffsetChange = (value: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const resolveCategoryBasePath = (category: ModelCategory) =>
|
||||||
modalMode.value = "create";
|
category === "COMPONENT" ? "/component-category" : "/piece-category";
|
||||||
modalInitialData.value = null;
|
|
||||||
editingId.value = null;
|
const openCreatePage = () => {
|
||||||
structureLoading.value = false;
|
const basePath = resolveCategoryBasePath(selectedCategory.value);
|
||||||
isModalOpen.value = true;
|
router.push(`${basePath}/new`).catch(() => {
|
||||||
|
showError("Navigation impossible vers la page de création.");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditModal = async (item: ModelType) => {
|
const openEditPage = (item: ModelType) => {
|
||||||
modalMode.value = "edit";
|
const category = item.category ?? selectedCategory.value;
|
||||||
editingId.value = item.id;
|
const basePath = resolveCategoryBasePath(category);
|
||||||
modalInitialData.value = {
|
router.push(`${basePath}/${item.id}/edit`).catch(() => {
|
||||||
name: item.name,
|
showError("Navigation impossible vers la page d'édition.");
|
||||||
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 confirmDelete = async (item: ModelType) => {
|
const confirmDelete = async (item: ModelType) => {
|
||||||
|
|||||||
306
app/components/model-types/ModelTypeForm.vue
Normal file
306
app/components/model-types/ModelTypeForm.vue
Normal 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>
|
||||||
124
app/pages/component-category/[id]/edit.vue
Normal file
124
app/pages/component-category/[id]/edit.vue
Normal 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>
|
||||||
68
app/pages/component-category/new.vue
Normal file
68
app/pages/component-category/new.vue
Normal 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>
|
||||||
122
app/pages/piece-category/[id]/edit.vue
Normal file
122
app/pages/piece-category/[id]/edit.vue
Normal 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>
|
||||||
68
app/pages/piece-category/new.vue
Normal file
68
app/pages/piece-category/new.vue
Normal 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>
|
||||||
@@ -210,16 +210,13 @@ const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[]
|
|||||||
export const normalizeStructureForSave = (input: any): ComponentModelStructure => {
|
export const normalizeStructureForSave = (input: any): ComponentModelStructure => {
|
||||||
const source = cloneStructure(input)
|
const source = cloneStructure(input)
|
||||||
|
|
||||||
return {
|
const result: ComponentModelStructure = {
|
||||||
customFields: sanitizeCustomFields(source.customFields),
|
customFields: sanitizeCustomFields(source.customFields),
|
||||||
pieces: sanitizePieces(source.pieces),
|
pieces: sanitizePieces(source.pieces),
|
||||||
subcomponents: sanitizeSubcomponents(source.subcomponents),
|
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[] => {
|
const hydrateCustomFields = (fields: any[]): any[] => {
|
||||||
|
|||||||
Reference in New Issue
Block a user