Files
Starseed/frontend/modules/core/pages/admin/audit-log.vue
T
tristan 2a0918bbfe
Auto Tag Develop / tag (push) Successful in 9s
[#ERP-42] Mettre à jour la lib Malio UI (#16)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #16
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-28 12:27:33 +00:00

427 lines
17 KiB
Vue

<template>
<div>
<PageHeader>
{{ t('admin.auditLog.title') }}
<template #actions>
<MalioButton
variant="tertiary"
:label="t('audit.filters.title')"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
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 border-t border-black p-6"
>
<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>
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
<MalioDateTime
v-model="draftDateFrom"
:max="draftDateTo ?? undefined"
/>
<span>{{ t('audit.filters.date_to') }}</span>
<MalioDateTime
v-model="draftDateTo"
:min="draftDateFrom ?? undefined"
/>
</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">
<MalioRadioButton
v-for="opt in actionOptions"
:key="opt.value"
v-model="draftAction"
name="audit-action"
:value="opt.value"
:label="opt.label"
/>
</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()
const toast = useToast()
// 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). Les valeurs
// sont uniques par construction (v-for sur entityTypeOptions), pas besoin de Set.
function toggleEntity(value: string, selected: boolean): void {
draftEntityTypes.value = selected
? [...draftEntityTypes.value, value]
: draftEntityTypes.value.filter(v => v !== value)
}
// "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 (err) {
// useAuditLog appelle useApi avec { toast: false } pour ne pas multiplier
// les toasts, donc c'est ici qu'on fait remonter l'erreur. Sans ce log+toast,
// une RangeError de `toIso` (date invalide) ou une 500 API laissait l'utilisateur
// devant une table vide indistinguable d'un filtre a zero resultat.
if (token === requestToken) {
entries.value = []
totalItems.value = 0
console.error('[audit-log] loadEntries failed', err)
toast.error({
title: t('audit.error.title'),
message: t('audit.error.message'),
})
}
} 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 ET la liste principale en parallele (TTFD divise
// par 2 sur un backend lent). Le `.catch` du premier garantit qu'un echec
// de /audit-log-entity-types ne bloque pas l'affichage du tableau —
// l'utilisateur perd juste le filtre, pas la page entiere.
await Promise.all([
fetchEntityTypes()
.then(types => { entityTypes.value = types })
.catch(() => { entityTypes.value = [] }),
loadEntries(),
])
})
</script>