feat: Add model feature for piece and component
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user