feat(history): add audit history views for products, pieces and components

This commit is contained in:
Matthieu
2026-01-25 21:20:14 +01:00
parent a7101c7e77
commit a1d15c23a4
6 changed files with 673 additions and 0 deletions

View File

@@ -434,6 +434,74 @@
</p>
</div>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-base-content">Historique</h2>
<p class="text-xs text-base-content/70">
Qui a changé quoi, et quand.
</p>
</div>
<span v-if="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in historyEntries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
@@ -466,6 +534,7 @@ import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory'
@@ -503,6 +572,12 @@ const { ensureConstructeurs } = useConstructeurs()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useComponentHistory()
const component = ref<any | null>(null)
const loading = ref(true)
@@ -513,6 +588,88 @@ const loadingDocuments = ref(false)
const componentDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
structure: 'Structure',
typeComposant: 'Catégorie',
product: 'Produit lié',
constructeurIds: 'Fournisseurs',
}
const historyActionLabel = (action: string) => {
if (action === 'create') {
return 'Création'
}
if (action === 'delete') {
return 'Suppression'
}
return 'Modification'
}
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
dateStyle: 'medium',
timeStyle: 'short',
})
const formatHistoryDate = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return historyDateFormatter.format(date)
}
const formatHistoryValue = (value: unknown): string => {
if (value === null || value === undefined || value === '') {
return '—'
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '—'
}
return value.map((item) => formatHistoryValue(item)).join(', ')
}
if (typeof value === 'object') {
const maybeRecord = value as Record<string, unknown>
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
if (name && id) {
return `${name} (#${id})`
}
if (name) {
return name
}
if (id) {
return `#${id}`
}
try {
return JSON.stringify(value)
} catch (error) {
return String(value)
}
}
return String(value)
}
const historyDiffEntries = (entry: ComponentHistoryEntry) => {
const diff = entry.diff ?? {}
return Object.entries(diff).map(([field, change]) => {
const label = historyFieldLabels[field] ?? field
const fromLabel = formatHistoryValue(change?.from)
const toLabel = formatHistoryValue(change?.to)
return {
field,
label,
fromLabel,
toLabel,
}
})
}
const selectedTypeId = ref<string>('')
const editionForm = reactive({
name: '' as string,
@@ -756,6 +913,7 @@ const fetchComponent = async () => {
component.value.customFieldValues = customValues.data
refreshCustomFieldInputs(undefined, customValues.data)
}
await loadHistory(result.data.id)
} else {
component.value = null
componentDocuments.value = []