feat(audit) : refonte écran journal (MalioDataTable + drawers filtre & détail)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:26:20 +02:00
parent 32b1af2377
commit 34ed3d0222
+212 -220
View File
@@ -1,254 +1,246 @@
<template> <template>
<div class="h-full flex flex-col overflow-hidden"> <div class="h-full flex flex-col overflow-hidden">
<h1 class="text-4xl font-bold text-primary-500 pb-6">Journal des actions</h1> <div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Journal des actions</h1>
<MalioButton
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
@click="list.openFilters()"
/>
</div>
<div class="flex items-end gap-4 pb-6 flex-wrap"> <div class="min-h-0 flex-1 overflow-auto">
<div> <MalioDataTable
<label class="text-md font-semibold text-neutral-700">Employé</label> :columns="columns"
<select :items="list.items.value"
v-model="filters.employeeId" :total-items="list.total.value"
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20" :page="list.page.value"
> :per-page="list.perPage.value"
<option :value="undefined">Tous</option> :per-page-options="[25, 50, 100]"
<option v-for="emp in employees" :key="emp.id" :value="emp.id"> empty-message="Aucune entrée trouvée."
{{ emp.lastName }} {{ emp.firstName }} @row-click="openDetail"
</option> @update:page="list.goToPage($event)"
</select> @update:per-page="list.setPerPage($event)"
</div>
<div>
<label class="text-md font-semibold text-neutral-700">Du</label>
<input
v-model="filters.from"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">Au</label>
<input
v-model="filters.to"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">Type</label>
<select
v-model="filters.entityType"
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
>
<option :value="undefined">Tous</option>
<option value="work_hour">Heures</option>
<option value="absence">Absences</option>
<option value="employee">Employé</option>
<option value="contract_suspension">Suspension</option>
<option value="rtt_payment">Paiement RTT</option>
<option value="fractioned_days">Jours fractionnés</option>
<option value="paid_leave_days">Congés N-1 payés</option>
</select>
</div>
<button
type="button"
class="h-[42px] rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="search"
> >
Rechercher <template #cell-createdAt="{ item }">
</button> {{ formatDateTime((item as AuditLog).createdAt) }}
</template>
<template #cell-action="{ item }">
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass((item as AuditLog).action)">
{{ actionLabel((item as AuditLog).action) }}
</span>
</template>
<template #cell-entityType="{ item }">
{{ entityTypeLabel((item as AuditLog).entityType) }}
</template>
<template #cell-employeeName="{ item }">
{{ (item as AuditLog).employeeName ?? '—' }}
</template>
<template #cell-deviceLabel="{ item }">
{{ (item as AuditLog).deviceLabel ?? '—' }}
</template>
<template #cell-description="{ item }">
<span class="block max-w-[320px] truncate" :title="(item as AuditLog).description">{{ (item as AuditLog).description }}</span>
</template>
</MalioDataTable>
</div> </div>
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"> <!-- Filter drawer -->
Chargement... <MalioDrawer
</div> v-model="list.filterOpen.value"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[32px] font-semibold text-primary-500">Filtres</h2>
</template>
<div v-else-if="logs.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"> <MalioAccordion>
Aucune entrée trouvée. <MalioAccordionItem title="Période" value="period">
</div> <MalioDateRange v-model="list.draftRange.value" clearable />
</MalioAccordionItem>
<template v-else> <MalioAccordionItem title="Employé" value="employee">
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-white"> <MalioSelect
<div class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10"> v-model="list.draftEmployeeId.value"
<span>Date action</span> :options="list.employeeOptions.value"
<span>Utilisateur</span> empty-option-label="Tous"
<span>Action</span> />
<span>Type</span> </MalioAccordionItem>
<span>Employé</span>
<span>Description</span> <MalioAccordionItem title="Type d'entité" value="entityType">
<span>Date affectée</span> <div class="flex flex-col">
</div> <MalioCheckbox
<div class="border-x border-b border-primary-500 rounded-b-md"> v-for="opt in entityTypeOptions"
<template v-for="log in logs" :key="log.id"> :id="`filter-type-${opt.value}`"
<div :key="opt.value"
class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500" :label="opt.label"
@click="toggleExpand(log.id)" :model-value="list.draftEntityTypes.value.includes(opt.value)"
> @update:model-value="(val: boolean) => list.toggleEntityType(opt.value, val)"
<span>{{ formatDateTime(log.createdAt) }}</span> />
<span>{{ log.username }}</span> </div>
<span> </MalioAccordionItem>
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(log.action)">
{{ actionLabel(log.action) }} <MalioAccordionItem title="Action" value="action">
</span> <div class="flex flex-col">
</span> <MalioCheckbox
<span>{{ entityTypeLabel(log.entityType) }}</span> v-for="opt in actionOptions"
<span>{{ log.employeeName ?? '-' }}</span> :id="`filter-action-${opt.value}`"
<span class="truncate font-normal" :title="log.description">{{ log.description }}</span> :key="opt.value"
<span>{{ log.affectedDate ? formatDate(log.affectedDate) : '-' }}</span> :label="opt.label"
:model-value="list.draftActions.value.includes(opt.value)"
@update:model-value="(val: boolean) => list.toggleAction(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<MalioAccordionItem title="Utilisateur / compte" value="username">
<MalioInputText v-model="list.draftUsername.value" icon-name="mdi:magnify" />
</MalioAccordionItem>
<MalioAccordionItem title="IP" value="ip">
<MalioInputText v-model="list.draftIp.value" icon-name="mdi:magnify" />
</MalioAccordionItem>
<MalioAccordionItem title="Appareil" value="device">
<MalioInputText v-model="list.draftDevice.value" icon-name="mdi:magnify" />
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton variant="tertiary" label="Réinitialiser" @click="list.resetFilters()" />
<MalioButton variant="primary" label="Appliquer" button-class="w-[170px]" @click="list.applyFilters()" />
</template>
</MalioDrawer>
<!-- Detail drawer -->
<MalioDrawer v-model="detailOpen" drawer-class="max-w-xl">
<template #header>
<h2 class="text-[32px] font-semibold text-primary-500">Détail de l'action</h2>
</template>
<div v-if="selected" class="space-y-6 text-md text-primary-500">
<section class="space-y-1">
<p><span class="font-semibold">Utilisateur :</span> {{ selected.username }}</p>
<p><span class="font-semibold">Employé :</span> {{ selected.employeeName ?? '—' }}</p>
<p><span class="font-semibold">Date action :</span> {{ formatDateTime(selected.createdAt) }}</p>
<p><span class="font-semibold">Date affectée :</span> {{ selected.affectedDate ? formatDate(selected.affectedDate) : '—' }}</p>
<p>
<span class="font-semibold">Action :</span>
<span class="ml-1 rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(selected.action)">{{ actionLabel(selected.action) }}</span>
</p>
<p><span class="font-semibold">Type :</span> {{ entityTypeLabel(selected.entityType) }}</p>
</section>
<section class="space-y-1">
<h3 class="font-bold">Contexte technique</h3>
<p><span class="font-semibold">IP :</span> {{ selected.ipAddress ?? '—' }}</p>
<p><span class="font-semibold">Appareil :</span> {{ selected.deviceLabel ?? '—' }}</p>
<p><span class="font-semibold">User-Agent :</span> <span class="break-all text-sm font-normal">{{ selected.userAgent ?? '—' }}</span></p>
<p><span class="font-semibold">Device id :</span> <span class="break-all text-sm font-normal">{{ selected.deviceId ?? '—' }}</span></p>
</section>
<section class="space-y-1">
<h3 class="font-bold">Changements</h3>
<div v-if="changeRows.length > 0" class="space-y-1">
<div v-for="row in changeRows" :key="row.key" class="text-sm">
<span class="font-semibold">{{ row.key }} :</span>
<span class="text-red-600">{{ row.old }}</span>
<span class="px-1">→</span>
<span class="text-green-600">{{ row.new }}</span>
</div> </div>
</div>
<div <p v-else class="text-sm font-normal text-neutral-400">Aucun détail de modification.</p>
v-if="expandedIds.has(log.id)" </section>
class="border-b border-primary-500 px-6 py-4 bg-neutral-50"
>
<div v-if="log.changes" class="grid grid-cols-2 gap-6 text-sm font-mono">
<div v-if="log.changes.old">
<p class="font-bold text-red-600 mb-2">Ancien</p>
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.old, null, 2) }}</pre>
</div>
<div v-if="log.changes.new">
<p class="font-bold text-green-600 mb-2">Nouveau</p>
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.new, null, 2) }}</pre>
</div>
</div>
<p v-else class="text-md text-neutral-400">Pas de détail disponible.</p>
</div>
</template>
</div>
</div> </div>
</MalioDrawer>
<div class="flex items-center justify-between pt-4">
<p class="text-md text-neutral-500">
{{ total }} résultat{{ total > 1 ? 's' : '' }} page {{ currentPage }}/{{ totalPages }}
</p>
<div class="flex gap-3">
<button
type="button"
:disabled="currentPage <= 1"
class="rounded-lg border border-primary-500 px-4 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500 disabled:opacity-30 disabled:cursor-not-allowed"
@click="goToPage(currentPage - 1)"
>
Précédent
</button>
<button
type="button"
:disabled="currentPage >= totalPages"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-30 disabled:cursor-not-allowed"
@click="goToPage(currentPage + 1)"
>
Suivant
</button>
</div>
</div>
</template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, computed } from 'vue'
import type { AuditLog } from '~/services/dto/audit-log' import type { AuditLog } from '~/services/dto/audit-log'
import type { Employee } from '~/services/dto/employee' import { useAuditLogsList } from '~/composables/useAuditLogsList'
import { fetchAuditLogs } from '~/services/audit-logs'
import { listEmployees } from '~/services/employees'
definePageMeta({
middleware: 'super-admin'
})
definePageMeta({ middleware: 'super-admin' })
useHead({ title: 'Journal des actions' }) useHead({ title: 'Journal des actions' })
const logs = ref<AuditLog[]>([]) const list = useAuditLogsList()
const employees = ref<Employee[]>([])
const isLoading = ref(false)
const expandedIds = ref(new Set<number>())
const total = ref(0)
const currentPage = ref(1)
const perPage = ref(50)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value))) const columns = [
{ key: 'createdAt', label: 'Date action' },
{ key: 'username', label: 'Utilisateur' },
{ key: 'action', label: 'Action' },
{ key: 'entityType', label: 'Type' },
{ key: 'employeeName', label: 'Employé' },
{ key: 'deviceLabel', label: 'Appareil' },
{ key: 'description', label: 'Description' },
]
const filters = reactive<{ const entityTypeOptions = [
employeeId?: number { value: 'work_hour', label: 'Heures' },
from?: string { value: 'absence', label: 'Absence' },
to?: string { value: 'employee', label: 'Employé' },
entityType?: string { value: 'contract_suspension', label: 'Suspension' },
}>({}) { value: 'rtt_payment', label: 'RTT' },
{ value: 'fractioned_days', label: 'Fract.' },
{ value: 'paid_leave_days', label: 'Congés payés' },
{ value: 'week_comment', label: 'Commentaire' },
]
const loadLogs = async (page = 1) => { const actionOptions = [
isLoading.value = true { value: 'create', label: 'Créer' },
try { { value: 'update', label: 'Modifier' },
const result = await fetchAuditLogs({ ...filters, page }) { value: 'delete', label: 'Supprimer' },
logs.value = result.items { value: 'validate', label: 'Valider' },
total.value = result.total { value: 'site_validate', label: 'Valider (site)' },
currentPage.value = result.page ]
perPage.value = result.perPage
expandedIds.value.clear() const filterButtonLabel = computed(() =>
} finally { list.activeFilterCount.value > 0 ? `Filtrer (${list.activeFilterCount.value})` : 'Filtrer',
isLoading.value = false )
}
// Detail drawer
const detailOpen = ref(false)
const selected = ref<AuditLog | null>(null)
const openDetail = (item: Record<string, unknown>) => {
selected.value = item as unknown as AuditLog
detailOpen.value = true
} }
const search = () => { const changeRows = computed(() => {
loadLogs(1) const c = selected.value?.changes
} if (!c) return []
const keys = new Set<string>([...Object.keys(c.old ?? {}), ...Object.keys(c.new ?? {})])
const goToPage = (page: number) => { return [...keys].map(key => ({
if (page >= 1 && page <= totalPages.value) { key,
loadLogs(page) old: c.old?.[key] === undefined ? '—' : JSON.stringify(c.old[key]),
} new: c.new?.[key] === undefined ? '—' : JSON.stringify(c.new[key]),
} }))
})
const toggleExpand = (id: number) => {
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id)
} else {
expandedIds.value.add(id)
}
}
const formatDateTime = (dt: string) => { const formatDateTime = (dt: string) => {
const d = new Date(dt) const d = new Date(dt)
return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
} }
const formatDate = (d: string) => { const formatDate = (d: string) => d.split('-').reverse().join('/')
return d.split('-').reverse().join('/')
}
const actionLabel = (action: string): string => { const actionLabel = (action: string): string => ({
const map: Record<string, string> = { create: 'Créer', update: 'Modifier', delete: 'Suppr.', validate: 'Valid.', site_validate: 'Valid. site',
create: 'Créer', }[action] ?? action)
update: 'Modifier',
delete: 'Suppr.',
validate: 'Valid.',
site_validate: 'Valid. site',
}
return map[action] ?? action
}
const actionClass = (action: string): string => { const actionClass = (action: string): string => ({
const map: Record<string, string> = { create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500', validate: 'bg-purple-500', site_validate: 'bg-indigo-500',
create: 'bg-green-500', }[action] ?? 'bg-neutral-500')
update: 'bg-blue-500',
delete: 'bg-red-500',
validate: 'bg-purple-500',
site_validate: 'bg-indigo-500',
}
return map[action] ?? 'bg-neutral-500'
}
const entityTypeLabel = (type: string): string => { const entityTypeLabel = (type: string): string => ({
const map: Record<string, string> = { work_hour: 'Heures', absence: 'Absence', employee: 'Employé', contract_suspension: 'Suspension',
work_hour: 'Heures', rtt_payment: 'RTT', fractioned_days: 'Fract.', paid_leave_days: 'Congés payés', week_comment: 'Commentaire',
absence: 'Absence', }[type] ?? type)
employee: 'Employé',
contract_suspension: 'Suspension',
rtt_payment: 'RTT',
fractioned_days: 'Fract.',
paid_leave_days: 'Congés payés',
}
return map[type] ?? type
}
onMounted(async () => { onMounted(() => { list.init() })
employees.value = await listEmployees()
await loadLogs()
})
</script> </script>