diff --git a/src/app/(app)/rapports/activite/page.tsx b/src/app/(app)/rapports/activite/page.tsx new file mode 100644 index 0000000..3bd3575 --- /dev/null +++ b/src/app/(app)/rapports/activite/page.tsx @@ -0,0 +1,197 @@ +import Link from 'next/link'; + +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge } from '@/components/ui/Badge'; +import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; +import { getActivity } from '@/server/reports/activity'; + +export const metadata = { title: 'Journal d’activité · PlanFlow' }; +export const dynamic = 'force-dynamic'; + +interface PageProps { + searchParams: Promise<{ + domaine?: string; + objet?: string; + du?: string; + au?: string; + }>; +} + +const stamp = new Intl.DateTimeFormat('fr-FR', { + dateStyle: 'short', + timeStyle: 'short', +}); + +export default async function ActivitePage({ searchParams }: PageProps) { + const params = await searchParams; + // `exactOptionalPropertyTypes` : un filtre absent ne se transmet pas comme + // « undefined », il ne se transmet pas du tout. + const page = await getActivity({ + ...(params.domaine ? { domain: params.domaine } : {}), + ...(params.objet ? { entityType: params.objet } : {}), + ...(params.du ? { from: params.du } : {}), + ...(params.au ? { to: params.au } : {}), + }); + + const withParam = (key: string, value: string | undefined) => { + const next = new URLSearchParams(); + for (const [name, current] of Object.entries(params)) { + if (current) next.set(name, current); + } + if (value) next.set(key, value); + else next.delete(key); + const qs = next.toString(); + return qs ? `/rapports/activite?${qs}` : '/rapports/activite'; + }; + + const chip = (href: string, label: string, active: boolean) => ( + + {label} + + ); + + return ( + + 1 ? 's' : ''} sur la période`} + /> + + + 200 dernières affichées + ) : null + } + /> + +
+
+ {chip(withParam('domaine', undefined), 'Tous les domaines', !params.domaine)} + {page.domains.map((domain) => + chip( + withParam('domaine', domain), + domain, + params.domaine === domain, + ), + )} +
+ +
+ {chip(withParam('objet', undefined), 'Tous les objets', !params.objet)} + {page.entityTypes.map((type) => + chip(withParam('objet', type), type, params.objet === type), + )} +
+ +
+ {params.domaine ? ( + + ) : null} + {params.objet ? ( + + ) : null} + + + +
+
+
+ + + + + {page.entries.length === 0 ? ( + + ) : ( +
+ + + + {['Horodatage', 'Auteur', 'Action', 'Objet', 'Champs', 'Justification'].map( + (heading) => ( + + ), + )} + + + + {page.entries.map((entry) => ( + + + + + + + + + ))} + +
+ {heading} +
+ {stamp.format(entry.occurredAt)} + {entry.actorName}{entry.action} + {entry.entityType} + + {entry.entityId} + + + {entry.changedFields.length === 0 + ? '—' + : entry.changedFields.join(', ')} + + {entry.reason ?? '—'} +
+
+ )} +
+ +

+ Le journal enregistre l’avant et l’après de chaque mutation — c’est ce + qui le rend opposable. Cet écran n’affiche que le nom des champs + modifiés : la valeur se lit sur la fiche concernée, sous ses propres + autorisations. Un salaire ou un motif médical n’a pas à être visible de + quiconque ouvre la piste d’audit. +

+
+ ); +} diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts index d81b970..0fadb59 100644 --- a/src/components/shell/navigation.ts +++ b/src/components/shell/navigation.ts @@ -91,7 +91,11 @@ export const NAVIGATION: NavSection[] = [ { id: 'paie', label: 'Préparation de paie', href: '/paie' }, { id: 'periodes', label: 'Périodes de paie', href: '/paie/periodes' }, { id: 'silae', label: 'Codes Silae', href: '/paie/silae' }, - { id: 'activite', label: "Journal d'activité" }, + { + id: 'activite', + label: "Journal d'activité", + href: '/rapports/activite', + }, ], }, { diff --git a/src/server/reports/activity.ts b/src/server/reports/activity.ts new file mode 100644 index 0000000..05fc811 --- /dev/null +++ b/src/server/reports/activity.ts @@ -0,0 +1,147 @@ +import { query } from '@/server/context'; + +/** + * Journal d'activité — PLAN.md §9, `/rapports/activite`. + * + * Le journal d'audit est append-only, imposé par un trigger. Cet écran ne fait + * que le lire : aucune action ne s'y trouve, et c'est le point. Une piste que + * l'on peut trier, filtrer et exporter, mais jamais corriger, est la seule qui + * prouve quelque chose. + * + * L'accès passe par `audit.view`, capacité distincte de l'administration : on + * confie la lecture de la piste à un contrôleur sans lui donner le droit de + * modifier ce qu'elle enregistre. + */ + +export interface ActivityEntry { + id: string; + occurredAt: Date; + action: string; + entityType: string; + entityId: string; + actorName: string; + reason: string | null; + ip: string | null; + /** Champs dont la valeur a changé, sans leur contenu. */ + changedFields: string[]; +} + +export interface ActivityFilters { + /** Préfixe d'action, par exemple `planning` ou `payroll.period`. */ + domain?: string; + entityType?: string; + from?: string; + to?: string; +} + +export interface ActivityPage { + entries: ActivityEntry[]; + /** Domaines présents dans le journal, pour le filtre. */ + domains: string[]; + entityTypes: string[]; + total: number; + truncated: boolean; +} + +const PAGE_SIZE = 200; + +/** + * Noms des champs modifiés, sans leur valeur. + * + * La piste enregistre l'avant et l'après en clair — c'est ce qui la rend + * opposable. Les afficher dans une liste, en revanche, exposerait un salaire ou + * une donnée de santé à quiconque ouvre l'écran. Le journal dit **ce qui** a + * changé ; le détail se lit sur la fiche, sous ses propres autorisations. + */ +function changedFields(before: unknown, after: unknown): string[] { + const keys = new Set(); + for (const value of [before, after]) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const key of Object.keys(value)) keys.add(key); + } + } + return [...keys].sort(); +} + +export async function getActivity( + filters: ActivityFilters = {}, +): Promise { + return query('audit.view', async (db) => { + const where = { + ...(filters.domain ? { action: { startsWith: filters.domain } } : {}), + ...(filters.entityType ? { entityType: filters.entityType } : {}), + ...(filters.from || filters.to + ? { + occurredAt: { + ...(filters.from + ? { gte: new Date(`${filters.from}T00:00:00Z`) } + : {}), + // Borne haute inclusive : filtrer « jusqu'au 31 » doit contenir + // le 31, pas s'arrêter à son premier instant. + ...(filters.to + ? { lte: new Date(`${filters.to}T23:59:59.999Z`) } + : {}), + }, + } + : {}), + }; + + const [rows, total] = await Promise.all([ + db.auditLog.findMany({ + where, + orderBy: { occurredAt: 'desc' }, + take: PAGE_SIZE, + include: { + actor: { + include: { + profile: { select: { firstName: true, lastName: true } }, + }, + }, + }, + }), + db.auditLog.count({ where }), + ]); + + // Les listes de filtres viennent du journal lui-même : une action ajoutée + // par un lot futur y apparaît sans qu'on ait à tenir une énumération à + // jour, et une énumération figée finirait par masquer ce qu'elle omet. + const [actions, types] = await Promise.all([ + db.auditLog.findMany({ + distinct: ['action'], + select: { action: true }, + orderBy: { action: 'asc' }, + take: 500, + }), + db.auditLog.findMany({ + distinct: ['entityType'], + select: { entityType: true }, + orderBy: { entityType: 'asc' }, + take: 200, + }), + ]); + + return { + entries: rows.map((row) => ({ + id: row.id, + occurredAt: row.occurredAt, + action: row.action, + entityType: row.entityType, + entityId: row.entityId, + actorName: row.actor + ? `${row.actor.profile?.firstName ?? ''} ${row.actor.profile?.lastName ?? row.actor.employeeNumber}`.trim() + : 'Système', + reason: row.reason, + ip: row.ip, + changedFields: changedFields(row.before, row.after), + })), + domains: [ + ...new Set( + actions.map((row) => row.action.split('.')[0] ?? row.action), + ), + ].sort(), + entityTypes: types.map((row) => row.entityType), + total, + truncated: total > rows.length, + }; + }); +}