Ouvrir le journal d'activité à la lecture
La piste d'audit était écrite à chaque mutation depuis WP-01, et lisible nulle part. Une piste que personne ne peut consulter ne prouve rien le jour où on le lui demande. L'écran ne porte aucune action : le journal est append-only, imposé par un trigger, et cette page ne fait que le lire. La capacité `audit.view` est 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. Seuls les noms des champs modifiés s'affichent, pas leurs valeurs. Le journal enregistre bien l'avant et l'après en clair, c'est ce qui le rend opposable ; mais les étaler dans une liste exposerait un salaire ou un motif médical à quiconque ouvre l'écran. La liste dit ce qui a changé, la fiche dit quoi, sous ses propres autorisations. Les listes de filtres se déduisent du journal lui-même plutôt que d'une énumération tenue à la main : une action ajoutée par un lot futur y apparaît seule, et une énumération figée finirait par masquer ce qu'elle omet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e4be93a208
commit
1519b6bf9d
3 files changed
+349
-1
No files matched your search
@@ -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) => (
|
||||
<Link
|
||||
key={`${href}-${label}`}
|
||||
href={href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={
|
||||
active
|
||||
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
|
||||
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Journal d’activité"
|
||||
subtitle={`${page.total} entrée${page.total > 1 ? 's' : ''} sur la période`}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Filtres"
|
||||
badge={
|
||||
page.truncated ? (
|
||||
<Badge tone="warn">200 dernières affichées</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{chip(withParam('domaine', undefined), 'Tous les domaines', !params.domaine)}
|
||||
{page.domains.map((domain) =>
|
||||
chip(
|
||||
withParam('domaine', domain),
|
||||
domain,
|
||||
params.domaine === domain,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{chip(withParam('objet', undefined), 'Tous les objets', !params.objet)}
|
||||
{page.entityTypes.map((type) =>
|
||||
chip(withParam('objet', type), type, params.objet === type),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="flex flex-wrap items-end gap-3" method="get">
|
||||
{params.domaine ? (
|
||||
<input type="hidden" name="domaine" value={params.domaine} />
|
||||
) : null}
|
||||
{params.objet ? (
|
||||
<input type="hidden" name="objet" value={params.objet} />
|
||||
) : null}
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Du</span>
|
||||
<input
|
||||
type="date"
|
||||
name="du"
|
||||
defaultValue={params.du}
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Au</span>
|
||||
<input
|
||||
type="date"
|
||||
name="au"
|
||||
defaultValue={params.au}
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="h-9 rounded-2 border border-line-3 px-3 text-sm hover:bg-surface-2"
|
||||
>
|
||||
Appliquer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Entrées" />
|
||||
|
||||
{page.entries.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucune entrée"
|
||||
description="Aucune action enregistrée pour ces filtres."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left">
|
||||
{['Horodatage', 'Auteur', 'Action', 'Objet', 'Champs', 'Justification'].map(
|
||||
(heading) => (
|
||||
<th
|
||||
key={heading}
|
||||
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
{heading}
|
||||
</th>
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line-1">
|
||||
{page.entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="px-4 py-2 tnum whitespace-nowrap">
|
||||
{stamp.format(entry.occurredAt)}
|
||||
</td>
|
||||
<td className="px-4 py-2">{entry.actorName}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{entry.action}</td>
|
||||
<td className="px-4 py-2 text-ink-2">
|
||||
{entry.entityType}
|
||||
<span className="block font-mono text-micro text-ink-3">
|
||||
{entry.entityId}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-ink-2">
|
||||
{entry.changedFields.length === 0
|
||||
? '—'
|
||||
: entry.changedFields.join(', ')}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-ink-2">
|
||||
{entry.reason ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
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 <em>nom</em> 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.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<string>();
|
||||
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<ActivityPage> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user