Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79d2df8bc6 | ||
|
|
23da4ba4c7 | ||
|
|
635b8f0461 | ||
|
|
bf74a50f57 | ||
|
|
7c44778f25 | ||
|
|
9f7dd12b34 |
@@ -6,26 +6,31 @@
|
|||||||
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
|
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
|
||||||
color-scheme: light; /* color of browser-provided UI */
|
color-scheme: light; /* color of browser-provided UI */
|
||||||
|
|
||||||
--color-base-100: oklch(98% 0.02 240);
|
/* #FBFAFA — gris clair */
|
||||||
--color-base-200: oklch(95% 0.03 240);
|
--color-base-100: oklch(98% 0.003 0);
|
||||||
--color-base-300: oklch(92% 0.04 240);
|
--color-base-200: oklch(94% 0.01 262);
|
||||||
--color-base-content: oklch(20% 0.05 240);
|
--color-base-300: oklch(90% 0.02 262);
|
||||||
--color-primary: oklch(55% 0.3 240);
|
--color-base-content: oklch(20% 0.03 262);
|
||||||
--color-primary-content: oklch(98% 0.01 240);
|
/* #304998 — bleu Malio */
|
||||||
--color-secondary: oklch(70% 0.25 200);
|
--color-primary: oklch(37% 0.15 262);
|
||||||
--color-secondary-content: oklch(98% 0.01 200);
|
--color-primary-content: oklch(98% 0.005 262);
|
||||||
--color-accent: oklch(65% 0.25 160);
|
/* #A5ACD0 — lavande */
|
||||||
--color-accent-content: oklch(98% 0.01 160);
|
--color-secondary: oklch(75% 0.055 270);
|
||||||
--color-neutral: oklch(50% 0.05 240);
|
--color-secondary-content: oklch(20% 0.03 270);
|
||||||
--color-neutral-content: oklch(98% 0.01 240);
|
/* #ED8521 — orange */
|
||||||
--color-info: oklch(70% 0.2 220);
|
--color-accent: oklch(71% 0.17 58);
|
||||||
--color-info-content: oklch(98% 0.01 220);
|
--color-accent-content: oklch(98% 0.005 58);
|
||||||
--color-success: oklch(65% 0.25 140);
|
/* neutral dérivé du bleu Malio */
|
||||||
--color-success-content: oklch(98% 0.01 140);
|
--color-neutral: oklch(37% 0.08 262);
|
||||||
--color-warning: oklch(80% 0.25 80);
|
--color-neutral-content: oklch(98% 0.005 262);
|
||||||
--color-warning-content: oklch(20% 0.05 80);
|
--color-info: oklch(55% 0.12 262);
|
||||||
--color-error: oklch(65% 0.3 30);
|
--color-info-content: oklch(98% 0.005 262);
|
||||||
--color-error-content: oklch(98% 0.01 30);
|
--color-success: oklch(65% 0.2 145);
|
||||||
|
--color-success-content: oklch(98% 0.005 145);
|
||||||
|
--color-warning: oklch(78% 0.15 70);
|
||||||
|
--color-warning-content: oklch(20% 0.05 70);
|
||||||
|
--color-error: oklch(60% 0.25 25);
|
||||||
|
--color-error-content: oklch(98% 0.005 25);
|
||||||
|
|
||||||
/* border radius */
|
/* border radius */
|
||||||
--radius-selector: 1rem;
|
--radius-selector: 1rem;
|
||||||
@@ -114,7 +119,7 @@
|
|||||||
|
|
||||||
/* Focus visible pour l'accessibilité */
|
/* Focus visible pour l'accessibilité */
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: 2px solid #3b82f6;
|
outline: 2px solid #304998;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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é' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
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 }
|
||||||
|
}
|
||||||
@@ -40,11 +40,13 @@ interface LoadComposantsOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const composants = ref<Composant[]>([])
|
const composants = ref<Composant[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||||
const p = payload as Record<string, unknown> | null
|
const p = payload as Record<string, unknown> | null
|
||||||
@@ -98,15 +100,31 @@ export function useComposants() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
|
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
|
||||||
|
const {
|
||||||
|
search = '',
|
||||||
|
page = 1,
|
||||||
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'name',
|
||||||
|
orderDir = 'asc',
|
||||||
|
force = false,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!force && loaded.value && !search && page === 1) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const {
|
|
||||||
search = '',
|
|
||||||
page = 1,
|
|
||||||
itemsPerPage = 30,
|
|
||||||
orderBy = 'name',
|
|
||||||
orderDir = 'asc',
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', String(itemsPerPage))
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
@@ -124,6 +142,7 @@ export function useComposants() {
|
|||||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
composants.value = enrichedItems
|
composants.value = enrichedItems
|
||||||
total.value = extractTotal(result.data, items.length)
|
total.value = extractTotal(result.data, items.length)
|
||||||
|
loaded.value = true
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -216,15 +235,23 @@ export function useComposants() {
|
|||||||
const getComposants = () => composants.value
|
const getComposants = () => composants.value
|
||||||
const isLoading = () => loading.value
|
const isLoading = () => loading.value
|
||||||
|
|
||||||
|
const clearComposantsCache = () => {
|
||||||
|
composants.value = []
|
||||||
|
total.value = 0
|
||||||
|
loaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
composants,
|
composants,
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
|
loaded,
|
||||||
loadComposants,
|
loadComposants,
|
||||||
createComposant,
|
createComposant,
|
||||||
updateComposant: updateComposantData,
|
updateComposant: updateComposantData,
|
||||||
deleteComposant,
|
deleteComposant,
|
||||||
getComposants,
|
getComposants,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
clearComposantsCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ interface LoadPiecesOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieces = ref<Piece[]>([])
|
const pieces = ref<Piece[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||||
const p = payload as Record<string, unknown> | null
|
const p = payload as Record<string, unknown> | null
|
||||||
@@ -108,15 +110,31 @@ export function usePieces() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
|
const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
|
||||||
|
const {
|
||||||
|
search = '',
|
||||||
|
page = 1,
|
||||||
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'name',
|
||||||
|
orderDir = 'asc',
|
||||||
|
force = false,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!force && loaded.value && !search && page === 1) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const {
|
|
||||||
search = '',
|
|
||||||
page = 1,
|
|
||||||
itemsPerPage = 30,
|
|
||||||
orderBy = 'name',
|
|
||||||
orderDir = 'asc',
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', String(itemsPerPage))
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
@@ -134,6 +152,7 @@ export function usePieces() {
|
|||||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
pieces.value = enrichedItems
|
pieces.value = enrichedItems
|
||||||
total.value = extractTotal(result.data, items.length)
|
total.value = extractTotal(result.data, items.length)
|
||||||
|
loaded.value = true
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -226,15 +245,23 @@ export function usePieces() {
|
|||||||
const getPieces = () => pieces.value
|
const getPieces = () => pieces.value
|
||||||
const isLoading = () => loading.value
|
const isLoading = () => loading.value
|
||||||
|
|
||||||
|
const clearPiecesCache = () => {
|
||||||
|
pieces.value = []
|
||||||
|
total.value = 0
|
||||||
|
loaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pieces,
|
pieces,
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
|
loaded,
|
||||||
loadPieces,
|
loadPieces,
|
||||||
createPiece,
|
createPiece,
|
||||||
updatePiece: updatePieceData,
|
updatePiece: updatePieceData,
|
||||||
deletePiece,
|
deletePiece,
|
||||||
getPieces,
|
getPieces,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
clearPiecesCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ const { updateComposant, loadComposants, composants: componentCatalogRef } = use
|
|||||||
const { pieces, loadPieces } = usePieces()
|
const { pieces, loadPieces } = usePieces()
|
||||||
const { products, loadProducts } = useProducts()
|
const { products, loadProducts } = useProducts()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
const {
|
const {
|
||||||
@@ -764,12 +764,10 @@ const fetchComponent = async () => {
|
|||||||
component.value = result.data
|
component.value = result.data
|
||||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||||
|
|
||||||
const customValues = await getCustomFieldValuesByEntity('composant', result.data.id)
|
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||||
if (customValues.success && Array.isArray(customValues.data)) {
|
refreshCustomFieldInputs(undefined, customValues)
|
||||||
component.value.customFieldValues = customValues.data
|
|
||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
loadHistory(result.data.id).catch(() => {})
|
||||||
}
|
|
||||||
await loadHistory(result.data.id)
|
|
||||||
} else {
|
} else {
|
||||||
component.value = null
|
component.value = null
|
||||||
componentDocuments.value = []
|
componentDocuments.value = []
|
||||||
@@ -805,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
|
||||||
},
|
},
|
||||||
@@ -1130,14 +1130,15 @@ onMounted(async () => {
|
|||||||
loadComponentTypes(),
|
loadComponentTypes(),
|
||||||
loadPieceTypes(),
|
loadPieceTypes(),
|
||||||
loadProductTypes(),
|
loadProductTypes(),
|
||||||
loadPieces({ itemsPerPage: 500 }),
|
|
||||||
loadProducts({ itemsPerPage: 500, force: true }),
|
|
||||||
loadComposants({ itemsPerPage: 500 }),
|
|
||||||
fetchComponent(),
|
fetchComponent(),
|
||||||
])
|
])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (component.value?.id) {
|
|
||||||
await refreshDocuments()
|
// Defer bulk catalog loads — not needed for initial render
|
||||||
}
|
Promise.allSettled([
|
||||||
|
loadPieces({ itemsPerPage: 500 }),
|
||||||
|
loadProducts({ itemsPerPage: 500 }),
|
||||||
|
loadComposants({ itemsPerPage: 500 }),
|
||||||
|
]).catch(() => {})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -516,7 +516,7 @@ const router = useRouter()
|
|||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const { updatePiece } = usePieces()
|
const { updatePiece } = usePieces()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
@@ -750,20 +750,23 @@ const fetchPiece = async () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
piece.value = result.data
|
piece.value = result.data
|
||||||
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||||
const customValues = await getCustomFieldValuesByEntity('piece', result.data.id)
|
|
||||||
if (customValues.success && Array.isArray(customValues.data)) {
|
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||||
piece.value.customFieldValues = customValues.data
|
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
refreshCustomFieldInputs(undefined, customValues)
|
||||||
}
|
|
||||||
await loadPieceTypeDetails(result.data)
|
// Use cached type from loadPieceTypes() instead of separate getModelType() call
|
||||||
await loadHistory(result.data.id)
|
loadPieceTypeDetailsFromCache(result.data)
|
||||||
|
|
||||||
|
// History is non-blocking — template handles its own loading state
|
||||||
|
loadHistory(result.data.id).catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
piece.value = null
|
piece.value = null
|
||||||
pieceDocuments.value = []
|
pieceDocuments.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPieceTypeDetails = async (currentPiece: any) => {
|
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
|
||||||
const typeId = currentPiece?.typePieceId
|
const typeId = currentPiece?.typePieceId
|
||||||
|| extractRelationId(currentPiece?.typePiece)
|
|| extractRelationId(currentPiece?.typePiece)
|
||||||
|| ''
|
|| ''
|
||||||
@@ -771,15 +774,22 @@ const loadPieceTypeDetails = async (currentPiece: any) => {
|
|||||||
pieceTypeDetails.value = null
|
pieceTypeDetails.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
|
||||||
const type = await getModelType(typeId)
|
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
|
||||||
|
if (cachedType) {
|
||||||
|
pieceTypeDetails.value = cachedType
|
||||||
|
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fallback: fetch if not in cache (edge case)
|
||||||
|
getModelType(typeId).then((type) => {
|
||||||
if (type && typeof type === 'object') {
|
if (type && typeof type === 'object') {
|
||||||
pieceTypeDetails.value = type
|
pieceTypeDetails.value = type
|
||||||
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
}).catch(() => {
|
||||||
pieceTypeDetails.value = null
|
pieceTypeDetails.value = null
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false
|
||||||
@@ -827,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
|
||||||
},
|
},
|
||||||
@@ -838,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) => {
|
||||||
@@ -920,8 +931,5 @@ const submitEdition = async () => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (piece.value?.id) {
|
|
||||||
await refreshDocuments()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { getProduct, updateProduct } = useProducts()
|
const { getProduct, updateProduct } = useProducts()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const {
|
const {
|
||||||
loadDocumentsByProduct,
|
loadDocumentsByProduct,
|
||||||
uploadDocuments: uploadProductDocuments,
|
uploadDocuments: uploadProductDocuments,
|
||||||
@@ -520,15 +520,17 @@ const loadProduct = async () => {
|
|||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
product.value = result.data
|
product.value = result.data
|
||||||
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
|
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
|
||||||
|
|
||||||
await loadProductType()
|
await loadProductType()
|
||||||
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
|
|
||||||
if (customValues.success && Array.isArray(customValues.data)) {
|
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||||
product.value.customFieldValues = customValues.data
|
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
refreshCustomFieldInputs(undefined, customValues)
|
||||||
}
|
|
||||||
await hydrateForm()
|
hydrateForm()
|
||||||
await refreshDocuments()
|
|
||||||
await loadHistory(result.data.id)
|
// History is non-blocking — template handles its own loading state
|
||||||
|
loadHistory(result.data.id).catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
product.value = null
|
product.value = null
|
||||||
}
|
}
|
||||||
@@ -587,9 +589,20 @@ const handleFilesAdded = async (files: File[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadProductType = async () => {
|
const loadProductType = async () => {
|
||||||
|
// Try using the expanded typeProduct from entity response first
|
||||||
|
const embedded = product.value?.typeProduct
|
||||||
|
if (embedded && typeof embedded === 'object' && embedded.id) {
|
||||||
|
const embeddedStructure = embedded.structure ?? embedded.productSkeleton ?? null
|
||||||
|
if (embeddedStructure) {
|
||||||
|
productType.value = embedded
|
||||||
|
structure.value = normalizeProductStructureForSave(embeddedStructure)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!product.value?.typeProductId) {
|
if (!product.value?.typeProductId) {
|
||||||
productType.value = product.value?.typeProduct ?? null
|
productType.value = embedded ?? null
|
||||||
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -598,12 +611,12 @@ const loadProductType = async () => {
|
|||||||
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
|
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement du type de produit:', error)
|
console.error('Erreur lors du chargement du type de produit:', error)
|
||||||
productType.value = product.value?.typeProduct ?? null
|
productType.value = embedded ?? null
|
||||||
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hydrateForm = async () => {
|
const hydrateForm = () => {
|
||||||
if (!product.value) {
|
if (!product.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -618,7 +631,8 @@ const hydrateForm = async () => {
|
|||||||
: ''
|
: ''
|
||||||
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||||
if (editionForm.constructeurIds.length) {
|
if (editionForm.constructeurIds.length) {
|
||||||
await ensureConstructeurs(editionForm.constructeurIds)
|
// Smart-cached + deduped — fire-and-forget, ConstructeurSelect handles its own loading
|
||||||
|
ensureConstructeurs(editionForm.constructeurIds).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user