0dfdaf3300
- 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
413 lines
16 KiB
Vue
413 lines
16 KiB
Vue
<template>
|
|
<div>
|
|
<PageHeader>
|
|
{{ t('admin.auditLog.title') }}
|
|
<template #actions>
|
|
<MalioButton
|
|
variant="tertiary"
|
|
:label="t('audit.filters.title')"
|
|
icon-name="mdi:equalizer"
|
|
icon-position="left"
|
|
button-class="w-[184px] justify-start gap-4 text-black"
|
|
@click="openFilters"
|
|
/>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<!-- Tableau -->
|
|
<MalioDataTable
|
|
:columns="columns"
|
|
:items="rows"
|
|
:total-items="totalItems"
|
|
:page="filters.page ?? 1"
|
|
:per-page="filters.itemsPerPage ?? 10"
|
|
:per-page-options="[10, 25, 50]"
|
|
:empty-message="isFiltered ? t('audit.no_results') : t('audit.empty')"
|
|
@update:page="onPageChange"
|
|
@update:per-page="onPerPageChange"
|
|
@row-click="onRowClick"
|
|
>
|
|
<template #cell-action="{ item }">
|
|
<span
|
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
|
:class="actionBadgeClass(item.action as string)"
|
|
>
|
|
{{ t(`audit.action.${item.action}`) }}
|
|
</span>
|
|
</template>
|
|
<template #cell-entityType="{ item }">
|
|
<span
|
|
class="text-xs"
|
|
:title="item.entityType as string"
|
|
>{{ formatEntityType(item.entityType as string) }}</span>
|
|
</template>
|
|
<template #cell-entityId="{ item }">
|
|
<span class="font-mono text-xs">{{ item.entityId }}</span>
|
|
</template>
|
|
<template #cell-summary="{ item }">
|
|
<span class="text-xs text-gray-600">{{ item.summary }}</span>
|
|
</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"
|
|
drawer-class="max-w-2xl"
|
|
>
|
|
<template #header>
|
|
<h2 class="text-[24px] font-bold">
|
|
{{ drawerTitle }}
|
|
</h2>
|
|
</template>
|
|
<div v-if="selectedEntry">
|
|
<AuditLogDetail :entry="selectedEntry" />
|
|
<div class="mt-4 border-t border-gray-200 pt-3">
|
|
<h3
|
|
class="text-sm font-medium text-gray-700 mb-2"
|
|
:title="selectedEntry.entityType"
|
|
>
|
|
{{ formatEntityType(selectedEntry.entityType) }} #{{ selectedEntry.entityId }}
|
|
</h3>
|
|
<AuditTimeline
|
|
:entity-type="selectedEntry.entityType"
|
|
:entity-id="selectedEntry.entityId"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</MalioDrawer>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
|
|
|
const { t, te } = useI18n()
|
|
const { can } = usePermissions()
|
|
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
|
|
|
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
|
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
|
|
// traduction n'existe, on retombe sur l'identifiant brut pour rester debug-friendly.
|
|
function formatEntityType(type: string): string {
|
|
const key = `audit.entity.${type.toLowerCase().replace(/\./g, '_')}`
|
|
return te(key) ? t(key) : type
|
|
}
|
|
|
|
// Protection cote UI : le middleware `modules.global.ts` filtre deja les
|
|
// routes desactivees, mais si quelqu'un atterit ici sans la permission on
|
|
// renvoie une 403 plutot que de flasher un ecran vide.
|
|
if (!can('core.audit_log.view')) {
|
|
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
|
|
}
|
|
|
|
useHead({ title: t('admin.auditLog.title') })
|
|
|
|
// 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,
|
|
entityType: undefined,
|
|
performedBy: undefined,
|
|
action: undefined,
|
|
page: 1,
|
|
itemsPerPage: 10,
|
|
})
|
|
|
|
// 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[]>([])
|
|
const entityTypeOptions = computed(() =>
|
|
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
|
)
|
|
|
|
// Actions : '' = "toutes". Sert d'options aux boutons radio.
|
|
const actionOptions = [
|
|
{ value: '', label: t('audit.filters.all_actions') },
|
|
{ value: 'create', label: t('audit.action.create') },
|
|
{ value: 'update', label: t('audit.action.update') },
|
|
{ value: 'delete', label: t('audit.action.delete') },
|
|
]
|
|
|
|
const entries = ref<AuditLogEntry[]>([])
|
|
const totalItems = ref(0)
|
|
const loading = ref(false)
|
|
|
|
const drawerOpen = ref(false)
|
|
const selectedEntry = ref<AuditLogEntry | null>(null)
|
|
|
|
const columns = [
|
|
{ key: 'performedAt', label: t('admin.auditLog.table.performedAt') },
|
|
{ key: 'performedBy', label: t('admin.auditLog.table.performedBy') },
|
|
{ key: 'entityType', label: t('admin.auditLog.table.entityType') },
|
|
{ key: 'entityId', label: t('admin.auditLog.table.entityId') },
|
|
{ key: 'action', label: t('admin.auditLog.table.action') },
|
|
{ key: 'summary', label: t('admin.auditLog.table.summary') },
|
|
]
|
|
|
|
// Transforme chaque AuditLogEntry en ligne compatible MalioDataTable.
|
|
// On conserve `id` pour retrouver l'entry complete sur row-click.
|
|
const rows = computed(() =>
|
|
entries.value.map(entry => ({
|
|
id: entry.id,
|
|
performedAt: formatDate(entry.performedAt),
|
|
performedBy: entry.performedBy,
|
|
entityType: entry.entityType,
|
|
entityId: entry.entityId,
|
|
action: entry.action,
|
|
summary: summarize(entry),
|
|
})),
|
|
)
|
|
|
|
const drawerTitle = computed(() =>
|
|
selectedEntry.value
|
|
? `${formatEntityType(selectedEntry.value.entityType)} #${selectedEntry.value.entityId}`
|
|
: t('audit.detail_title'),
|
|
)
|
|
|
|
const isFiltered = computed(() =>
|
|
Boolean(filters.performedAtAfter || filters.performedAtBefore
|
|
|| (Array.isArray(filters.entityType) ? filters.entityType.length : filters.entityType)
|
|
|| filters.performedBy || filters.action),
|
|
)
|
|
|
|
// Anti-race : chaque fetch incremente un compteur ; seul le dernier en date
|
|
// ecrit les resultats dans `entries`/`totalItems`. Evite qu'une reponse tardive
|
|
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
|
let requestToken = 0
|
|
|
|
// 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 = ''
|
|
|
|
filters.performedAtAfter = undefined
|
|
filters.performedAtBefore = undefined
|
|
filters.entityType = undefined
|
|
filters.action = undefined
|
|
filters.performedBy = undefined
|
|
filters.page = 1
|
|
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()
|
|
}
|
|
|
|
async function loadEntries(): Promise<void> {
|
|
const token = ++requestToken
|
|
loading.value = true
|
|
try {
|
|
const data = await fetchLogsCached({
|
|
...filters,
|
|
// 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,
|
|
})
|
|
// Reponse obsolete (un fetch plus recent a ete lance entre-temps) :
|
|
// on ignore le resultat pour ne pas overwrite l'etat courant.
|
|
if (token !== requestToken) return
|
|
entries.value = data.member ?? []
|
|
totalItems.value = data.totalItems ?? 0
|
|
} catch {
|
|
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas
|
|
// laisser l'utilisateur croire que les donnees affichees sont a jour.
|
|
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog.
|
|
if (token === requestToken) {
|
|
entries.value = []
|
|
totalItems.value = 0
|
|
}
|
|
} finally {
|
|
if (token === requestToken) {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
function toIso(localDateTime: string): string {
|
|
// 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()
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleString('fr-FR', {
|
|
dateStyle: 'short',
|
|
timeStyle: 'short',
|
|
})
|
|
}
|
|
|
|
function actionBadgeClass(action: string): string {
|
|
switch (action) {
|
|
case 'create': return 'bg-green-100 text-green-800'
|
|
case 'update': return 'bg-yellow-100 text-yellow-800'
|
|
case 'delete': return 'bg-red-100 text-red-800'
|
|
default: return 'bg-gray-100 text-gray-800'
|
|
}
|
|
}
|
|
|
|
function summarize(entry: AuditLogEntry): string {
|
|
const keys = Object.keys(entry.changes)
|
|
if (keys.length === 0) return '—'
|
|
if (keys.length <= 3) return keys.join(', ')
|
|
return `${keys.slice(0, 3).join(', ')}… (+${keys.length - 3})`
|
|
}
|
|
|
|
function onRowClick(item: Record<string, unknown>): void {
|
|
const entry = entries.value.find(e => e.id === item.id)
|
|
if (entry) {
|
|
selectedEntry.value = entry
|
|
drawerOpen.value = true
|
|
}
|
|
}
|
|
|
|
function onPageChange(value: number): void {
|
|
filters.page = value
|
|
loadEntries()
|
|
}
|
|
|
|
function onPerPageChange(value: number): void {
|
|
filters.itemsPerPage = value
|
|
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
|
|
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
|
|
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
|
|
try {
|
|
entityTypes.value = await fetchEntityTypes()
|
|
} catch {
|
|
entityTypes.value = []
|
|
}
|
|
await loadEntries()
|
|
})
|
|
</script>
|