Poser les modèles de documents et le dictionnaire RGPD

Deux livrables de WP-10 manquaient, et le second n'existait nulle part.

Les modèles de documents produisent attestations et courriers types en
résolvant les variables du dossier. Le rendu vit dans le domaine, avec une règle
qui n'est pas négociable : une variable citée sans valeur fait échouer la
génération. Une attestation trouée est un document faux, pas un document
incomplet — le blanc se remarque à la relecture une fois sur deux, et la pièce
part signée. De même, une variable inconnue est refusée à l'enregistrement du
modèle, par l'administrateur qui l'écrit, plutôt que découverte par le
gestionnaire devant le salarié qui attend.

Le corps reste de l'HTML rédigé par un administrateur ; ce sont les valeurs
venues du dossier qui sont échappées à l'insertion. Un nom de famille importé
d'un autre outil ne doit pas pouvoir ouvrir une balise.

`Document.templateId` est en ON DELETE SET NULL : une pièce déjà remise ne
disparaît pas parce qu'on a effacé le modèle qui l'a produite.

L'écran RGPD livre le dictionnaire donnée → finalité → base légale →
destinataire → durée qu'exige la matrice n° 14. Le dictionnaire vit dans le code
parce qu'il décrit le schéma ; les durées viennent de RetentionPolicy parce
qu'elles se paramètrent et s'auditent. Une catégorie sans durée déclarée est
signalée en rouge : le plan interdit d'appliquer une durée par défaut.

L'affectation des bases légales porte un avertissement explicite. C'est une
qualification juridique, pas un réglage : elle doit être confirmée par le
responsable de traitement avant d'être opposée à quiconque.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 12:44:44 +02:00
1 parent 5b60e7ddb6
commit db03768025
10 files changed
+1090 -1

No files matched your search

