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>
275 lines
9.3 KiB
Vue
275 lines
9.3 KiB
Vue
<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>
|