3 Commits

Author SHA1 Message Date
Matthieu
635b8f0461 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>
2026-02-10 08:54:12 +01:00
Matthieu
bf74a50f57 feat(catalog) : make category types clickable in catalog pages
Type columns in piece, component and product catalogs now link
directly to the category edit page for quick access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:07 +01:00
Matthieu
7c44778f25 fix(edit-pages): resolve custom field display race condition
The init watcher destructured currentType/currentStructure before
setting selectedTypeId, so the values were stale (null). This caused
refreshCustomFieldInputs to receive null structure → empty definitions,
permanently wiping custom field display on piece and component edit pages.

Read selectedType.value / selectedTypeStructure.value after setting the
ID so the computed is already updated. Also remove the guard on the
piece selectedType watcher that prevented recovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:47:54 +01:00
8 changed files with 384 additions and 9 deletions

View File

@@ -275,11 +275,12 @@ const navGroups: NavGroup[] = [
{ {
id: 'resources', id: 'resources',
label: 'Ressources liées', label: 'Ressources liées',
activePaths: ['/sites', '/documents', '/constructeurs'], activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'],
children: [ children: [
{ to: '/sites', label: 'Sites' }, { to: '/sites', label: 'Sites' },
{ to: '/documents', label: 'Documents' }, { to: '/documents', label: 'Documents' },
{ to: '/constructeurs', label: 'Fournisseurs' }, { to: '/constructeurs', label: 'Fournisseurs' },
{ to: '/activity-log', label: 'Journal d\'activité' },
], ],
}, },
] ]

View 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
View 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>

View File

@@ -130,7 +130,16 @@
</td> </td>
<td>{{ component.name || 'Composant sans nom' }}</td> <td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ component.reference || '—' }}</td> <td>{{ component.reference || '—' }}</td>
<td>{{ resolveComponentType(component) }}</td> <td>
<NuxtLink
v-if="component.typeComposant?.id"
:to="`/component-category/${component.typeComposant.id}/edit`"
class="link link-hover link-primary"
>
{{ resolveComponentType(component) }}
</NuxtLink>
<span v-else>{{ resolveComponentType(component) }}</span>
</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NuxtLink <NuxtLink

View File

@@ -803,7 +803,9 @@ watch(
void ensureConstructeurs(editionForm.constructeurIds) void ensureConstructeurs(editionForm.constructeurIds)
} }
refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues) // After setting selectedTypeId, read selectedTypeStructure.value (now updated) instead of
// the stale destructured currentStructure which was captured before the ID change.
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
initialized = true initialized = true
}, },

View File

@@ -152,7 +152,16 @@
</div> </div>
<span v-else></span> <span v-else></span>
</td> </td>
<td>{{ resolvePieceType(row.piece) }}</td> <td>
<NuxtLink
v-if="row.piece.typePiece?.id"
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
class="link link-hover link-primary"
>
{{ resolvePieceType(row.piece) }}
</NuxtLink>
<span v-else>{{ resolvePieceType(row.piece) }}</span>
</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NuxtLink <NuxtLink

View File

@@ -837,7 +837,10 @@ watch(
pendingProductIds = [] pendingProductIds = []
} }
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues) // After setting selectedTypeId, read selectedType.value (now updated) instead of
// the stale destructured currentType which was captured before the ID change.
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
initialized = true initialized = true
}, },
@@ -848,9 +851,7 @@ watch(selectedType, (currentType) => {
if (!piece.value || !currentType) { if (!piece.value || !currentType) {
return return
} }
if (!pieceTypeDetails.value) { refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
}
}) })
watch(resolvedStructure, (currentStructure) => { watch(resolvedStructure, (currentStructure) => {

View File

@@ -110,7 +110,16 @@
</td> </td>
<td class="font-medium">{{ row.product.name }}</td> <td class="font-medium">{{ row.product.name }}</td>
<td>{{ row.product.reference || '—' }}</td> <td>{{ row.product.reference || '—' }}</td>
<td>{{ row.product.typeProduct?.name || '—' }}</td> <td>
<NuxtLink
v-if="row.product.typeProduct?.id"
:to="`/product-category/${row.product.typeProduct.id}/edit`"
class="link link-hover link-primary"
>
{{ row.product.typeProduct.name }}
</NuxtLink>
<span v-else>{{ row.product.typeProduct?.name || '' }}</span>
</td>
<td> <td>
<div <div
v-if="row.suppliers.visible.length" v-if="row.suppliers.visible.length"