feat: Add model feature for piece and component
This commit is contained in:
18
app/app.vue
18
app/app.vue
@@ -53,6 +53,15 @@
|
|||||||
Modèles
|
Modèles
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/model-types"
|
||||||
|
class="rounded-md px-2 py-1 transition-colors"
|
||||||
|
:class="isActive('/model-types') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||||
|
>
|
||||||
|
Types de modèles
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/sites"
|
to="/sites"
|
||||||
@@ -138,6 +147,15 @@
|
|||||||
Modèles
|
Modèles
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/model-types"
|
||||||
|
class="transition-colors px-3 py-2 rounded-md"
|
||||||
|
:class="isActive('/model-types') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||||
|
>
|
||||||
|
Types de modèles
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/sites"
|
to="/sites"
|
||||||
|
|||||||
212
app/components/PieceModelStructureEditor.vue
Normal file
212
app/components/PieceModelStructureEditor.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<section class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold">Champs personnalisés</h3>
|
||||||
|
<button type="button" class="btn btn-outline btn-xs" @click="addField">
|
||||||
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!localFields.length" class="text-xs text-gray-500">
|
||||||
|
Aucun champ personnalisé n'a encore été défini.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(field, index) in localFields"
|
||||||
|
:key="`custom-field-${index}`"
|
||||||
|
class="border border-base-200 rounded-md p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
<input
|
||||||
|
v-model="field.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-xs"
|
||||||
|
placeholder="Nom du champ"
|
||||||
|
/>
|
||||||
|
<select v-model="field.type" class="select select-bordered select-xs">
|
||||||
|
<option value="text">Texte</option>
|
||||||
|
<option value="number">Nombre</option>
|
||||||
|
<option value="select">Liste</option>
|
||||||
|
<option value="boolean">Oui/Non</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
<label class="flex items-center gap-2 text-xs">
|
||||||
|
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
|
||||||
|
Obligatoire
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="field.defaultValue"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-xs"
|
||||||
|
placeholder="Valeur par défaut"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
v-if="field.type === 'select'"
|
||||||
|
v-model="field.optionsText"
|
||||||
|
class="textarea textarea-bordered textarea-xs h-20"
|
||||||
|
placeholder="Option 1 Option 2"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-xs btn-square"
|
||||||
|
@click="removeField(index)"
|
||||||
|
>
|
||||||
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
import IconLucideTrash from '~icons/lucide/trash'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ customFields: [] }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const ensureArray = (value) => (Array.isArray(value) ? value : [])
|
||||||
|
|
||||||
|
const clone = (input, fallback = {}) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(input ?? fallback))
|
||||||
|
} catch (error) {
|
||||||
|
return JSON.parse(JSON.stringify(fallback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractRest = (structure = {}) => {
|
||||||
|
if (!structure || typeof structure !== 'object') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(structure).filter(([key]) => key !== 'customFields')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toEditorField = (input = {}) => ({
|
||||||
|
name: typeof input.name === 'string' ? input.name : '',
|
||||||
|
type: typeof input.type === 'string' && input.type ? input.type : 'text',
|
||||||
|
required: Boolean(input.required),
|
||||||
|
defaultValue: typeof input.defaultValue === 'string' ? input.defaultValue : '',
|
||||||
|
optionsText: Array.isArray(input.options)
|
||||||
|
? input.options.join('\n')
|
||||||
|
: typeof input.optionsText === 'string'
|
||||||
|
? input.optionsText
|
||||||
|
: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const hydrateFields = (structure = {}) => ensureArray(structure.customFields).map(toEditorField)
|
||||||
|
|
||||||
|
const localState = reactive({
|
||||||
|
fields: hydrateFields(props.modelValue),
|
||||||
|
})
|
||||||
|
|
||||||
|
const extraState = reactive({
|
||||||
|
rest: clone(extractRest(props.modelValue)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const localFields = computed({
|
||||||
|
get: () => localState.fields,
|
||||||
|
set: (value) => {
|
||||||
|
localState.fields = ensureArray(value).map(toEditorField)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeFields = (fields) => {
|
||||||
|
return ensureArray(fields)
|
||||||
|
.map((field) => {
|
||||||
|
const name = typeof field.name === 'string' ? field.name.trim() : ''
|
||||||
|
if (!name) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = field.type || 'text'
|
||||||
|
const required = Boolean(field.required)
|
||||||
|
const defaultValue = typeof field.defaultValue === 'string'
|
||||||
|
? field.defaultValue.trim() || undefined
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let options
|
||||||
|
if (type === 'select') {
|
||||||
|
const raw = typeof field.optionsText === 'string' ? field.optionsText : ''
|
||||||
|
const parsed = raw
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((option) => option.trim())
|
||||||
|
.filter((option) => option.length > 0)
|
||||||
|
options = parsed.length > 0 ? parsed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = { name, type, required }
|
||||||
|
if (defaultValue !== undefined) {
|
||||||
|
normalized.defaultValue = defaultValue
|
||||||
|
}
|
||||||
|
if (options) {
|
||||||
|
normalized.options = options
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastEmitted = JSON.stringify({
|
||||||
|
...clone(extraState.rest, {}),
|
||||||
|
customFields: normalizeFields(props.modelValue?.customFields),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emitUpdate = () => {
|
||||||
|
const customFields = normalizeFields(localFields.value)
|
||||||
|
const payload = {
|
||||||
|
...clone(extraState.rest, {}),
|
||||||
|
customFields,
|
||||||
|
}
|
||||||
|
const serialized = JSON.stringify(payload)
|
||||||
|
if (serialized !== lastEmitted) {
|
||||||
|
lastEmitted = serialized
|
||||||
|
emit('update:modelValue', payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
localFields.value = hydrateFields(value)
|
||||||
|
extraState.rest = clone(extractRest(value), {})
|
||||||
|
lastEmitted = JSON.stringify({
|
||||||
|
...clone(extraState.rest, {}),
|
||||||
|
customFields: normalizeFields(value?.customFields),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(localFields, emitUpdate, { deep: true })
|
||||||
|
|
||||||
|
const addField = () => {
|
||||||
|
localFields.value = [...localFields.value, toEditorField()]
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeField = (index) => {
|
||||||
|
localFields.value = localFields.value.filter((_, i) => i !== index)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
212
app/components/model-types/EditModal.vue
Normal file
212
app/components/model-types/EditModal.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<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-5" @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
|
||||||
|
>
|
||||||
|
<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="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="close">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||||
|
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
{{ 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 type { ModelCategory, ModelTypePayload } from '~/services/modelTypes';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
initialCategory: ModelCategory;
|
||||||
|
initialData?: Partial<ModelTypePayload> | null;
|
||||||
|
saving?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'save', payload: ModelTypePayload): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = reactive<ModelTypePayload>({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
category: 'COMPONENT',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = reactive<{ name?: string; code?: string }>({});
|
||||||
|
|
||||||
|
const nameInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const codePattern = /^[a-z0-9\-_.]+$/i;
|
||||||
|
|
||||||
|
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 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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('save', {
|
||||||
|
name: form.name.trim(),
|
||||||
|
code: form.code.trim(),
|
||||||
|
category: form.category,
|
||||||
|
notes: form.notes?.trim() ? form.notes.trim() : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
resetForm();
|
||||||
|
document.addEventListener('keydown', onEscape);
|
||||||
|
nextTick(() => nameInput.value?.focus());
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('keydown', onEscape);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialData,
|
||||||
|
() => {
|
||||||
|
if (props.visible) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('keydown', onEscape);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
161
app/components/model-types/Table.vue
Normal file
161
app/components/model-types/Table.vue
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<section class="space-y-4" aria-live="polite">
|
||||||
|
<header class="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-base-content">Types de modèles</h2>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{{ totalLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="loading" class="text-sm text-info flex items-center gap-2">
|
||||||
|
<span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||||
|
Chargement en cours…
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading && items.length === 0" class="space-y-3" role="status" aria-live="polite">
|
||||||
|
<div
|
||||||
|
v-for="index in 3"
|
||||||
|
:key="index"
|
||||||
|
class="rounded-xl border border-base-200 bg-base-200/70 animate-pulse h-24"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="items.length === 0" class="rounded-xl border border-dashed border-base-200 p-10 text-center">
|
||||||
|
<IconLucideInbox class="mx-auto h-12 w-12 text-base-content/30" aria-hidden="true" />
|
||||||
|
<h3 class="mt-4 text-lg font-medium text-base-content">Aucun type trouvé</h3>
|
||||||
|
<p class="text-sm text-base-content/70">Ajustez votre recherche ou créez un nouveau type.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<div class="hidden sm:block overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-base-content/70">
|
||||||
|
<th scope="col">Nom</th>
|
||||||
|
<th scope="col">Code</th>
|
||||||
|
<th scope="col">Catégorie</th>
|
||||||
|
<th scope="col">Notes</th>
|
||||||
|
<th scope="col" class="w-32 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in items" :key="item.id">
|
||||||
|
<td class="font-medium">{{ item.name }}</td>
|
||||||
|
<td><code class="badge badge-neutral badge-sm">{{ item.code }}</code></td>
|
||||||
|
<td>{{ categoryLabel(item.category) }}</td>
|
||||||
|
<td class="max-w-xs align-middle">
|
||||||
|
<span v-if="item.notes" class="block text-sm text-base-content/80 break-words">{{ item.notes }}</span>
|
||||||
|
<span v-else class="text-base-content/50">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right space-x-2">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
|
Éditer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:hidden">
|
||||||
|
<article
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="rounded-xl border border-base-200 bg-base-100 p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<header class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-base-content">{{ item.name }}</h3>
|
||||||
|
<p class="text-sm text-base-content/60">{{ categoryLabel(item.category) }}</p>
|
||||||
|
</div>
|
||||||
|
<code class="badge badge-neutral">{{ item.code }}</code>
|
||||||
|
</header>
|
||||||
|
<p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p>
|
||||||
|
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
|
||||||
|
<footer class="mt-4 flex flex-wrap items-center gap-2 justify-end">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
|
Éditer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex flex-wrap items-center justify-between gap-3" aria-label="Pagination">
|
||||||
|
<span class="text-sm text-base-content/70">
|
||||||
|
Page {{ currentPage }} sur {{ totalPages }}
|
||||||
|
</span>
|
||||||
|
<div class="join">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-sm join-item"
|
||||||
|
:disabled="!canGoPrevious"
|
||||||
|
@click="emit('update:offset', Math.max(0, offset - limit))"
|
||||||
|
>
|
||||||
|
Précédent
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-sm join-item"
|
||||||
|
:disabled="!canGoNext"
|
||||||
|
@click="emit('update:offset', offset + limit)"
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import IconLucideInbox from '~icons/lucide/inbox';
|
||||||
|
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: ModelType[];
|
||||||
|
loading: boolean;
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'edit', item: ModelType): void;
|
||||||
|
(e: 'delete', item: ModelType): void;
|
||||||
|
(e: 'update:offset', offset: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const categoryDictionary: Record<ModelCategory, string> = {
|
||||||
|
COMPONENT: 'Composants',
|
||||||
|
PIECE: 'Pièces',
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category;
|
||||||
|
|
||||||
|
const currentPage = computed(() => {
|
||||||
|
if (props.limit <= 0) return 1;
|
||||||
|
return Math.floor(props.offset / props.limit) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
if (props.limit <= 0) return 1;
|
||||||
|
return Math.max(1, Math.ceil(props.total / props.limit));
|
||||||
|
});
|
||||||
|
|
||||||
|
const canGoPrevious = computed(() => props.offset > 0);
|
||||||
|
const canGoNext = computed(() => props.offset + props.limit < props.total);
|
||||||
|
|
||||||
|
const totalLabel = computed(() => {
|
||||||
|
if (props.total === 0) return 'Aucun résultat';
|
||||||
|
if (props.total === 1) return '1 type trouvé';
|
||||||
|
return `${props.total} types trouvés`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
107
app/components/model-types/Toolbar.vue
Normal file
107
app/components/model-types/Toolbar.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<section class="space-y-4">
|
||||||
|
<nav class="tabs tabs-boxed inline-flex" role="tablist" aria-label="Catégories">
|
||||||
|
<button
|
||||||
|
v-for="option in categories"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': option.value === category }"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="option.value === category"
|
||||||
|
@click="emit('update:category', option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<label class="input input-bordered flex items-center gap-2 w-full sm:w-72" :aria-busy="loading">
|
||||||
|
<IconLucideSearch class="w-4 h-4" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
:value="search"
|
||||||
|
type="search"
|
||||||
|
class="grow min-w-0"
|
||||||
|
placeholder="Rechercher par nom ou code…"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="onSearch"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium text-base-content/70" for="model-type-sort">Trier par</label>
|
||||||
|
<select
|
||||||
|
id="model-type-sort"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
:value="sort"
|
||||||
|
@change="emit('update:sort', ($event.target as HTMLSelectElement).value as SortField)"
|
||||||
|
>
|
||||||
|
<option value="name">Nom</option>
|
||||||
|
<option value="code">Code</option>
|
||||||
|
<option value="createdAt">Date de création</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium text-base-content/70" for="model-type-dir">Ordre</label>
|
||||||
|
<select
|
||||||
|
id="model-type-dir"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
:value="dir"
|
||||||
|
@change="emit('update:dir', ($event.target as HTMLSelectElement).value as SortDirection)"
|
||||||
|
>
|
||||||
|
<option value="asc">Ascendant</option>
|
||||||
|
<option value="desc">Descendant</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary self-start"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="emit('create')"
|
||||||
|
>
|
||||||
|
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
|
||||||
|
Créer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus';
|
||||||
|
import IconLucideSearch from '~icons/lucide/search';
|
||||||
|
|
||||||
|
type SortField = 'name' | 'code' | 'createdAt';
|
||||||
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
category: 'COMPONENT' | 'PIECE';
|
||||||
|
search: string;
|
||||||
|
sort: SortField;
|
||||||
|
dir: SortDirection;
|
||||||
|
loading?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:category', value: 'COMPONENT' | 'PIECE'): void;
|
||||||
|
(e: 'update:search', value: string): void;
|
||||||
|
(e: 'update:sort', value: SortField): void;
|
||||||
|
(e: 'update:dir', value: SortDirection): void;
|
||||||
|
(e: 'create'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const categories: Array<{ label: string; value: 'COMPONENT' | 'PIECE' }> = [
|
||||||
|
{ label: 'Composants', value: 'COMPONENT' },
|
||||||
|
{ label: 'Pièces', value: 'PIECE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const onSearch = (event: Event) => {
|
||||||
|
emit('update:search', (event.target as HTMLInputElement).value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = computed(() => props.loading ?? false);
|
||||||
|
</script>
|
||||||
@@ -1,25 +1,43 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from './useApi'
|
import { listModelTypes, createModelType, updateModelType, deleteModelType } from '~/services/modelTypes'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
const componentTypes = ref([])
|
const componentTypes = ref([])
|
||||||
const loadingComponentTypes = ref(false)
|
const loadingComponentTypes = ref(false)
|
||||||
|
|
||||||
export function useComponentTypes() {
|
export function useComponentTypes() {
|
||||||
const { get, post, patch, delete: del } = useApi()
|
|
||||||
const { showSuccess, showError } = useToast()
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
|
const generateCodeFromName = (name) => {
|
||||||
|
return (name || '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-+/g, '-') || 'type'
|
||||||
|
}
|
||||||
|
|
||||||
const loadComponentTypes = async () => {
|
const loadComponentTypes = async () => {
|
||||||
loadingComponentTypes.value = true
|
loadingComponentTypes.value = true
|
||||||
try {
|
try {
|
||||||
const result = await get('/types/composants')
|
const data = await listModelTypes({
|
||||||
if (result.success) {
|
category: 'COMPONENT',
|
||||||
componentTypes.value = result.data
|
sort: 'name',
|
||||||
}
|
dir: 'asc',
|
||||||
return result
|
limit: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
componentTypes.value = data.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
description: item.description ?? item.notes ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { success: true, data: componentTypes.value }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Impossible de charger les types de composant: ${error.message}`)
|
const message = error?.message || 'Erreur inconnue'
|
||||||
return { success: false, error: error.message }
|
showError(`Impossible de charger les types de composant: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loadingComponentTypes.value = false
|
loadingComponentTypes.value = false
|
||||||
}
|
}
|
||||||
@@ -28,15 +46,27 @@ export function useComponentTypes() {
|
|||||||
const createComponentType = async (payload) => {
|
const createComponentType = async (payload) => {
|
||||||
loadingComponentTypes.value = true
|
loadingComponentTypes.value = true
|
||||||
try {
|
try {
|
||||||
const result = await post('/types/composants', payload)
|
const data = await createModelType({
|
||||||
if (result.success) {
|
name: payload.name,
|
||||||
componentTypes.value.push(result.data)
|
code: payload.code || generateCodeFromName(payload.name),
|
||||||
showSuccess(`Type de composant "${result.data.name}" créé`)
|
category: 'COMPONENT',
|
||||||
|
notes: payload.description ?? payload.notes,
|
||||||
|
description: payload.description ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalized = {
|
||||||
|
...data,
|
||||||
|
description: data.description ?? data.notes ?? null,
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
|
componentTypes.value.push(normalized)
|
||||||
|
showSuccess(`Type de composant "${data.name}" créé`)
|
||||||
|
|
||||||
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la création du type de composant: ${error.message}`)
|
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||||
return { success: false, error: error.message }
|
showError(`Erreur lors de la création du type de composant: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loadingComponentTypes.value = false
|
loadingComponentTypes.value = false
|
||||||
}
|
}
|
||||||
@@ -45,18 +75,32 @@ export function useComponentTypes() {
|
|||||||
const updateComponentType = async (id, payload) => {
|
const updateComponentType = async (id, payload) => {
|
||||||
loadingComponentTypes.value = true
|
loadingComponentTypes.value = true
|
||||||
try {
|
try {
|
||||||
const result = await patch(`/types/composants/${id}`, payload)
|
const data = await updateModelType(id, {
|
||||||
if (result.success) {
|
name: payload.name,
|
||||||
const index = componentTypes.value.findIndex((type) => type.id === id)
|
description: payload.description,
|
||||||
if (index !== -1) {
|
notes: payload.notes,
|
||||||
componentTypes.value[index] = result.data
|
code: payload.code,
|
||||||
}
|
})
|
||||||
showSuccess(`Type de composant "${result.data.name}" mis à jour`)
|
|
||||||
|
const normalized = {
|
||||||
|
...data,
|
||||||
|
description: data.description ?? data.notes ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = componentTypes.value.findIndex((type) => type.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
componentTypes.value[index] = normalized
|
||||||
|
}
|
||||||
|
showSuccess(`Type de composant "${data.name}" mis à jour`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: normalized,
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la mise à jour du type de composant: ${error.message}`)
|
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||||
return { success: false, error: error.message }
|
showError(`Erreur lors de la mise à jour du type de composant: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loadingComponentTypes.value = false
|
loadingComponentTypes.value = false
|
||||||
}
|
}
|
||||||
@@ -65,15 +109,14 @@ export function useComponentTypes() {
|
|||||||
const deleteComponentType = async (id) => {
|
const deleteComponentType = async (id) => {
|
||||||
loadingComponentTypes.value = true
|
loadingComponentTypes.value = true
|
||||||
try {
|
try {
|
||||||
const result = await del(`/types/composants/${id}`)
|
await deleteModelType(id)
|
||||||
if (result.success) {
|
componentTypes.value = componentTypes.value.filter((type) => type.id !== id)
|
||||||
componentTypes.value = componentTypes.value.filter((type) => type.id !== id)
|
showSuccess('Type de composant supprimé')
|
||||||
showSuccess('Type de composant supprimé')
|
return { success: true }
|
||||||
}
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la suppression du type de composant: ${error.message}`)
|
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||||
return { success: false, error: error.message }
|
showError(`Erreur lors de la suppression du type de composant: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loadingComponentTypes.value = false
|
loadingComponentTypes.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from './useApi'
|
import { listModelTypes, createModelType, updateModelType, deleteModelType } from '~/services/modelTypes'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
const pieceTypes = ref([])
|
const pieceTypes = ref([])
|
||||||
const loadingPieceTypes = ref(false)
|
const loadingPieceTypes = ref(false)
|
||||||
|
|
||||||
export function usePieceTypes() {
|
export function usePieceTypes() {
|
||||||
const { get, post, patch, delete: del } = useApi()
|
|
||||||
const { showSuccess, showError } = useToast()
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
|
const generateCodeFromName = (name) => {
|
||||||
|
return (name || '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-+/g, '-') || 'type'
|
||||||
|
}
|
||||||
|
|
||||||
const loadPieceTypes = async () => {
|
const loadPieceTypes = async () => {
|
||||||
loadingPieceTypes.value = true
|
loadingPieceTypes.value = true
|
||||||
try {
|
try {
|
||||||
const result = await get('/types/pieces')
|
const data = await listModelTypes({
|
||||||
if (result.success) {
|
category: 'PIECE',
|
||||||
pieceTypes.value = result.data
|
sort: 'name',
|
||||||
}
|
dir: 'asc',
|
||||||
return result
|
limit: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
pieceTypes.value = data.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
description: item.description ?? item.notes ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { success: true, data: pieceTypes.value }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Impossible de charger les types de pièce: ${error.message}`)
|
const message = error?.message || 'Erreur inconnue'
|
||||||
return { success: false, error: error.message }
|
showError(`Impossible de charger les types de pièce: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loadingPieceTypes.value = false
|
loadingPieceTypes.value = false
|
||||||
}
|
}
|
||||||
@@ -28,15 +46,27 @@ export function usePieceTypes() {
|
|||||||
const createPieceType = async (payload) => {
|
const createPieceType = async (payload) => {
|
||||||
loadingPieceTypes.value = true
|
loadingPieceTypes.value = true
|
||||||
try {
|
try {
|
||||||
const result = await post('/types/pieces', payload)
|
const data = await createModelType({
|
||||||
if (result.success) {
|
name: payload.name,
|
||||||
pieceTypes.value.push(result.data)
|
code: payload.code || generateCodeFromName(payload.name),
|
||||||
showSuccess(`Type de pièce "${result.data.name}" créé`)
|
category: 'PIECE',
|
||||||
|
notes: payload.description ?? payload.notes,
|
||||||
|
description: payload.description ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalized = {
|
||||||
|
...data,
|
||||||
|
description: data.description ?? data.notes ?? null,
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
|
pieceTypes.value.push(normalized)
|
||||||
|
showSuccess(`Type de pièce "${data.name}" créé`)
|
||||||
|
|
||||||
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la création du type de pièce: ${error.message}`)
|
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||||
return { success: false, error: error.message }
|
showError(`Erreur lors de la création du type de pièce: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loadingPieceTypes.value = false
|
loadingPieceTypes.value = false
|
||||||
}
|
}
|
||||||
@@ -45,18 +75,32 @@ export function usePieceTypes() {
|
|||||||
const updatePieceType = async (id, payload) => {
|
const updatePieceType = async (id, payload) => {
|
||||||
loadingPieceTypes.value = true
|
loadingPieceTypes.value = true
|
||||||
try {
|
try {
|
||||||
const result = await patch(`/types/pieces/${id}`, payload)
|
const data = await updateModelType(id, {
|
||||||
if (result.success) {
|
name: payload.name,
|
||||||
const index = pieceTypes.value.findIndex((type) => type.id === id)
|
description: payload.description,
|
||||||
if (index !== -1) {
|
notes: payload.notes,
|
||||||
pieceTypes.value[index] = result.data
|
code: payload.code,
|
||||||
}
|
})
|
||||||
showSuccess(`Type de pièce "${result.data.name}" mis à jour`)
|
|
||||||
|
const normalized = {
|
||||||
|
...data,
|
||||||
|
description: data.description ?? data.notes ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = pieceTypes.value.findIndex((type) => type.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
pieceTypes.value[index] = normalized
|
||||||
|
}
|
||||||
|
showSuccess(`Type de pièce "${data.name}" mis à jour`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: normalized,
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la mise à jour du type de pièce: ${error.message}`)
|
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||||
return { success: false, error: error.message }
|
showError(`Erreur lors de la mise à jour du type de pièce: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loadingPieceTypes.value = false
|
loadingPieceTypes.value = false
|
||||||
}
|
}
|
||||||
@@ -65,15 +109,14 @@ export function usePieceTypes() {
|
|||||||
const deletePieceType = async (id) => {
|
const deletePieceType = async (id) => {
|
||||||
loadingPieceTypes.value = true
|
loadingPieceTypes.value = true
|
||||||
try {
|
try {
|
||||||
const result = await del(`/types/pieces/${id}`)
|
await deleteModelType(id)
|
||||||
if (result.success) {
|
pieceTypes.value = pieceTypes.value.filter((type) => type.id !== id)
|
||||||
pieceTypes.value = pieceTypes.value.filter((type) => type.id !== id)
|
showSuccess('Type de pièce supprimé')
|
||||||
showSuccess('Type de pièce supprimé')
|
return { success: true }
|
||||||
}
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la suppression du type de pièce: ${error.message}`)
|
const message = error?.data?.message || error?.message || 'Erreur inconnue'
|
||||||
return { success: false, error: error.message }
|
showError(`Erreur lors de la suppression du type de pièce: ${message}`)
|
||||||
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loadingPieceTypes.value = false
|
loadingPieceTypes.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,30 +295,8 @@
|
|||||||
:key="`${requirement.id}-piece-${entryIndex}`"
|
:key="`${requirement.id}-piece-${entryIndex}`"
|
||||||
class="bg-base-200/60 rounded-md p-3 space-y-3"
|
class="bg-base-200/60 rounded-md p-3 space-y-3"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<label class="inline-flex items-center gap-1">
|
<div class="form-control">
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
class="radio radio-xs"
|
|
||||||
:checked="entry.mode === 'model'"
|
|
||||||
@change="setPieceSelectionMode(requirement.id, entryIndex, 'model')"
|
|
||||||
/>
|
|
||||||
Modèle existant
|
|
||||||
</label>
|
|
||||||
<label class="inline-flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
class="radio radio-xs"
|
|
||||||
:checked="entry.mode === 'manual'"
|
|
||||||
@change="setPieceSelectionMode(requirement.id, entryIndex, 'manual')"
|
|
||||||
:disabled="!requirement.allowNewModels"
|
|
||||||
/>
|
|
||||||
Définir manuellement
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="entry.mode === 'model'" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs">Modèle de pièce</span>
|
<span class="label-text text-xs">Modèle de pièce</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -343,32 +321,12 @@
|
|||||||
>
|
>
|
||||||
Aucun modèle disponible pour ce type.
|
Aucun modèle disponible pour ce type.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p
|
||||||
</div>
|
v-else-if="!entry.pieceModelId && entry.legacyName"
|
||||||
|
class="text-[10px] text-warning mt-1"
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
>
|
||||||
<div class="form-control">
|
Ancienne pièce : {{ entry.legacyName }} — sélectionner un modèle.
|
||||||
<label class="label">
|
</p>
|
||||||
<span class="label-text text-xs">Nom de la pièce</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm"
|
|
||||||
:value="entry.name"
|
|
||||||
placeholder="Nom de la pièce"
|
|
||||||
@input="updatePieceSelectionEntry(requirement.id, entryIndex, { name: $event.target.value })"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs">Référence (optionnel)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm"
|
|
||||||
placeholder="(Non géré pour l'instant)"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1055,9 +1013,7 @@ const createComponentSelectionEntry = () => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createPieceSelectionEntry = () => ({
|
const createPieceSelectionEntry = () => ({
|
||||||
mode: 'model',
|
|
||||||
pieceModelId: '',
|
pieceModelId: '',
|
||||||
name: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const resetSkeletonRequirementSelections = () => {
|
const resetSkeletonRequirementSelections = () => {
|
||||||
@@ -1121,24 +1077,20 @@ const removePieceSelectionEntry = (requirementId, index) => {
|
|||||||
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setPieceSelectionMode = (requirementId, index, mode) => {
|
const updatePieceSelectionEntry = (requirementId, index, patch) => {
|
||||||
const entries = getPieceRequirementEntries(requirementId)
|
const entries = getPieceRequirementEntries(requirementId)
|
||||||
pieceRequirementSelections[requirementId] = entries.map((entry, i) => {
|
pieceRequirementSelections[requirementId] = entries.map((entry, i) => {
|
||||||
if (i !== index) return entry
|
if (i !== index) return entry
|
||||||
if (mode === 'model') {
|
const updated = { ...entry, ...patch }
|
||||||
return { ...entry, mode: 'model', pieceModelId: entry.pieceModelId || '', name: '' }
|
if (Object.prototype.hasOwnProperty.call(patch, 'pieceModelId')) {
|
||||||
|
if (patch.pieceModelId) {
|
||||||
|
delete updated.legacyName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { ...entry, mode: 'manual', pieceModelId: '', name: entry.name || '' }
|
return updated
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePieceSelectionEntry = (requirementId, index, patch) => {
|
|
||||||
const entries = getPieceRequirementEntries(requirementId)
|
|
||||||
pieceRequirementSelections[requirementId] = entries.map((entry, i) =>
|
|
||||||
i === index ? { ...entry, ...patch } : entry
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const collectPiecesForSkeleton = () => {
|
const collectPiecesForSkeleton = () => {
|
||||||
const aggregated = []
|
const aggregated = []
|
||||||
machinePieces.value.forEach((piece) => {
|
machinePieces.value.forEach((piece) => {
|
||||||
@@ -1195,9 +1147,12 @@ const initializeSkeletonRequirementSelections = async () => {
|
|||||||
const entries = existingPieces.map((piece) => {
|
const entries = existingPieces.map((piece) => {
|
||||||
const modelId = piece.pieceModelId || piece.pieceModel?.id || null
|
const modelId = piece.pieceModelId || piece.pieceModel?.id || null
|
||||||
if (modelId) {
|
if (modelId) {
|
||||||
return { mode: 'model', pieceModelId: modelId, name: '' }
|
return { pieceModelId: modelId }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pieceModelId: '',
|
||||||
|
legacyName: piece.name || piece.reference || '',
|
||||||
}
|
}
|
||||||
return { mode: 'manual', pieceModelId: '', name: piece.name || '' }
|
|
||||||
})
|
})
|
||||||
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
|
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
|
||||||
while (entries.length < min) {
|
while (entries.length < min) {
|
||||||
@@ -1287,12 +1242,7 @@ const validateSkeletonSelections = (type) => {
|
|||||||
|
|
||||||
for (const requirement of type.pieceRequirements || []) {
|
for (const requirement of type.pieceRequirements || []) {
|
||||||
const entries = getPieceRequirementEntries(requirement.id)
|
const entries = getPieceRequirementEntries(requirement.id)
|
||||||
const usableEntries = entries.filter((entry) => {
|
const usableEntries = entries.filter((entry) => !!entry.pieceModelId)
|
||||||
if (entry.mode === 'model') {
|
|
||||||
return !!entry.pieceModelId
|
|
||||||
}
|
|
||||||
return !!entry.name && entry.name.trim().length > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
|
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
|
||||||
const max = requirement.maxCount ?? null
|
const max = requirement.maxCount ?? null
|
||||||
@@ -1309,26 +1259,11 @@ const validateSkeletonSelections = (type) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!requirement.allowNewModels && usableEntries.some((entry) => entry.mode === 'manual')) {
|
|
||||||
errors.push(
|
|
||||||
`Le groupe "${requirement.label || requirement.typePiece?.name || 'Pièces'}" n'autorise que les modèles existants.`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
usableEntries.forEach((entry) => {
|
usableEntries.forEach((entry) => {
|
||||||
if (entry.mode === 'model') {
|
pieceSelectionsPayload.push({
|
||||||
pieceSelectionsPayload.push({
|
requirementId: requirement.id,
|
||||||
requirementId: requirement.id,
|
pieceModelId: entry.pieceModelId,
|
||||||
pieceModelId: entry.pieceModelId,
|
})
|
||||||
})
|
|
||||||
} else {
|
|
||||||
pieceSelectionsPayload.push({
|
|
||||||
requirementId: requirement.id,
|
|
||||||
definition: {
|
|
||||||
name: entry.name.trim(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -293,29 +293,7 @@
|
|||||||
:key="`${requirement.id}-piece-${entryIndex}`"
|
:key="`${requirement.id}-piece-${entryIndex}`"
|
||||||
class="bg-base-200/60 rounded-md p-3 space-y-3"
|
class="bg-base-200/60 rounded-md p-3 space-y-3"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<label class="inline-flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
class="radio radio-xs"
|
|
||||||
:checked="entry.mode === 'model'"
|
|
||||||
@change="setPieceSelectionMode(requirement.id, entryIndex, 'model')"
|
|
||||||
/>
|
|
||||||
Modèle existant
|
|
||||||
</label>
|
|
||||||
<label class="inline-flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
class="radio radio-xs"
|
|
||||||
:checked="entry.mode === 'manual'"
|
|
||||||
@change="setPieceSelectionMode(requirement.id, entryIndex, 'manual')"
|
|
||||||
:disabled="!requirement.allowNewModels"
|
|
||||||
/>
|
|
||||||
Définir manuellement
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="entry.mode === 'model'" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs">Modèle de pièce</span>
|
<span class="label-text text-xs">Modèle de pièce</span>
|
||||||
@@ -344,32 +322,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs">Nom de la pièce</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm"
|
|
||||||
:value="entry.name"
|
|
||||||
placeholder="Nom de la pièce"
|
|
||||||
@input="updatePieceSelectionEntry(requirement.id, entryIndex, { name: $event.target.value })"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs">Référence (optionnel)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm"
|
|
||||||
placeholder="(Non géré pour l'instant)"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -482,7 +434,6 @@
|
|||||||
</p>
|
</p>
|
||||||
<p v-if="entry.subtitle" class="text-xs text-gray-500">{{ entry.subtitle }}</p>
|
<p v-if="entry.subtitle" class="text-xs text-gray-500">{{ entry.subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="entry.mode === 'manual'" class="badge badge-ghost badge-xs">manuel</span>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -903,23 +854,12 @@ const machinePreview = computed(() => {
|
|||||||
const entriesSource = getPieceRequirementEntries(requirement.id)
|
const entriesSource = getPieceRequirementEntries(requirement.id)
|
||||||
const entriesList = entriesSource ? [...entriesSource] : []
|
const entriesList = entriesSource ? [...entriesSource] : []
|
||||||
const normalizedEntries = entriesList.map((entry, index) => {
|
const normalizedEntries = entriesList.map((entry, index) => {
|
||||||
if (entry.mode === 'model') {
|
const model = resolvePieceModel(requirement, entry.pieceModelId)
|
||||||
const model = resolvePieceModel(requirement, entry.pieceModelId)
|
|
||||||
return {
|
|
||||||
key: `${requirement.id}-${index}`,
|
|
||||||
mode: 'model',
|
|
||||||
status: model ? 'complete' : 'pending',
|
|
||||||
title: model ? model.name : 'Sélectionner un modèle',
|
|
||||||
subtitle: model?.description || null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const manualName = (entry.name || '').trim()
|
|
||||||
return {
|
return {
|
||||||
key: `${requirement.id}-${index}`,
|
key: `${requirement.id}-${index}`,
|
||||||
mode: 'manual',
|
status: model ? 'complete' : 'pending',
|
||||||
status: manualName ? 'complete' : 'pending',
|
title: model ? model.name : 'Sélectionner un modèle',
|
||||||
title: manualName || 'Nom à renseigner',
|
subtitle: model?.description || null,
|
||||||
subtitle: manualName ? null : null,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -936,10 +876,6 @@ const machinePreview = computed(() => {
|
|||||||
issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `piece-group-${requirement.id}` })
|
issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `piece-group-${requirement.id}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!requirement.allowNewModels && normalizedEntries.some((entry) => entry.mode === 'manual' && entry.status === 'complete')) {
|
|
||||||
issues.push({ message: "Ce groupe n'autorise que les modèles existants.", kind: 'error', anchor: `piece-group-${requirement.id}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedEntries.some((entry) => entry.status !== 'complete')) {
|
if (normalizedEntries.some((entry) => entry.status !== 'complete')) {
|
||||||
issues.push({ message: 'Compléter les sélections restantes.', kind: 'warning', anchor: `piece-group-${requirement.id}` })
|
issues.push({ message: 'Compléter les sélections restantes.', kind: 'warning', anchor: `piece-group-${requirement.id}` })
|
||||||
}
|
}
|
||||||
@@ -1056,9 +992,7 @@ const createComponentSelectionEntry = () => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createPieceSelectionEntry = () => ({
|
const createPieceSelectionEntry = () => ({
|
||||||
mode: 'model',
|
|
||||||
pieceModelId: '',
|
pieceModelId: '',
|
||||||
name: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const addComponentSelectionEntry = (requirement) => {
|
const addComponentSelectionEntry = (requirement) => {
|
||||||
@@ -1109,17 +1043,6 @@ const removePieceSelectionEntry = (requirementId, index) => {
|
|||||||
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setPieceSelectionMode = (requirementId, index, mode) => {
|
|
||||||
const entries = getPieceRequirementEntries(requirementId)
|
|
||||||
pieceRequirementSelections[requirementId] = entries.map((entry, i) => {
|
|
||||||
if (i !== index) return entry
|
|
||||||
if (mode === 'model') {
|
|
||||||
return { ...entry, mode: 'model', pieceModelId: entry.pieceModelId || '', name: '' }
|
|
||||||
}
|
|
||||||
return { ...entry, mode: 'manual', pieceModelId: '', name: entry.name || '' }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePieceSelectionEntry = (requirementId, index, patch) => {
|
const updatePieceSelectionEntry = (requirementId, index, patch) => {
|
||||||
const entries = getPieceRequirementEntries(requirementId)
|
const entries = getPieceRequirementEntries(requirementId)
|
||||||
pieceRequirementSelections[requirementId] = entries.map((entry, i) =>
|
pieceRequirementSelections[requirementId] = entries.map((entry, i) =>
|
||||||
@@ -1175,12 +1098,7 @@ const validateRequirementSelections = (type) => {
|
|||||||
|
|
||||||
for (const requirement of type.pieceRequirements || []) {
|
for (const requirement of type.pieceRequirements || []) {
|
||||||
const entries = getPieceRequirementEntries(requirement.id)
|
const entries = getPieceRequirementEntries(requirement.id)
|
||||||
const usableEntries = entries.filter((entry) => {
|
const usableEntries = entries.filter((entry) => !!entry.pieceModelId)
|
||||||
if (entry.mode === 'model') {
|
|
||||||
return !!entry.pieceModelId
|
|
||||||
}
|
|
||||||
return !!entry.name && entry.name.trim().length > 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
|
const min = requirement.minCount ?? (requirement.required ? 1 : 0)
|
||||||
const max = requirement.maxCount ?? null
|
const max = requirement.maxCount ?? null
|
||||||
@@ -1193,24 +1111,11 @@ const validateRequirementSelections = (type) => {
|
|||||||
errors.push(`Le groupe "${requirement.label || requirement.typePiece?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
|
errors.push(`Le groupe "${requirement.label || requirement.typePiece?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!requirement.allowNewModels && usableEntries.some((entry) => entry.mode === 'manual')) {
|
|
||||||
errors.push(`Le groupe "${requirement.label || requirement.typePiece?.name || 'Pièces'}" n'autorise que les modèles existants.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
usableEntries.forEach((entry) => {
|
usableEntries.forEach((entry) => {
|
||||||
if (entry.mode === 'model') {
|
pieceSelectionsPayload.push({
|
||||||
pieceSelectionsPayload.push({
|
requirementId: requirement.id,
|
||||||
requirementId: requirement.id,
|
pieceModelId: entry.pieceModelId,
|
||||||
pieceModelId: entry.pieceModelId,
|
})
|
||||||
})
|
|
||||||
} else {
|
|
||||||
pieceSelectionsPayload.push({
|
|
||||||
requirementId: requirement.id,
|
|
||||||
definition: {
|
|
||||||
name: entry.name.trim(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1322,10 +1227,9 @@ const submitCreatePieceModel = async () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
await loadPieceModels(createPieceModelModal.requirement.typePieceId)
|
await loadPieceModels(createPieceModelModal.requirement.typePieceId)
|
||||||
const entries = getPieceRequirementEntries(createPieceModelModal.requirement.id)
|
const entries = getPieceRequirementEntries(createPieceModelModal.requirement.id)
|
||||||
const targetIndex = entries.findIndex((entry) => entry.mode === 'model' && !entry.pieceModelId)
|
const targetIndex = entries.findIndex((entry) => !entry.pieceModelId)
|
||||||
if (targetIndex !== -1) {
|
if (targetIndex !== -1) {
|
||||||
updatePieceSelectionEntry(createPieceModelModal.requirement.id, targetIndex, {
|
updatePieceSelectionEntry(createPieceModelModal.requirement.id, targetIndex, {
|
||||||
mode: 'model',
|
|
||||||
pieceModelId: result.data.id,
|
pieceModelId: result.data.id,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -1334,7 +1238,6 @@ const submitCreatePieceModel = async () => {
|
|||||||
createPieceModelModal.requirement.id,
|
createPieceModelModal.requirement.id,
|
||||||
getPieceRequirementEntries(createPieceModelModal.requirement.id).length - 1,
|
getPieceRequirementEntries(createPieceModelModal.requirement.id).length - 1,
|
||||||
{
|
{
|
||||||
mode: 'model',
|
|
||||||
pieceModelId: result.data.id,
|
pieceModelId: result.data.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
284
app/pages/model-types.vue
Normal file
284
app/pages/model-types.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<main class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<header class="space-y-2">
|
||||||
|
<p class="text-sm uppercase tracking-wide text-primary">Administration</p>
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">Types de modèles</h1>
|
||||||
|
<p class="text-base text-base-content/70">
|
||||||
|
Gérez les types de modèles pour les composants et les pièces. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ModelTypesToolbar
|
||||||
|
:category="category"
|
||||||
|
:search="searchInput"
|
||||||
|
:sort="sort"
|
||||||
|
:dir="dir"
|
||||||
|
:loading="loading || saving"
|
||||||
|
@update:category="onCategoryChange"
|
||||||
|
@update:search="onSearchInput"
|
||||||
|
@update:sort="onSortChange"
|
||||||
|
@update:dir="onDirChange"
|
||||||
|
@create="openCreateModal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModelTypesTable
|
||||||
|
:items="items"
|
||||||
|
:loading="loading"
|
||||||
|
:total="total"
|
||||||
|
:limit="limit"
|
||||||
|
:offset="offset"
|
||||||
|
@edit="openEditModal"
|
||||||
|
@delete="confirmDelete"
|
||||||
|
@update:offset="onOffsetChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditModal
|
||||||
|
:visible="isModalOpen"
|
||||||
|
:mode="modalMode"
|
||||||
|
:initial-category="category"
|
||||||
|
:initial-data="modalInitialData"
|
||||||
|
:saving="saving"
|
||||||
|
@close="closeModal"
|
||||||
|
@save="handleSave"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useHead } from '#imports';
|
||||||
|
import ModelTypesToolbar from '~/components/model-types/Toolbar.vue';
|
||||||
|
import ModelTypesTable from '~/components/model-types/Table.vue';
|
||||||
|
import EditModal from '~/components/model-types/EditModal.vue';
|
||||||
|
import {
|
||||||
|
createModelType,
|
||||||
|
deleteModelType,
|
||||||
|
listModelTypes,
|
||||||
|
updateModelType,
|
||||||
|
type ModelCategory,
|
||||||
|
type ModelType,
|
||||||
|
type ModelTypeListResponse,
|
||||||
|
type ModelTypePayload,
|
||||||
|
} from '~/services/modelTypes';
|
||||||
|
import { useToast } from '~/composables/useToast';
|
||||||
|
|
||||||
|
const category = ref<ModelCategory>('COMPONENT');
|
||||||
|
const searchInput = ref('');
|
||||||
|
const searchTerm = ref('');
|
||||||
|
const sort = ref<'name' | 'code' | 'createdAt'>('createdAt');
|
||||||
|
const dir = ref<'asc' | 'desc'>('desc');
|
||||||
|
const limit = ref(20);
|
||||||
|
const offset = ref(0);
|
||||||
|
|
||||||
|
const items = ref<ModelType[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const isModalOpen = ref(false);
|
||||||
|
const modalMode = ref<'create' | 'edit'>('create');
|
||||||
|
const modalInitialData = ref<Partial<ModelTypePayload> | null>(null);
|
||||||
|
const editingId = ref<string | null>(null);
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let activeController: AbortController | null = null;
|
||||||
|
|
||||||
|
const { showError, showSuccess } = useToast();
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Types de modèles',
|
||||||
|
});
|
||||||
|
|
||||||
|
const extractErrorMessage = (error: unknown) => {
|
||||||
|
if (error && typeof error === 'object') {
|
||||||
|
const maybeFetchError = error as { data?: any; statusMessage?: string; message?: string };
|
||||||
|
if (maybeFetchError.data) {
|
||||||
|
const data = maybeFetchError.data;
|
||||||
|
if (typeof data.message === 'string') {
|
||||||
|
return data.message;
|
||||||
|
}
|
||||||
|
if (Array.isArray(data.message) && data.message.length > 0) {
|
||||||
|
return data.message[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof maybeFetchError.statusMessage === 'string') {
|
||||||
|
return maybeFetchError.statusMessage;
|
||||||
|
}
|
||||||
|
if (typeof maybeFetchError.message === 'string') {
|
||||||
|
return maybeFetchError.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Une erreur est survenue lors de la communication avec le serveur.';
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}) => {
|
||||||
|
if (resetOffset) {
|
||||||
|
offset.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeController) {
|
||||||
|
activeController.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeController = controller;
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: ModelTypeListResponse = await listModelTypes(
|
||||||
|
{
|
||||||
|
q: searchTerm.value || undefined,
|
||||||
|
category: category.value,
|
||||||
|
sort: sort.value,
|
||||||
|
dir: dir.value,
|
||||||
|
limit: limit.value,
|
||||||
|
offset: offset.value,
|
||||||
|
},
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
items.value = response.items;
|
||||||
|
total.value = response.total;
|
||||||
|
offset.value = response.offset;
|
||||||
|
limit.value = response.limit;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError(extractErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
if (activeController === controller) {
|
||||||
|
loading.value = false;
|
||||||
|
activeController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearchInput = (value: string) => {
|
||||||
|
searchInput.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCategoryChange = (value: ModelCategory) => {
|
||||||
|
if (category.value !== value) {
|
||||||
|
category.value = value;
|
||||||
|
refresh({ resetOffset: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSortChange = (value: 'name' | 'code' | 'createdAt') => {
|
||||||
|
if (sort.value !== value) {
|
||||||
|
sort.value = value;
|
||||||
|
refresh({ resetOffset: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDirChange = (value: 'asc' | 'desc') => {
|
||||||
|
if (dir.value !== value) {
|
||||||
|
dir.value = value;
|
||||||
|
refresh({ resetOffset: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOffsetChange = (value: number) => {
|
||||||
|
const nextOffset = Math.max(0, value);
|
||||||
|
if (nextOffset !== offset.value) {
|
||||||
|
offset.value = nextOffset;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
modalMode.value = 'create';
|
||||||
|
modalInitialData.value = null;
|
||||||
|
editingId.value = null;
|
||||||
|
isModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (item: ModelType) => {
|
||||||
|
modalMode.value = 'edit';
|
||||||
|
editingId.value = item.id;
|
||||||
|
modalInitialData.value = {
|
||||||
|
name: item.name,
|
||||||
|
code: item.code,
|
||||||
|
category: item.category,
|
||||||
|
notes: item.notes ?? item.description ?? '',
|
||||||
|
};
|
||||||
|
isModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isModalOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (payload: ModelTypePayload) => {
|
||||||
|
saving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enrichedPayload = {
|
||||||
|
...payload,
|
||||||
|
description: payload.notes ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (modalMode.value === 'create') {
|
||||||
|
await createModelType(enrichedPayload);
|
||||||
|
showSuccess('Type de modèle créé avec succès.');
|
||||||
|
} else if (modalMode.value === 'edit' && editingId.value) {
|
||||||
|
await updateModelType(editingId.value, enrichedPayload);
|
||||||
|
showSuccess('Type de modèle mis à jour avec succès.');
|
||||||
|
}
|
||||||
|
|
||||||
|
isModalOpen.value = false;
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
showError(extractErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async (item: ModelType) => {
|
||||||
|
const confirmed = window.confirm('Supprimer ce type ? Cette action est irréversible.');
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteModelType(item.id);
|
||||||
|
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
|
||||||
|
|
||||||
|
if (items.value.length === 1 && offset.value >= limit.value) {
|
||||||
|
offset.value = Math.max(0, offset.value - limit.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
showError(extractErrorMessage(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => searchInput.value,
|
||||||
|
(value) => {
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
searchTerm.value = value.trim();
|
||||||
|
refresh({ resetOffset: true });
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
if (activeController) {
|
||||||
|
activeController.abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="container mx-auto px-6 py-8 space-y-8">
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-800">Catalogue de modèles</h1>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Administrez les modèles de composants et de pièces disponibles lors de la création des machines.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="tabs tabs-boxed w-full md:w-auto">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tab"
|
|
||||||
:class="{ 'tab-active': activeTab === 'components' }"
|
|
||||||
@click="activeTab = 'components'"
|
|
||||||
>
|
|
||||||
Modèles de composants
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tab"
|
|
||||||
:class="{ 'tab-active': activeTab === 'pieces' }"
|
|
||||||
@click="activeTab = 'pieces'"
|
|
||||||
>
|
|
||||||
Modèles de pièces
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Component Models -->
|
|
||||||
<section v-if="activeTab === 'components'" class="space-y-4">
|
|
||||||
<div class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body space-y-4">
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:gap-4">
|
|
||||||
<label class="form-control w-full md:w-64">
|
|
||||||
<span class="label-text text-sm">Type de composant</span>
|
|
||||||
<select v-model="selectedComponentType" class="select select-bordered select-sm">
|
|
||||||
<option value="all">Tous les types</option>
|
|
||||||
<option
|
|
||||||
v-for="type in componentTypes"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
|
||||||
{{ type.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{{ componentModelsList.length }} modèle(s)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm w-full md:w-auto" @click="openComponentModal('create')">
|
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
|
||||||
Nouveau modèle
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loadingComponentModels" class="flex justify-center py-12">
|
|
||||||
<span class="loading loading-spinner loading-md"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="componentModelsList.length === 0" class="py-12 text-center text-sm text-gray-500">
|
|
||||||
Aucun modèle trouvé pour ce filtre.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-sm text-gray-500">
|
|
||||||
<th>Nom</th>
|
|
||||||
<th class="hidden md:table-cell">Description</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th class="hidden lg:table-cell">Structure</th>
|
|
||||||
<th class="hidden lg:table-cell">Dernière modification</th>
|
|
||||||
<th class="text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="model in componentModelsList" :key="model.id">
|
|
||||||
<td>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<IconLucideLayers class="w-4 h-4 text-primary" aria-hidden="true" />
|
|
||||||
<span class="font-medium">{{ model.name }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
|
||||||
<td>{{ model.typeComposant?.name || 'Non défini' }}</td>
|
|
||||||
<td class="hidden lg:table-cell">{{ formatStructurePreview(model.structure) }}</td>
|
|
||||||
<td class="hidden lg:table-cell">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
|
|
||||||
<td class="text-right space-x-2">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline" @click="openComponentModal('edit', model)">
|
|
||||||
Éditer
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-error" @click="confirmDeleteComponentModel(model)">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Piece Models -->
|
|
||||||
<section v-else class="space-y-4">
|
|
||||||
<div class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body space-y-4">
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:gap-4">
|
|
||||||
<label class="form-control w-full md:w-64">
|
|
||||||
<span class="label-text text-sm">Type de pièce</span>
|
|
||||||
<select v-model="selectedPieceType" class="select select-bordered select-sm">
|
|
||||||
<option value="all">Tous les types</option>
|
|
||||||
<option
|
|
||||||
v-for="type in pieceTypes"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
|
||||||
{{ type.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{{ pieceModelsList.length }} modèle(s)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm w-full md:w-auto" @click="openPieceModal('create')">
|
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
|
||||||
Nouveau modèle
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loadingPieceModels" class="flex justify-center py-12">
|
|
||||||
<span class="loading loading-spinner loading-md"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="pieceModelsList.length === 0" class="py-12 text-center text-sm text-gray-500">
|
|
||||||
Aucun modèle trouvé pour ce filtre.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-sm text-gray-500">
|
|
||||||
<th>Nom</th>
|
|
||||||
<th class="hidden md:table-cell">Description</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th class="hidden lg:table-cell">Dernière modification</th>
|
|
||||||
<th class="text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="model in pieceModelsList" :key="model.id">
|
|
||||||
<td>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<IconLucidePackage class="w-4 h-4 text-secondary" aria-hidden="true" />
|
|
||||||
<span class="font-medium">{{ model.name }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
|
||||||
<td>{{ model.typePiece?.name || 'Non défini' }}</td>
|
|
||||||
<td class="hidden lg:table-cell">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
|
|
||||||
<td class="text-right space-x-2">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline" @click="openPieceModal('edit', model)">
|
|
||||||
Éditer
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-error" @click="confirmDeletePieceModel(model)">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Component Model Modal -->
|
|
||||||
<div v-if="componentModal.open" class="modal modal-open">
|
|
||||||
<div class="modal-box max-w-4xl">
|
|
||||||
<h3 class="font-bold text-lg mb-1">
|
|
||||||
{{ componentModal.mode === 'create' ? 'Nouveau modèle de composant' : 'Modifier le modèle de composant' }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-xs text-gray-500 mb-4">
|
|
||||||
Définissez le modèle de composant ainsi que sa structure par défaut (sous-composants, pièces et champs personnalisés).
|
|
||||||
</p>
|
|
||||||
<form class="space-y-5" @submit.prevent="submitComponentModal">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Nom</span></label>
|
|
||||||
<input v-model="componentModal.form.name" type="text" class="input input-bordered input-sm" required />
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Description</span></label>
|
|
||||||
<textarea
|
|
||||||
v-model="componentModal.form.description"
|
|
||||||
class="textarea textarea-bordered textarea-sm"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Notes sur ce modèle"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Type de composant</span></label>
|
|
||||||
<select v-model="componentModal.form.typeComposantId" class="select select-bordered select-sm" required>
|
|
||||||
<option value="" disabled>Choisir un type</option>
|
|
||||||
<option
|
|
||||||
v-for="type in componentTypes"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
|
||||||
{{ type.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="divider my-0">Structure</div>
|
|
||||||
<ComponentModelStructureEditor v-model="componentModal.form.structure" />
|
|
||||||
<div class="rounded-lg border border-base-200 bg-base-200/60 p-3">
|
|
||||||
<ModelStructureViewer :structure="componentModal.form.structure" />
|
|
||||||
</div>
|
|
||||||
<div class="modal-action">
|
|
||||||
<button type="button" class="btn btn-outline" @click="closeComponentModal">Annuler</button>
|
|
||||||
<button type="submit" class="btn btn-primary" :class="{ loading: componentModal.submitting }">
|
|
||||||
{{ componentModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Piece Model Modal -->
|
|
||||||
<div v-if="pieceModal.open" class="modal modal-open">
|
|
||||||
<div class="modal-box max-w-md">
|
|
||||||
<h3 class="font-bold text-lg mb-1">
|
|
||||||
{{ pieceModal.mode === 'create' ? 'Nouveau modèle de pièce' : 'Modifier le modèle de pièce' }}
|
|
||||||
</h3>
|
|
||||||
<form class="space-y-4" @submit.prevent="submitPieceModal">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Nom</span></label>
|
|
||||||
<input v-model="pieceModal.form.name" type="text" class="input input-bordered input-sm" required />
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Description</span></label>
|
|
||||||
<textarea
|
|
||||||
v-model="pieceModal.form.description"
|
|
||||||
class="textarea textarea-bordered textarea-sm"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Notes sur ce modèle"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Type de pièce</span></label>
|
|
||||||
<select v-model="pieceModal.form.typePieceId" class="select select-bordered select-sm" required>
|
|
||||||
<option value="" disabled>Choisir un type</option>
|
|
||||||
<option
|
|
||||||
v-for="type in pieceTypes"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
|
||||||
{{ type.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="modal-action">
|
|
||||||
<button type="button" class="btn btn-outline" @click="closePieceModal">Annuler</button>
|
|
||||||
<button type="submit" class="btn btn-primary" :class="{ loading: pieceModal.submitting }">
|
|
||||||
{{ pieceModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, reactive, watch, onMounted } from 'vue'
|
|
||||||
import { useComponentModels } from '~/composables/useComponentModels'
|
|
||||||
import { usePieceModels } from '~/composables/usePieceModels'
|
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
|
||||||
import { useToast } from '~/composables/useToast'
|
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
|
||||||
import IconLucideLayers from '~icons/lucide/layers'
|
|
||||||
import IconLucidePackage from '~icons/lucide/package'
|
|
||||||
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue'
|
|
||||||
import ModelStructureViewer from '~/components/ModelStructureViewer.vue'
|
|
||||||
import {
|
|
||||||
defaultStructure,
|
|
||||||
cloneStructure,
|
|
||||||
formatStructurePreview,
|
|
||||||
normalizeStructureForSave,
|
|
||||||
} from '~/shared/modelUtils'
|
|
||||||
|
|
||||||
const activeTab = ref('components')
|
|
||||||
const selectedComponentType = ref('all')
|
|
||||||
const selectedPieceType = ref('all')
|
|
||||||
|
|
||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
|
||||||
|
|
||||||
const {
|
|
||||||
componentModels,
|
|
||||||
loadComponentModels,
|
|
||||||
createComponentModel,
|
|
||||||
updateComponentModel,
|
|
||||||
deleteComponentModel,
|
|
||||||
loadingComponentModels,
|
|
||||||
getComponentModelsForType,
|
|
||||||
} = useComponentModels()
|
|
||||||
|
|
||||||
const {
|
|
||||||
pieceModels,
|
|
||||||
loadPieceModels,
|
|
||||||
createPieceModel,
|
|
||||||
updatePieceModel,
|
|
||||||
deletePieceModel,
|
|
||||||
loadingPieceModels,
|
|
||||||
getPieceModelsForType,
|
|
||||||
} = usePieceModels()
|
|
||||||
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const componentModal = reactive({
|
|
||||||
open: false,
|
|
||||||
mode: 'create',
|
|
||||||
submitting: false,
|
|
||||||
previousTypeId: null,
|
|
||||||
form: {
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
typeComposantId: '',
|
|
||||||
structure: defaultStructure(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const pieceModal = reactive({
|
|
||||||
open: false,
|
|
||||||
mode: 'create',
|
|
||||||
submitting: false,
|
|
||||||
previousTypeId: null,
|
|
||||||
form: {
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
typePieceId: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const componentModelsList = computed(() => {
|
|
||||||
if (selectedComponentType.value === 'all') {
|
|
||||||
return componentModels.value
|
|
||||||
}
|
|
||||||
return getComponentModelsForType(selectedComponentType.value) || []
|
|
||||||
})
|
|
||||||
|
|
||||||
const pieceModelsList = computed(() => {
|
|
||||||
if (selectedPieceType.value === 'all') {
|
|
||||||
return pieceModels.value
|
|
||||||
}
|
|
||||||
return getPieceModelsForType(selectedPieceType.value) || []
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatDate = (value) => {
|
|
||||||
if (!value) return '—'
|
|
||||||
const date = new Date(value)
|
|
||||||
return date.toLocaleDateString('fr-FR', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureTypeSelected = (typeId, list) => {
|
|
||||||
if (typeId && list.some((type) => type.id === typeId)) {
|
|
||||||
return typeId
|
|
||||||
}
|
|
||||||
return list[0]?.id || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const openComponentModal = (mode, model) => {
|
|
||||||
componentModal.mode = mode
|
|
||||||
componentModal.open = true
|
|
||||||
componentModal.submitting = false
|
|
||||||
componentModal.previousTypeId = model?.typeComposantId || null
|
|
||||||
if (mode === 'edit' && model) {
|
|
||||||
componentModal.form = {
|
|
||||||
id: model.id,
|
|
||||||
name: model.name,
|
|
||||||
description: model.description || '',
|
|
||||||
typeComposantId: model.typeComposantId || model.typeComposant?.id || '',
|
|
||||||
structure: cloneStructure(model.structure || defaultStructure()),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
componentModal.form = {
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
typeComposantId: ensureTypeSelected(selectedComponentType.value !== 'all' ? selectedComponentType.value : '', componentTypes.value),
|
|
||||||
structure: defaultStructure(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeComponentModal = () => {
|
|
||||||
componentModal.open = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitComponentModal = async () => {
|
|
||||||
if (!componentModal.form.typeComposantId) {
|
|
||||||
toast.showError('Veuillez sélectionner un type de composant')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
componentModal.submitting = true
|
|
||||||
try {
|
|
||||||
if (componentModal.mode === 'create') {
|
|
||||||
await createComponentModel({
|
|
||||||
name: componentModal.form.name.trim(),
|
|
||||||
description: componentModal.form.description.trim() || undefined,
|
|
||||||
typeComposantId: componentModal.form.typeComposantId,
|
|
||||||
structure: normalizeStructureForSave(componentModal.form.structure),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await updateComponentModel(componentModal.form.id, {
|
|
||||||
name: componentModal.form.name.trim(),
|
|
||||||
description: componentModal.form.description.trim() || undefined,
|
|
||||||
typeComposantId: componentModal.form.typeComposantId,
|
|
||||||
structure: normalizeStructureForSave(componentModal.form.structure),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshComponentModels(componentModal.form.typeComposantId)
|
|
||||||
if (selectedComponentType.value === 'all') {
|
|
||||||
await refreshComponentModels()
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
componentModal.mode === 'edit' &&
|
|
||||||
componentModal.previousTypeId &&
|
|
||||||
componentModal.previousTypeId !== componentModal.form.typeComposantId
|
|
||||||
) {
|
|
||||||
await refreshComponentModels(componentModal.previousTypeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
closeComponentModal()
|
|
||||||
} catch (error) {
|
|
||||||
toast.showError('Impossible d\'enregistrer le modèle de composant')
|
|
||||||
} finally {
|
|
||||||
componentModal.submitting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDeleteComponentModel = async (model) => {
|
|
||||||
if (!confirm(`Supprimer le modèle "${model.name}" ?`)) return
|
|
||||||
try {
|
|
||||||
const result = await deleteComponentModel(model.id)
|
|
||||||
if (result.success) {
|
|
||||||
await refreshComponentModels(model.typeComposantId)
|
|
||||||
if (selectedComponentType.value === 'all') {
|
|
||||||
await refreshComponentModels()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.showError('Impossible de supprimer ce modèle')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openPieceModal = (mode, model) => {
|
|
||||||
pieceModal.mode = mode
|
|
||||||
pieceModal.open = true
|
|
||||||
pieceModal.submitting = false
|
|
||||||
pieceModal.previousTypeId = model?.typePieceId || null
|
|
||||||
if (mode === 'edit' && model) {
|
|
||||||
pieceModal.form = {
|
|
||||||
id: model.id,
|
|
||||||
name: model.name,
|
|
||||||
description: model.description || '',
|
|
||||||
typePieceId: model.typePieceId || model.typePiece?.id || '',
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pieceModal.form = {
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
typePieceId: ensureTypeSelected(selectedPieceType.value !== 'all' ? selectedPieceType.value : '', pieceTypes.value),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closePieceModal = () => {
|
|
||||||
pieceModal.open = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitPieceModal = async () => {
|
|
||||||
if (!pieceModal.form.typePieceId) {
|
|
||||||
toast.showError('Veuillez sélectionner un type de pièce')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pieceModal.submitting = true
|
|
||||||
try {
|
|
||||||
if (pieceModal.mode === 'create') {
|
|
||||||
await createPieceModel({
|
|
||||||
name: pieceModal.form.name.trim(),
|
|
||||||
description: pieceModal.form.description.trim() || undefined,
|
|
||||||
typePieceId: pieceModal.form.typePieceId,
|
|
||||||
structure: {},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await updatePieceModel(pieceModal.form.id, {
|
|
||||||
name: pieceModal.form.name.trim(),
|
|
||||||
description: pieceModal.form.description.trim() || undefined,
|
|
||||||
typePieceId: pieceModal.form.typePieceId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshPieceModels(pieceModal.form.typePieceId)
|
|
||||||
if (selectedPieceType.value === 'all') {
|
|
||||||
await refreshPieceModels()
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
pieceModal.mode === 'edit' &&
|
|
||||||
pieceModal.previousTypeId &&
|
|
||||||
pieceModal.previousTypeId !== pieceModal.form.typePieceId
|
|
||||||
) {
|
|
||||||
await refreshPieceModels(pieceModal.previousTypeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
closePieceModal()
|
|
||||||
} catch (error) {
|
|
||||||
toast.showError('Impossible d\'enregistrer le modèle de pièce')
|
|
||||||
} finally {
|
|
||||||
pieceModal.submitting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDeletePieceModel = async (model) => {
|
|
||||||
if (!confirm(`Supprimer le modèle "${model.name}" ?`)) return
|
|
||||||
try {
|
|
||||||
const result = await deletePieceModel(model.id)
|
|
||||||
if (result.success) {
|
|
||||||
await refreshPieceModels(model.typePieceId)
|
|
||||||
if (selectedPieceType.value === 'all') {
|
|
||||||
await refreshPieceModels()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.showError('Impossible de supprimer ce modèle')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshComponentModels = async (typeId) => {
|
|
||||||
if (typeId) {
|
|
||||||
await loadComponentModels(typeId)
|
|
||||||
} else {
|
|
||||||
await loadComponentModels()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshPieceModels = async (typeId) => {
|
|
||||||
if (typeId) {
|
|
||||||
await loadPieceModels(typeId)
|
|
||||||
} else {
|
|
||||||
await loadPieceModels()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(selectedComponentType, async (value) => {
|
|
||||||
await refreshComponentModels(value === 'all' ? undefined : value)
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
watch(selectedPieceType, async (value) => {
|
|
||||||
await refreshPieceModels(value === 'all' ? undefined : value)
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await Promise.all([
|
|
||||||
loadComponentTypes(),
|
|
||||||
loadPieceTypes(),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
347
app/pages/models/components.vue
Normal file
347
app/pages/models/components.vue
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto px-6 py-8 space-y-8">
|
||||||
|
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800">Modèles de composants</h1>
|
||||||
|
<p class="text-sm text-gray-500">Gérez les modèles disponibles pour chaque famille de composant.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tabs tabs-boxed">
|
||||||
|
<NuxtLink to="/models/components" class="tab tab-active">Composants</NuxtLink>
|
||||||
|
<NuxtLink to="/models/pieces" class="tab">Pièces</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-8 xl:grid-cols-[2fr,1fr]">
|
||||||
|
<section class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div class="flex flex-col gap-2 md:flex-row md:items-center md:gap-4">
|
||||||
|
<label class="form-control w-full md:w-64">
|
||||||
|
<span class="label-text text-sm">Type de composant</span>
|
||||||
|
<select v-model="selectedType" class="select select-bordered select-sm">
|
||||||
|
<option value="all">Tous les types</option>
|
||||||
|
<option
|
||||||
|
v-for="type in componentTypes"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-control">
|
||||||
|
<span class="label-text text-sm">Filtrer</span>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher un modèle..."
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span class="text-xs text-gray-500">{{ filteredModels.length }} modèle(s)</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm w-full md:w-auto" @click="startCreate">
|
||||||
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Nouveau modèle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingComponentModels" class="flex justify-center py-16">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredModels.length === 0" class="py-16 text-center text-sm text-gray-500">
|
||||||
|
Aucun modèle ne correspond à ce filtre.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-sm text-gray-500">
|
||||||
|
<th>Nom</th>
|
||||||
|
<th class="hidden md:table-cell">Description</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="hidden xl:table-cell">Structure</th>
|
||||||
|
<th class="hidden lg:table-cell">Modifié</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="model in filteredModels"
|
||||||
|
:key="model.id"
|
||||||
|
:class="{
|
||||||
|
'bg-base-200/60': form.mode === 'edit' && form.data.id === model.id
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<IconLucideLayers class="w-4 h-4 text-primary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">{{ model.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
||||||
|
<td>{{ model.typeComposant?.name || 'Non défini' }}</td>
|
||||||
|
<td class="hidden xl:table-cell text-xs text-gray-500">{{ formatStructurePreview(model.structure) }}</td>
|
||||||
|
<td class="hidden lg:table-cell text-xs text-gray-500">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
|
||||||
|
<td class="text-right space-x-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
|
||||||
|
Éditer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-error" @click="confirmDelete(model)">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-lg">
|
||||||
|
{{ form.mode === 'edit' ? 'Modifier le modèle' : 'Nouveau modèle' }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
v-if="form.mode === 'edit'"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="startCreate"
|
||||||
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Nom</span></label>
|
||||||
|
<input
|
||||||
|
v-model="form.data.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
placeholder="Nom du modèle"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Description</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.data.description"
|
||||||
|
class="textarea textarea-bordered textarea-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Notes optionnelles"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Type de composant</span></label>
|
||||||
|
<select
|
||||||
|
v-model="form.data.typeComposantId"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>Sélectionner un type</option>
|
||||||
|
<option
|
||||||
|
v-for="type in componentTypes"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider my-0">Structure</div>
|
||||||
|
<ComponentModelStructureEditor v-model="form.data.structure" />
|
||||||
|
<div class="rounded-lg border border-base-200 bg-base-200/60 p-3 text-xs text-gray-500">
|
||||||
|
Aperçu : {{ formatStructurePreview(form.data.structure) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button type="submit" class="btn btn-primary" :class="{ loading: form.submitting }">
|
||||||
|
{{ form.mode === 'edit' ? 'Enregistrer' : 'Créer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, reactive, onMounted, watch } from 'vue'
|
||||||
|
import { useComponentModels } from '~/composables/useComponentModels'
|
||||||
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue'
|
||||||
|
import { formatStructurePreview } from '~/shared/modelUtils'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
import IconLucideLayers from '~icons/lucide/layers'
|
||||||
|
|
||||||
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
|
const {
|
||||||
|
componentModels,
|
||||||
|
loadComponentModels,
|
||||||
|
createComponentModel,
|
||||||
|
updateComponentModel,
|
||||||
|
deleteComponentModel,
|
||||||
|
loadingComponentModels,
|
||||||
|
getComponentModelsForType,
|
||||||
|
} = useComponentModels()
|
||||||
|
const { showError, showSuccess } = useToast()
|
||||||
|
|
||||||
|
const selectedType = ref('all')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
mode: 'create',
|
||||||
|
submitting: false,
|
||||||
|
data: {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
typeComposantId: '',
|
||||||
|
structure: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensureTypeSelected = () => {
|
||||||
|
if (form.data.typeComposantId && componentTypes.value.some((type) => type.id === form.data.typeComposantId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedType.value !== 'all' && componentTypes.value.some((type) => type.id === selectedType.value)) {
|
||||||
|
form.data.typeComposantId = selectedType.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.data.typeComposantId = componentTypes.value[0]?.id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCreate = () => {
|
||||||
|
form.mode = 'create'
|
||||||
|
form.data = {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
typeComposantId: selectedType.value !== 'all' ? selectedType.value : '',
|
||||||
|
structure: {},
|
||||||
|
}
|
||||||
|
ensureTypeSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = (model) => {
|
||||||
|
form.mode = 'edit'
|
||||||
|
form.data = {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
description: model.description || '',
|
||||||
|
typeComposantId: model.typeComposantId || model.typeComposant?.id || '',
|
||||||
|
structure: model.structure || {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredModels = computed(() => {
|
||||||
|
const list = selectedType.value === 'all'
|
||||||
|
? componentModels.value
|
||||||
|
: getComponentModelsForType(selectedType.value) || []
|
||||||
|
|
||||||
|
if (!searchQuery.value.trim()) {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
const term = searchQuery.value.toLowerCase()
|
||||||
|
return list.filter((model) => {
|
||||||
|
return (
|
||||||
|
model.name?.toLowerCase().includes(term)
|
||||||
|
|| model.description?.toLowerCase().includes(term)
|
||||||
|
|| model.typeComposant?.name?.toLowerCase().includes(term)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
return new Date(value).toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshModels = async () => {
|
||||||
|
if (selectedType.value === 'all') {
|
||||||
|
await loadComponentModels()
|
||||||
|
} else {
|
||||||
|
await loadComponentModels(selectedType.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.data.typeComposantId) {
|
||||||
|
showError('Veuillez sélectionner un type de composant')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form.submitting = true
|
||||||
|
try {
|
||||||
|
if (form.mode === 'create') {
|
||||||
|
const result = await createComponentModel({
|
||||||
|
name: form.data.name.trim(),
|
||||||
|
description: form.data.description.trim() || undefined,
|
||||||
|
typeComposantId: form.data.typeComposantId,
|
||||||
|
structure: form.data.structure || {},
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
showError(result.error || 'Impossible de créer le modèle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showSuccess(`Modèle "${result.data.name}" créé`)
|
||||||
|
} else if (form.data.id) {
|
||||||
|
const result = await updateComponentModel(form.data.id, {
|
||||||
|
name: form.data.name.trim(),
|
||||||
|
description: form.data.description.trim() || undefined,
|
||||||
|
typeComposantId: form.data.typeComposantId,
|
||||||
|
structure: form.data.structure || {},
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
showError(result.error || 'Impossible de mettre à jour le modèle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showSuccess(`Modèle "${result.data.name}" mis à jour`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshModels()
|
||||||
|
startCreate()
|
||||||
|
} finally {
|
||||||
|
form.submitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async (model) => {
|
||||||
|
const ok = confirm(`Supprimer le modèle "${model.name}" ?`)
|
||||||
|
if (!ok) return
|
||||||
|
const result = await deleteComponentModel(model.id)
|
||||||
|
if (!result.success) {
|
||||||
|
showError(result.error || 'Impossible de supprimer ce modèle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.mode === 'edit' && form.data.id === model.id) {
|
||||||
|
startCreate()
|
||||||
|
}
|
||||||
|
showSuccess('Modèle supprimé')
|
||||||
|
await refreshModels()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadComponentTypes(), loadComponentModels()])
|
||||||
|
ensureTypeSelected()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedType, async () => {
|
||||||
|
await refreshModels()
|
||||||
|
if (form.mode === 'create') {
|
||||||
|
ensureTypeSelected()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
25
app/pages/models/index.vue
Normal file
25
app/pages/models/index.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-[60vh] flex flex-col items-center justify-center gap-6 text-center">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h1 class="text-3xl font-semibold">Gestion des modèles</h1>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Administrez les modèles de composants et de pièces utilisés lors de la configuration des machines.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<NuxtLink to="/models/components" class="btn btn-primary">
|
||||||
|
Modèles de composants
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/models/pieces" class="btn btn-outline">
|
||||||
|
Modèles de pièces
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const route = useRoute()
|
||||||
|
if (route.fullPath === '/models') {
|
||||||
|
navigateTo('/models/components', { replace: true })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
349
app/pages/models/pieces.vue
Normal file
349
app/pages/models/pieces.vue
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto px-6 py-8 space-y-8">
|
||||||
|
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800">Modèles de pièces</h1>
|
||||||
|
<p class="text-sm text-gray-500">Gérez les modèles disponibles pour chaque groupe de pièces.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tabs tabs-boxed">
|
||||||
|
<NuxtLink to="/models/components" class="tab">Composants</NuxtLink>
|
||||||
|
<NuxtLink to="/models/pieces" class="tab tab-active">Pièces</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-8 xl:grid-cols-[2fr,1fr]">
|
||||||
|
<section class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div class="flex flex-col gap-2 md:flex-row md:items-center md:gap-4">
|
||||||
|
<label class="form-control w-full md:w-64">
|
||||||
|
<span class="label-text text-sm">Type de pièce</span>
|
||||||
|
<select v-model="selectedType" class="select select-bordered select-sm">
|
||||||
|
<option value="all">Tous les types</option>
|
||||||
|
<option v-for="type in pieceTypes" :key="type.id" :value="type.id">
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-control">
|
||||||
|
<span class="label-text text-sm">Filtrer</span>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher un modèle..."
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span class="text-xs text-gray-500">{{ filteredModels.length }} modèle(s)</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm w-full md:w-auto" @click="startCreate">
|
||||||
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Nouveau modèle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingPieceModels" class="flex justify-center py-16">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredModels.length === 0" class="py-16 text-center text-sm text-gray-500">
|
||||||
|
Aucun modèle ne correspond à ce filtre.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-sm text-gray-500">
|
||||||
|
<th>Nom</th>
|
||||||
|
<th class="hidden md:table-cell">Description</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th class="hidden lg:table-cell">Modifié</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="model in filteredModels"
|
||||||
|
:key="model.id"
|
||||||
|
:class="{
|
||||||
|
'bg-base-200/60': form.mode === 'edit' && form.data.id === model.id
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<IconLucidePackage class="w-4 h-4 text-secondary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">{{ model.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
||||||
|
<td>{{ model.typePiece?.name || 'Non défini' }}</td>
|
||||||
|
<td class="hidden lg:table-cell text-xs text-gray-500">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
|
||||||
|
<td class="text-right space-x-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
|
||||||
|
Éditer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-error" @click="confirmDelete(model)">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-lg">
|
||||||
|
{{ form.mode === 'edit' ? 'Modifier le modèle' : 'Nouveau modèle' }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
v-if="form.mode === 'edit'"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="startCreate"
|
||||||
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Nom</span></label>
|
||||||
|
<input
|
||||||
|
v-model="form.data.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
placeholder="Nom du modèle"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Description</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.data.description"
|
||||||
|
class="textarea textarea-bordered textarea-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Notes optionnelles"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Type de pièce</span></label>
|
||||||
|
<select
|
||||||
|
v-model="form.data.typePieceId"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>Sélectionner un type</option>
|
||||||
|
<option v-for="type in pieceTypes" :key="type.id" :value="type.id">
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider my-0">Structure</div>
|
||||||
|
<PieceModelStructureEditor v-model="form.data.structure" />
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-base-200 bg-base-200/60 p-3 text-xs text-gray-500">
|
||||||
|
Aperçu : {{ formatStructurePreview(form.data.structure) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button type="submit" class="btn btn-primary" :class="{ loading: form.submitting }">
|
||||||
|
{{ form.mode === 'edit' ? 'Enregistrer' : 'Créer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, reactive, onMounted, watch } from 'vue'
|
||||||
|
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
|
||||||
|
import { usePieceModels } from '~/composables/usePieceModels'
|
||||||
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { formatStructurePreview } from '~/shared/modelUtils'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
import IconLucidePackage from '~icons/lucide/package'
|
||||||
|
|
||||||
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
|
const {
|
||||||
|
pieceModels,
|
||||||
|
loadPieceModels,
|
||||||
|
createPieceModel,
|
||||||
|
updatePieceModel,
|
||||||
|
deletePieceModel,
|
||||||
|
loadingPieceModels,
|
||||||
|
getPieceModelsForType,
|
||||||
|
} = usePieceModels()
|
||||||
|
const { showError, showSuccess } = useToast()
|
||||||
|
|
||||||
|
const selectedType = ref('all')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const defaultStructure = () => ({ customFields: [] })
|
||||||
|
|
||||||
|
const cloneStructure = (value) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value ?? defaultStructure()))
|
||||||
|
} catch (error) {
|
||||||
|
return defaultStructure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
mode: 'create',
|
||||||
|
submitting: false,
|
||||||
|
data: {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
typePieceId: '',
|
||||||
|
structure: defaultStructure(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensureTypeSelected = () => {
|
||||||
|
if (form.data.typePieceId && pieceTypes.value.some((type) => type.id === form.data.typePieceId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedType.value !== 'all' && pieceTypes.value.some((type) => type.id === selectedType.value)) {
|
||||||
|
form.data.typePieceId = selectedType.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.data.typePieceId = pieceTypes.value[0]?.id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCreate = () => {
|
||||||
|
form.mode = 'create'
|
||||||
|
form.data = {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
typePieceId: selectedType.value !== 'all' ? selectedType.value : '',
|
||||||
|
structure: defaultStructure(),
|
||||||
|
}
|
||||||
|
ensureTypeSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = (model) => {
|
||||||
|
form.mode = 'edit'
|
||||||
|
form.data = {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
description: model.description || '',
|
||||||
|
typePieceId: model.typePieceId || model.typePiece?.id || '',
|
||||||
|
structure: cloneStructure(model.structure || defaultStructure()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredModels = computed(() => {
|
||||||
|
const list = selectedType.value === 'all'
|
||||||
|
? pieceModels.value
|
||||||
|
: getPieceModelsForType(selectedType.value) || []
|
||||||
|
|
||||||
|
if (!searchQuery.value.trim()) {
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
const term = searchQuery.value.toLowerCase()
|
||||||
|
return list.filter((model) => {
|
||||||
|
return (
|
||||||
|
model.name?.toLowerCase().includes(term)
|
||||||
|
|| model.description?.toLowerCase().includes(term)
|
||||||
|
|| model.typePiece?.name?.toLowerCase().includes(term)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
return new Date(value).toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshModels = async () => {
|
||||||
|
if (selectedType.value === 'all') {
|
||||||
|
await loadPieceModels()
|
||||||
|
} else {
|
||||||
|
await loadPieceModels(selectedType.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.data.typePieceId) {
|
||||||
|
showError('Veuillez sélectionner un type de pièce')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form.submitting = true
|
||||||
|
try {
|
||||||
|
const structure = cloneStructure(form.data.structure)
|
||||||
|
if (form.mode === 'create') {
|
||||||
|
const result = await createPieceModel({
|
||||||
|
name: form.data.name.trim(),
|
||||||
|
description: form.data.description.trim() || undefined,
|
||||||
|
typePieceId: form.data.typePieceId,
|
||||||
|
structure,
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
showError(result.error || 'Impossible de créer le modèle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showSuccess(`Modèle "${result.data.name}" créé`)
|
||||||
|
} else if (form.data.id) {
|
||||||
|
const result = await updatePieceModel(form.data.id, {
|
||||||
|
name: form.data.name.trim(),
|
||||||
|
description: form.data.description.trim() || undefined,
|
||||||
|
typePieceId: form.data.typePieceId,
|
||||||
|
structure,
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
showError(result.error || 'Impossible de mettre à jour le modèle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showSuccess(`Modèle "${result.data.name}" mis à jour`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshModels()
|
||||||
|
startCreate()
|
||||||
|
} finally {
|
||||||
|
form.submitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async (model) => {
|
||||||
|
const ok = confirm(`Supprimer le modèle "${model.name}" ?`)
|
||||||
|
if (!ok) return
|
||||||
|
const result = await deletePieceModel(model.id)
|
||||||
|
if (!result.success) {
|
||||||
|
showError(result.error || 'Impossible de supprimer ce modèle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.mode === 'edit' && form.data.id === model.id) {
|
||||||
|
startCreate()
|
||||||
|
}
|
||||||
|
showSuccess('Modèle supprimé')
|
||||||
|
await refreshModels()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadPieceTypes(), loadPieceModels()])
|
||||||
|
ensureTypeSelected()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedType, async () => {
|
||||||
|
await refreshModels()
|
||||||
|
if (form.mode === 'create') {
|
||||||
|
ensureTypeSelected()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
113
app/services/modelTypes.ts
Normal file
113
app/services/modelTypes.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useRequestFetch } from '#imports';
|
||||||
|
import type { FetchOptions } from 'ofetch';
|
||||||
|
|
||||||
|
export type ModelCategory = 'COMPONENT' | 'PIECE';
|
||||||
|
|
||||||
|
export interface ModelTypePayload {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
category: ModelCategory;
|
||||||
|
notes?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelType extends ModelTypePayload {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelTypeListParams {
|
||||||
|
q?: string;
|
||||||
|
category?: ModelCategory;
|
||||||
|
sort?: 'name' | 'code' | 'createdAt';
|
||||||
|
dir?: 'asc' | 'desc';
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelTypeListResponse {
|
||||||
|
items: ModelType[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENDPOINT = '/api/model-types';
|
||||||
|
|
||||||
|
function resolveBaseUrl() {
|
||||||
|
const runtimeConfig = useRuntimeConfig();
|
||||||
|
return runtimeConfig.public.apiBaseUrl || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOptions<T>(options: FetchOptions<T> = {}) {
|
||||||
|
return {
|
||||||
|
baseURL: resolveBaseUrl(),
|
||||||
|
credentials: 'include' as const,
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) {
|
||||||
|
const requestFetch = useRequestFetch();
|
||||||
|
const query: Record<string, string | number> = {};
|
||||||
|
|
||||||
|
if (params.q) {
|
||||||
|
query.q = params.q;
|
||||||
|
}
|
||||||
|
if (params.category) {
|
||||||
|
query.category = params.category;
|
||||||
|
}
|
||||||
|
if (params.sort) {
|
||||||
|
query.sort = params.sort;
|
||||||
|
}
|
||||||
|
if (params.dir) {
|
||||||
|
query.dir = params.dir;
|
||||||
|
}
|
||||||
|
if (typeof params.limit === 'number') {
|
||||||
|
query.limit = params.limit;
|
||||||
|
}
|
||||||
|
if (typeof params.offset === 'number') {
|
||||||
|
query.offset = params.offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestFetch<ModelTypeListResponse>(ENDPOINT, createOptions({
|
||||||
|
method: 'GET',
|
||||||
|
query,
|
||||||
|
signal: opts.signal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) {
|
||||||
|
const requestFetch = useRequestFetch();
|
||||||
|
return requestFetch<ModelType>(ENDPOINT, createOptions({
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
signal: opts.signal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateModelType(id: string, payload: Partial<ModelTypePayload>, opts: { signal?: AbortSignal } = {}) {
|
||||||
|
const requestFetch = useRequestFetch();
|
||||||
|
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
|
||||||
|
method: 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
signal: opts.signal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteModelType(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||||
|
const requestFetch = useRequestFetch();
|
||||||
|
return requestFetch<void>(`${ENDPOINT}/${id}`, createOptions({
|
||||||
|
method: 'DELETE',
|
||||||
|
signal: opts.signal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelType(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||||
|
const requestFetch = useRequestFetch();
|
||||||
|
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
|
||||||
|
method: 'GET',
|
||||||
|
signal: opts.signal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user