feat(audit) : filtres du journal dans un drawer (accordeons malio)
- bouton Filtres ouvrant un drawer dedie (icone mdi:equalizer) - accordeons Malio : dates (MalioDateTime Du/Au), type d'entite (checkbox), action (radio), utilisateur (recherche texte) - application des filtres uniquement au clic sur Voir les resultats (etat brouillon) - Reinitialiser vide brouillon + filtres actifs et recharge - i18n : audit.filters.title / apply / date_range
This commit is contained in:
@@ -90,7 +90,10 @@
|
||||
"load_more": "Voir plus"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser",
|
||||
"date_range": "Date à date",
|
||||
"date_from": "Du",
|
||||
"date_to": "Au",
|
||||
"entity_type": "Type d'entité",
|
||||
|
||||
@@ -1,91 +1,21 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>{{ t('admin.auditLog.title') }}</PageHeader>
|
||||
|
||||
<!-- Filtres -->
|
||||
<section class="rounded border border-gray-200 bg-white p-4">
|
||||
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
|
||||
leur `label` flottant interne pour ne pas mixer deux patterns de label.
|
||||
A revoir une fois le composant calendar Malio développé -->
|
||||
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
|
||||
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
|
||||
exposera un datetime picker. Cf. exception documentee dans
|
||||
CLAUDE.md (section "Composants formulaires"). -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.date_from') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="filters.performedAtAfter"
|
||||
type="datetime-local"
|
||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||
>
|
||||
</div>
|
||||
<!-- TODO(malio-ui): idem ci-dessus. -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.date_to') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="filters.performedAtBefore"
|
||||
type="datetime-local"
|
||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.entity_type') }}
|
||||
</label>
|
||||
<div class="[&>div>div]:!mt-0">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedEntityTypes"
|
||||
:options="entityTypeOptions"
|
||||
:display-select-all="true"
|
||||
:display-tag="true"
|
||||
min-width="w-full"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.user') }}
|
||||
</label>
|
||||
<MalioInputText
|
||||
v-model="performedByInput"
|
||||
icon-name="mdi:account-search"
|
||||
input-class="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.action') }}
|
||||
</label>
|
||||
<div class="[&>div>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="actionValue"
|
||||
:options="actionOptions"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<PageHeader>
|
||||
{{ t('admin.auditLog.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('audit.filters.reset')"
|
||||
button-class="text-xs"
|
||||
@click="resetFilters"
|
||||
:label="t('audit.filters.title')"
|
||||
icon-name="mdi:equalizer"
|
||||
icon-position="left"
|
||||
button-class="w-[184px] justify-start gap-4 text-black"
|
||||
@click="openFilters"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Tableau -->
|
||||
<MalioDataTable
|
||||
class="mt-4"
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
@@ -119,6 +49,83 @@
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<!-- Drawer de filtres : etat brouillon, applique uniquement au clic sur
|
||||
"Voir les resultats". `body-class="p-0"` pour que l'accordeon aille
|
||||
bord a bord (les items portent leur propre px-7). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between py-7"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('audit.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
|
||||
<span>{{ t('audit.filters.date_from') }}</span>
|
||||
<MalioDateTime v-model="draftDateFrom" />
|
||||
<span>{{ t('audit.filters.date_to') }}</span>
|
||||
<MalioDateTime v-model="draftDateTo" />
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Type d'entite : cases a cocher (multi-selection) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
|
||||
<div class="flex flex-col gap-4">
|
||||
<MalioCheckbox
|
||||
v-for="opt in entityTypeOptions"
|
||||
:id="`filter-entity-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftEntityTypes.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleEntity(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Action : boutons radio (selection unique, '' = toutes) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.action')" value="action">
|
||||
<div class="flex flex-col gap-4">
|
||||
<MalioRadioButton
|
||||
v-for="opt in actionOptions"
|
||||
:key="opt.value"
|
||||
v-model="draftAction"
|
||||
name="audit-action"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Utilisateur : recherche texte (ILIKE partiel cote backend) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.user')" value="user">
|
||||
<MalioInputText
|
||||
v-model="draftPerformedBy"
|
||||
icon-name="mdi:account-search"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('audit.filters.reset')"
|
||||
button-class="w-[150px]"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('audit.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
|
||||
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
||||
<MalioDrawer
|
||||
v-model="drawerOpen"
|
||||
@@ -149,7 +156,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
@@ -173,8 +180,11 @@ if (!can('core.audit_log.view')) {
|
||||
|
||||
useHead({ title: t('admin.auditLog.title') })
|
||||
|
||||
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
||||
// CLAUDE.md "Tableau : pas de persistance URL").
|
||||
// Etat des filtres APPLIQUES : pilote `loadEntries`. Local uniquement, JAMAIS
|
||||
// persiste dans l'URL (cf. regle CLAUDE.md "Tableau : pas de persistance URL").
|
||||
// `performedAtAfter`/`performedAtBefore` stockent une date+heure ISO naive
|
||||
// (`YYYY-MM-DDTHH:MM:00`, fournie par MalioDateTime), convertie en ISO UTC
|
||||
// au moment du fetch.
|
||||
const filters = reactive<AuditLogFilters>({
|
||||
performedAtAfter: undefined,
|
||||
performedAtBefore: undefined,
|
||||
@@ -185,26 +195,23 @@ const filters = reactive<AuditLogFilters>({
|
||||
itemsPerPage: 10,
|
||||
})
|
||||
|
||||
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
|
||||
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
|
||||
const selectedEntityTypes = ref<(string | number)[]>([])
|
||||
// Etat BROUILLON du drawer de filtres : edite librement, recopie dans `filters`
|
||||
// uniquement au clic sur "Voir les resultats". Permet d'annuler une saisie en
|
||||
// fermant le drawer sans relancer de requete.
|
||||
const filterDrawerOpen = ref(false)
|
||||
const draftDateFrom = ref<string | null>(null)
|
||||
const draftDateTo = ref<string | null>(null)
|
||||
const draftEntityTypes = ref<string[]>([])
|
||||
const draftAction = ref<string>('')
|
||||
const draftPerformedBy = ref<string>('')
|
||||
|
||||
// Liste des entity types (distincts) pour alimenter les cases a cocher.
|
||||
const entityTypes = ref<string[]>([])
|
||||
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
|
||||
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
|
||||
const entityTypeOptions = computed(() =>
|
||||
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
||||
)
|
||||
|
||||
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
|
||||
// pas binder directement un `string | undefined` reactive.
|
||||
const performedByInput = ref<string>('')
|
||||
|
||||
// Action : '' = "toutes les actions". On declare l'option dans `actionOptions`
|
||||
// plutot que via `emptyOptionLabel` (qui n'inclut pas l'option vide dans
|
||||
// `props.options`, donc `selectedLabel` reste vide). On evite aussi `value: null`
|
||||
// car MalioSelect grise visuellement les options dont la valeur est `null`
|
||||
// (Select.vue:137) — on utilise donc une chaine vide comme sentinelle.
|
||||
const actionValue = ref<string>('')
|
||||
// Actions : '' = "toutes". Sert d'options aux boutons radio.
|
||||
const actionOptions = [
|
||||
{ value: '', label: t('audit.filters.all_actions') },
|
||||
{ value: 'create', label: t('audit.action.create') },
|
||||
@@ -259,29 +266,55 @@ const isFiltered = computed(() =>
|
||||
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
||||
let requestToken = 0
|
||||
|
||||
// Pendant un reset, on suspend temporairement les watchers pour ne pas
|
||||
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
|
||||
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
|
||||
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
|
||||
// et les fetchs partent quand meme. Un seul loadEntries() est appele
|
||||
// explicitement apres la liberation.
|
||||
let watchersSuspended = false
|
||||
// Ouvre le drawer en recopiant l'etat applique vers le brouillon, pour que la
|
||||
// reouverture reflete les filtres actifs.
|
||||
function openFilters(): void {
|
||||
draftDateFrom.value = filters.performedAtAfter ?? null
|
||||
draftDateTo.value = filters.performedAtBefore ?? null
|
||||
draftEntityTypes.value = Array.isArray(filters.entityType)
|
||||
? [...filters.entityType]
|
||||
: (filters.entityType ? [filters.entityType] : [])
|
||||
draftAction.value = filters.action ?? ''
|
||||
draftPerformedBy.value = filters.performedBy ?? ''
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
// Bascule un type d'entite dans le brouillon (multi-selection).
|
||||
function toggleEntity(value: string, selected: boolean): void {
|
||||
const set = new Set(draftEntityTypes.value)
|
||||
if (selected) set.add(value)
|
||||
else set.delete(value)
|
||||
draftEntityTypes.value = [...set]
|
||||
}
|
||||
|
||||
// "Reinitialiser" : vide le brouillon ET les filtres actifs, puis recharge.
|
||||
// La remise a zero s'applique immediatement (la table revient a la liste
|
||||
// complete) ; le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftDateFrom.value = null
|
||||
draftDateTo.value = null
|
||||
draftEntityTypes.value = []
|
||||
draftAction.value = ''
|
||||
draftPerformedBy.value = ''
|
||||
|
||||
async function resetFilters(): Promise<void> {
|
||||
watchersSuspended = true
|
||||
filters.performedAtAfter = undefined
|
||||
filters.performedAtBefore = undefined
|
||||
filters.entityType = undefined
|
||||
filters.performedBy = undefined
|
||||
filters.action = undefined
|
||||
filters.performedBy = undefined
|
||||
filters.page = 1
|
||||
selectedEntityTypes.value = []
|
||||
performedByInput.value = ''
|
||||
actionValue.value = ''
|
||||
// Les watchers mute de Vue 3 se planifient en microtask : on attend
|
||||
// leur execution avec le flag `true`, puis on libere.
|
||||
await nextTick()
|
||||
watchersSuspended = false
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
// "Voir les resultats" : applique le brouillon, recharge et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
filters.performedAtAfter = draftDateFrom.value ?? undefined
|
||||
filters.performedAtBefore = draftDateTo.value ?? undefined
|
||||
filters.entityType = draftEntityTypes.value.length > 0 ? [...draftEntityTypes.value] : undefined
|
||||
filters.action = draftAction.value === '' ? undefined : draftAction.value
|
||||
filters.performedBy = draftPerformedBy.value.trim() === '' ? undefined : draftPerformedBy.value.trim()
|
||||
filters.page = 1
|
||||
filterDrawerOpen.value = false
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
@@ -291,7 +324,8 @@ async function loadEntries(): Promise<void> {
|
||||
try {
|
||||
const data = await fetchLogsCached({
|
||||
...filters,
|
||||
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
|
||||
// MalioDateTime fournit une date+heure sans fuseau (heure locale) ;
|
||||
// on la convertit en ISO UTC pour l'API (bornes exactes, intervalle inclusif).
|
||||
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
||||
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
||||
})
|
||||
@@ -315,14 +349,9 @@ async function loadEntries(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
|
||||
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
|
||||
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
|
||||
const debouncedReload = debounce(() => loadEntries(), 300)
|
||||
|
||||
function toIso(localDateTime: string): string {
|
||||
// datetime-local n'a pas de timezone : on assume heure locale et on
|
||||
// laisse le navigateur generer l'ISO via Date().
|
||||
// MalioDateTime emet une date+heure sans fuseau (heure murale locale) ;
|
||||
// on laisse Date() generer l'ISO UTC correspondant pour l'API.
|
||||
return new Date(localDateTime).toISOString()
|
||||
}
|
||||
|
||||
@@ -368,43 +397,6 @@ function onPerPageChange(value: number): void {
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
|
||||
watch(selectedEntityTypes, values => {
|
||||
if (watchersSuspended) return
|
||||
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
// Sync MalioSelect action -> filters.action.
|
||||
watch(actionValue, value => {
|
||||
if (watchersSuspended) return
|
||||
filters.action = value === '' ? undefined : value
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
|
||||
// refetch par caractere. Le reset passe par debouncedReload egalement pour
|
||||
// coalescer si plusieurs watchers tirent en meme temps.
|
||||
watch(performedByInput, value => {
|
||||
if (watchersSuspended) return
|
||||
filters.performedBy = value === '' ? undefined : value
|
||||
filters.page = 1
|
||||
debouncedReload()
|
||||
})
|
||||
|
||||
// Synchronisation reactive : tout changement de dates declenche un fetch +
|
||||
// reset de la pagination a la page 1.
|
||||
watch(
|
||||
() => [filters.performedAtAfter, filters.performedAtBefore],
|
||||
() => {
|
||||
if (watchersSuspended) return
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// Charge les entity types en parallele de la liste principale : un
|
||||
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
||||
|
||||
Reference in New Issue
Block a user