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"
|
"load_more": "Voir plus"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
|
"title": "Filtres",
|
||||||
|
"apply": "Voir les résultats",
|
||||||
"reset": "Réinitialiser",
|
"reset": "Réinitialiser",
|
||||||
|
"date_range": "Date à date",
|
||||||
"date_from": "Du",
|
"date_from": "Du",
|
||||||
"date_to": "Au",
|
"date_to": "Au",
|
||||||
"entity_type": "Type d'entité",
|
"entity_type": "Type d'entité",
|
||||||
|
|||||||
@@ -1,91 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PageHeader>{{ t('admin.auditLog.title') }}</PageHeader>
|
<PageHeader>
|
||||||
|
{{ t('admin.auditLog.title') }}
|
||||||
<!-- Filtres -->
|
<template #actions>
|
||||||
<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">
|
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
:label="t('audit.filters.reset')"
|
:label="t('audit.filters.title')"
|
||||||
button-class="text-xs"
|
icon-name="mdi:equalizer"
|
||||||
@click="resetFilters"
|
icon-position="left"
|
||||||
|
button-class="w-[184px] justify-start gap-4 text-black"
|
||||||
|
@click="openFilters"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</section>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Tableau -->
|
<!-- Tableau -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-4"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="rows"
|
:items="rows"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
@@ -119,6 +49,83 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioDataTable>
|
</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 -->
|
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
v-model="drawerOpen"
|
v-model="drawerOpen"
|
||||||
@@ -149,7 +156,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||||
|
|
||||||
const { t, te } = useI18n()
|
const { t, te } = useI18n()
|
||||||
@@ -173,8 +180,11 @@ if (!can('core.audit_log.view')) {
|
|||||||
|
|
||||||
useHead({ title: t('admin.auditLog.title') })
|
useHead({ title: t('admin.auditLog.title') })
|
||||||
|
|
||||||
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
// Etat des filtres APPLIQUES : pilote `loadEntries`. Local uniquement, JAMAIS
|
||||||
// CLAUDE.md "Tableau : pas de persistance URL").
|
// 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>({
|
const filters = reactive<AuditLogFilters>({
|
||||||
performedAtAfter: undefined,
|
performedAtAfter: undefined,
|
||||||
performedAtBefore: undefined,
|
performedAtBefore: undefined,
|
||||||
@@ -185,26 +195,23 @@ const filters = reactive<AuditLogFilters>({
|
|||||||
itemsPerPage: 10,
|
itemsPerPage: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
|
// Etat BROUILLON du drawer de filtres : edite librement, recopie dans `filters`
|
||||||
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
|
// uniquement au clic sur "Voir les resultats". Permet d'annuler une saisie en
|
||||||
const selectedEntityTypes = ref<(string | number)[]>([])
|
// 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[]>([])
|
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(() =>
|
const entityTypeOptions = computed(() =>
|
||||||
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
|
// Actions : '' = "toutes". Sert d'options aux boutons radio.
|
||||||
// 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>('')
|
|
||||||
const actionOptions = [
|
const actionOptions = [
|
||||||
{ value: '', label: t('audit.filters.all_actions') },
|
{ value: '', label: t('audit.filters.all_actions') },
|
||||||
{ value: 'create', label: t('audit.action.create') },
|
{ value: 'create', label: t('audit.action.create') },
|
||||||
@@ -259,29 +266,55 @@ const isFiltered = computed(() =>
|
|||||||
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
||||||
let requestToken = 0
|
let requestToken = 0
|
||||||
|
|
||||||
// Pendant un reset, on suspend temporairement les watchers pour ne pas
|
// Ouvre le drawer en recopiant l'etat applique vers le brouillon, pour que la
|
||||||
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
|
// reouverture reflete les filtres actifs.
|
||||||
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
|
function openFilters(): void {
|
||||||
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
|
draftDateFrom.value = filters.performedAtAfter ?? null
|
||||||
// et les fetchs partent quand meme. Un seul loadEntries() est appele
|
draftDateTo.value = filters.performedAtBefore ?? null
|
||||||
// explicitement apres la liberation.
|
draftEntityTypes.value = Array.isArray(filters.entityType)
|
||||||
let watchersSuspended = false
|
? [...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.performedAtAfter = undefined
|
||||||
filters.performedAtBefore = undefined
|
filters.performedAtBefore = undefined
|
||||||
filters.entityType = undefined
|
filters.entityType = undefined
|
||||||
filters.performedBy = undefined
|
|
||||||
filters.action = undefined
|
filters.action = undefined
|
||||||
|
filters.performedBy = undefined
|
||||||
filters.page = 1
|
filters.page = 1
|
||||||
selectedEntityTypes.value = []
|
loadEntries()
|
||||||
performedByInput.value = ''
|
}
|
||||||
actionValue.value = ''
|
|
||||||
// Les watchers mute de Vue 3 se planifient en microtask : on attend
|
// "Voir les resultats" : applique le brouillon, recharge et ferme le drawer.
|
||||||
// leur execution avec le flag `true`, puis on libere.
|
function applyFilters(): void {
|
||||||
await nextTick()
|
filters.performedAtAfter = draftDateFrom.value ?? undefined
|
||||||
watchersSuspended = false
|
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()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +324,8 @@ async function loadEntries(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const data = await fetchLogsCached({
|
const data = await fetchLogsCached({
|
||||||
...filters,
|
...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,
|
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
||||||
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : 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 {
|
function toIso(localDateTime: string): string {
|
||||||
// datetime-local n'a pas de timezone : on assume heure locale et on
|
// MalioDateTime emet une date+heure sans fuseau (heure murale locale) ;
|
||||||
// laisse le navigateur generer l'ISO via Date().
|
// on laisse Date() generer l'ISO UTC correspondant pour l'API.
|
||||||
return new Date(localDateTime).toISOString()
|
return new Date(localDateTime).toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,43 +397,6 @@ function onPerPageChange(value: number): void {
|
|||||||
loadEntries()
|
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 () => {
|
onMounted(async () => {
|
||||||
// Charge les entity types en parallele de la liste principale : un
|
// Charge les entity types en parallele de la liste principale : un
|
||||||
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
||||||
|
|||||||
Reference in New Issue
Block a user