@@ -0,0 +1,89 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createTemplateAction,
updateTemplateAction,
type ActionState,
} from '@/server/settings/templates';
const empty: ActionState = {};
function BodyField({ defaultValue }: { defaultValue?: string }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Corps du document</span>
<textarea
name="bodyHtml"
rows={10}
required
defaultValue={defaultValue}
placeholder="<p>Je soussigné…, atteste que {{salarie.prenom}} {{salarie.nom}} est employé…</p>"
className="rounded-2 border border-line-2 bg-surface px-3 py-2 font-mono text-xs text-ink-1 outline-none focus-visible:border-focus"
/>
<span className="text-micro text-ink-3">
Les variables s’écrivent entre doubles accolades. Une variable inconnue
fait refuser l’enregistrement — mieux vaut la corriger ici que de
découvrir un trou dans l’attestation au moment de la remettre.
</span>
</label>
);
}
export function AddTemplateForm() {
const [state, action] = useActionState(createTemplateAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<Field
label="Nom du modèle"
name="name"
required
maxLength={160}
placeholder="Attestation d’emploi"
/>
<BodyField />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Créer le modèle</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Modèle enregistré.</span>
) : null}
</div>
</PersistentForm>
);
}
export function EditTemplateForm({
id,
name,
bodyHtml,
}: {
id: string;
name: string;
bodyHtml: string;
}) {
const [state, action] = useActionState(updateTemplateAction, empty);
return (
<PersistentForm action={action} className="flex flex-col gap-3">
<input type="hidden" name="id" value={id} />
<Field label="Nom du modèle" name="name" defaultValue={name} required maxLength={160} />
<BodyField defaultValue={bodyHtml} />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton size="sm">Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Modifications prises.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -0,0 +1,164 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddTemplateForm,
EditTemplateForm,
} from '@/app/(app)/reglages/modeles-documents/TemplateForms';
import { TEMPLATE_FIELDS } from '@/domain/documents/template';
import {
archiveTemplateAction,
listTemplates,
} from '@/server/settings/templates';
export const metadata = { title: 'Modèles de documents · PlanFlow' };
export const dynamic = 'force-dynamic';
const SCOPE_LABEL = {
employee: 'Salarié',
contract: 'Contrat',
location: 'Établissement',
} as const;
export default async function ModelesDocumentsPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const templates = await listTemplates(showArchived);
const active = templates.filter((template) => template.archivedAt === null);
return (
<PageBody>
<PageHeader
title="Modèles de documents"
subtitle={`${active.length} modèle${active.length > 1 ? 's' : ''} disponible${active.length > 1 ? 's' : ''}`}
/>
<div className="grid gap-5 lg:grid-cols-[1fr_18rem]">
<div className="flex min-w-0 flex-col gap-5">
<Card>
<CardHeader
title="Modèles"
action={
<a
href={
showArchived
? '/reglages/modeles-documents'
: '/reglages/modeles-documents?archives=1'
}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
</a>
}
/>
{templates.length === 0 ? (
<EmptyState
title="Aucun modèle"
description="Un modèle produit une attestation ou un courrier type en résolvant les variables du dossier."
/>
) : (
<ul className="divide-y divide-line-1">
{templates.map((template) => (
<li key={template.id} className="px-4 py-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">
{template.name}
</span>
<Badge tone="neutral">
{template.documentCount} pièce
{template.documentCount > 1 ? 's' : ''}
</Badge>
{template.archivedAt ? (
<Badge tone="warn">Archivé</Badge>
) : null}
<span className="flex-1" />
<form action={archiveTemplateAction}>
<input type="hidden" name="id" value={template.id} />
{template.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{template.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</div>
{template.fields.length > 0 ? (
<ul className="mb-3 flex flex-wrap gap-1.5">
{template.fields.map((field) => (
<li key={field}>
<Badge tone="info">{field}</Badge>
</li>
))}
</ul>
) : (
<p className="mb-3 text-xs text-ink-3">
Aucune variable : ce modèle produit toujours le même
texte.
</p>
)}
<details className="text-sm">
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
Modifier
</summary>
<div className="pt-3">
<EditTemplateForm
id={template.id}
name={template.name}
bodyHtml={template.bodyHtml}
/>
</div>
</details>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouveau modèle" />
<div className="p-4">
<AddTemplateForm />
</div>
</Card>
</div>
<aside className="flex flex-col gap-3">
<Card>
<CardHeader title="Variables disponibles" />
<ul className="divide-y divide-line-1">
{TEMPLATE_FIELDS.map((field) => (
<li key={field.key} className="px-3 py-2">
<code className="text-xs text-ink-1">
{'{{'}
{field.key}
{'}}'}
</code>
<span className="mt-0.5 flex items-center gap-1.5">
<span className="text-micro text-ink-3">{field.label}</span>
<Badge tone="neutral" subtle>
{SCOPE_LABEL[field.scope]}
</Badge>
</span>
</li>
))}
</ul>
</Card>
<p className="text-xs leading-[var(--lh-prose)] text-ink-3">
Les variables d’établissement se résolvent dans le contexte de
l’établissement porté par le contrat du salarié : la même attestation
ne porte pas la même raison sociale d’un magasin à l’autre.
</p>
</aside>
</div>
</PageBody>
);
}
+184
View File
@@ -0,0 +1,184 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader } from '@/components/ui/Card';
import {
DATA_DICTIONARY,
LEGAL_BASIS_LABELS,
} from '@/domain/legal/data-dictionary';
import { query } from '@/server/context';
export const metadata = { title: 'RGPD · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function RgpdPage() {
// Les durées ne sont pas écrites dans le dictionnaire : elles viennent des
// politiques de conservation, qui se paramètrent et s'auditent. Un artefact
// qui afficherait sa propre durée mentirait le jour où la politique change.
const policies = await query('settings.access', async (db) =>
db.retentionPolicy.findMany({
orderBy: [{ objectType: 'asc' }, { effectiveFrom: 'desc' }],
select: {
objectType: true,
durationMonths: true,
effectiveFrom: true,
},
}),
);
const durationFor = (objectTypes: string[]): string => {
const matches = objectTypes
.map((objectType) => {
// Une politique précise l'emporte sur une politique générale :
// « Document:SICK_NOTE » avant « Document ».
const exact = policies.find((policy) => policy.objectType === objectType);
if (exact) return exact;
const base = objectType.split(':')[0] as string;
return policies.find((policy) => policy.objectType === base);
})
.filter((policy) => policy !== undefined);
if (matches.length === 0) return 'Non déclarée';
const months = [...new Set(matches.map((policy) => policy.durationMonths))];
return months
.sort((a, b) => a - b)
.map((count) =>
count % 12 === 0 ? `${count / 12} an${count > 12 ? 's' : ''}` : `${count} mois`,
)
.join(' · ');
};
const undeclared = DATA_DICTIONARY.filter(
(entry) => durationFor(entry.objectTypes) === 'Non déclarée',
).length;
return (
<PageBody>
<PageHeader
title="RGPD"
subtitle="Dictionnaire des données : finalité, base légale, destinataires, durée"
/>
<p
role="note"
className="max-w-[70ch] rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs leading-[var(--lh-prose)] text-warn-soft-ink"
>
L’affectation d’une base légale est une qualification juridique. Ce
tableau porte la lecture usuelle d’un traitement de paie et de planning ;
il doit être confirmé par le responsable de traitement avant d’être
opposé à qui que ce soit.
</p>
{undeclared > 0 ? (
<p
role="status"
className="max-w-[70ch] rounded-2 border border-danger bg-danger-soft px-3 py-2 text-xs leading-[var(--lh-prose)] text-danger-soft-ink"
>
{undeclared} catégorie{undeclared > 1 ? 's' : ''} sans durée de
conservation déclarée. Le plan interdit d’appliquer une durée par
défaut : déclarez-la dans{' '}
<Link href="/reglages/conservation" className="underline">
Durées de conservation
</Link>
.
</p>
) : null}
<Card>
<CardHeader
title="Données traitées"
badge={<Badge tone="neutral">{DATA_DICTIONARY.length}</Badge>}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{[
'Catégorie',
'Finalité',
'Base légale',
'Destinataires',
'Durée',
].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">
{DATA_DICTIONARY.map((entry) => {
const duration = durationFor(entry.objectTypes);
return (
<tr key={entry.category}>
<td className="px-4 py-3 align-top">
<span className="font-medium">{entry.category}</span>
{entry.sensitive ? (
<Badge tone="danger" className="ml-1.5">
Catégorie particulière
</Badge>
) : null}
<span className="mt-1 block font-mono text-micro text-ink-3">
{entry.objectTypes.join(', ')}
</span>
</td>
<td className="max-w-[28ch] px-4 py-3 align-top text-ink-2">
{entry.purpose}
{entry.notes ? (
<span className="mt-1 block text-micro text-ink-3">
{entry.notes}
</span>
) : null}
</td>
<td className="px-4 py-3 align-top">
{LEGAL_BASIS_LABELS[entry.basis]}
</td>
<td className="px-4 py-3 align-top text-ink-2">
{entry.recipients.join(', ')}
</td>
<td className="px-4 py-3 align-top">
{duration === 'Non déclarée' ? (
<Badge tone="danger">Non déclarée</Badge>
) : (
<span className="tnum">{duration}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
<Card>
<CardHeader title="Droits des personnes" />
<div className="flex flex-col gap-2 px-4 py-3 text-sm leading-[var(--lh-prose)] text-ink-2">
<p>
L’accès, la rectification et l’effacement s’exercent sur le dossier
du salarié. L’effacement ne franchit pas les durées de conservation
légales : une pièce sous obligation de conservation ne s’efface pas
à la demande, et le refus doit être motivé.
</p>
<p>
La portabilité passe par le paquet de départ : export filtré des
données du salarié, accompagné de son manifeste et de son empreinte.
</p>
<p>
Toute lecture d’une pièce de santé est journalisée et consultable
dans le{' '}
<Link href="/rapports/activite" className="underline">
journal d’activité
</Link>
.
</p>
</div>
</Card>
</PageBody>
);
}