feat(activity-log) : add global activity log page with filters and pagination
New /activity-log page showing all audit entries across pieces, products and composants. Includes entity type and action filters, expandable diffs, clickable entity links and pagination. Navbar link added under Ressources liées. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -275,11 +275,12 @@ const navGroups: NavGroup[] = [
|
||||
{
|
||||
id: 'resources',
|
||||
label: 'Ressources liées',
|
||||
activePaths: ['/sites', '/documents', '/constructeurs'],
|
||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'],
|
||||
children: [
|
||||
{ to: '/sites', label: 'Sites' },
|
||||
{ to: '/documents', label: 'Documents' },
|
||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||
{ to: '/activity-log', label: 'Journal d\'activité' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
70
app/composables/useActivityLog.ts
Normal file
70
app/composables/useActivityLog.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
export type ActivityLogActor = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type ActivityLogEntry = {
|
||||
id: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
entityName: string | null
|
||||
entityRef: string | null
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: ActivityLogActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface LoadActivityLogOptions {
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
entityType?: string
|
||||
action?: string
|
||||
}
|
||||
|
||||
export function useActivityLog() {
|
||||
const { get } = useApi()
|
||||
|
||||
const entries = ref<ActivityLogEntry[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadActivityLog = async (options: LoadActivityLogOptions = {}) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(options.page ?? 1))
|
||||
params.set('itemsPerPage', String(options.itemsPerPage ?? 30))
|
||||
if (options.entityType) params.set('entityType', options.entityType)
|
||||
if (options.action) params.set('action', options.action)
|
||||
|
||||
const result = await get(`/activity-logs?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger le journal d\'activité.'
|
||||
entries.value = []
|
||||
return result
|
||||
}
|
||||
|
||||
const data = result.data as any
|
||||
entries.value = Array.isArray(data?.items) ? data.items : []
|
||||
total.value = typeof data?.total === 'number' ? data.total : entries.value.length
|
||||
|
||||
return { success: true, data: entries.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
entries.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, total, loading, error, loadActivityLog }
|
||||
}
|
||||
274
app/pages/activity-log.vue
Normal file
274
app/pages/activity-log.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-10 space-y-8">
|
||||
<header>
|
||||
<h1 class="text-3xl font-semibold text-base-content">Journal d'activité</h1>
|
||||
<p class="text-sm text-gray-500">
|
||||
Historique des modifications sur l'ensemble des pièces, produits et composants.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="activity-entity-type"
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id="activity-entity-type"
|
||||
v-model="entityTypeFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
<option value="piece">Pièce</option>
|
||||
<option value="product">Produit</option>
|
||||
<option value="composant">Composant</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="activity-action"
|
||||
>
|
||||
Action
|
||||
</label>
|
||||
<select
|
||||
id="activity-action"
|
||||
v-model="actionFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option value="">Toutes</option>
|
||||
<option value="create">Création</option>
|
||||
<option value="update">Modification</option>
|
||||
<option value="delete">Suppression</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="activity-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="activity-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ entries.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p v-else-if="!total" class="text-sm text-base-content/70">
|
||||
Aucune activité enregistrée.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!entries.length" class="text-sm text-base-content/70">
|
||||
Aucune activité ne correspond à vos filtres.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
<th>Type</th>
|
||||
<th>Entité</th>
|
||||
<th>Auteur</th>
|
||||
<th>Détails</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="entry in entries" :key="entry.id">
|
||||
<tr>
|
||||
<td class="whitespace-nowrap">{{ formatHistoryDate(entry.createdAt) }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="actionBadgeClass(entry.action)"
|
||||
>
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-ghost badge-sm">
|
||||
{{ entityTypeLabel(entry.entityType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="entry.action !== 'delete'"
|
||||
:to="entityEditLink(entry)"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ entry.entityName || 'Sans nom' }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-base-content/50 line-through">
|
||||
{{ entry.entityName || 'Sans nom' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.entityRef"
|
||||
class="text-xs text-base-content/50 ml-1"
|
||||
>
|
||||
({{ entry.entityRef }})
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ entry.actor?.label || '—' }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="hasDiff(entry)"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="toggleExpanded(entry.id)"
|
||||
>
|
||||
{{ expandedIds.has(entry.id) ? 'Masquer' : 'Voir' }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-base-content/50">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="expandedIds.has(entry.id)">
|
||||
<td colspan="6" class="bg-base-200/50 p-4">
|
||||
<div class="space-y-1 text-sm">
|
||||
<div
|
||||
v-for="diffEntry in historyDiffEntries(entry, globalFieldLabels)"
|
||||
:key="diffEntry.field"
|
||||
class="flex gap-2"
|
||||
>
|
||||
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
|
||||
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
|
||||
<span>→</span>
|
||||
<span class="text-success">{{ diffEntry.toLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useActivityLog } from '~/composables/useActivityLog'
|
||||
import type { ActivityLogEntry } from '~/composables/useActivityLog'
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
|
||||
const { entries, total, loading, loadActivityLog } = useActivityLog()
|
||||
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(50)
|
||||
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
|
||||
|
||||
const entityTypeFilter = ref('')
|
||||
const actionFilter = ref('')
|
||||
|
||||
const expandedIds = reactive(new Set<string>())
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
if (expandedIds.has(id)) expandedIds.delete(id)
|
||||
else expandedIds.add(id)
|
||||
}
|
||||
|
||||
const hasDiff = (entry: ActivityLogEntry) =>
|
||||
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
|
||||
|
||||
const fetchLog = () => {
|
||||
loadActivityLog({
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
entityType: entityTypeFilter.value || undefined,
|
||||
action: actionFilter.value || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchLog()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchLog()
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
piece: 'Pièce',
|
||||
product: 'Produit',
|
||||
composant: 'Composant',
|
||||
}
|
||||
|
||||
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
|
||||
|
||||
const ENTITY_EDIT_ROUTES: Record<string, string> = {
|
||||
piece: '/pieces',
|
||||
product: '/product',
|
||||
composant: '/component',
|
||||
}
|
||||
|
||||
const entityEditLink = (entry: ActivityLogEntry) => {
|
||||
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? ''
|
||||
return base ? `${base}/${entry.entityId}/edit` : '#'
|
||||
}
|
||||
|
||||
const actionBadgeClass = (action: string) => {
|
||||
if (action === 'create') return 'badge-success'
|
||||
if (action === 'delete') return 'badge-error'
|
||||
return 'badge-warning'
|
||||
}
|
||||
|
||||
const globalFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
prix: 'Prix',
|
||||
supplierPrice: 'Prix fournisseur',
|
||||
typePiece: 'Type de pièce',
|
||||
typeProduct: 'Type de produit',
|
||||
typeComposant: 'Type de composant',
|
||||
product: 'Produit',
|
||||
productIds: 'Produits',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
structure: 'Structure',
|
||||
}
|
||||
|
||||
onMounted(fetchLog)
|
||||
</script>
|
||||
Reference in New Issue
Block a user