Rendre les politiques de RTT administrables

Le modèle existait depuis WP-06, l'écran d'embauche proposait déjà de rattacher
un salarié à une politique — mais rien ne permettait d'en créer une. La liste
déroulante était vide sur une instance neuve, et le droit à RTT restait
inatteignable.

Les deux actions de ligne sont celles relevées à l'audit : assigner des employés,
archiver. Pas de suppression. Une politique a produit des acquisitions au
registre des compteurs ; l'effacer rendrait ces écritures inexplicables, et un
solde de RTT est un droit ouvert, pas un affichage.

Archiver arrête la reconduction sans toucher au reste : les jours déjà acquis
restent au registre, les affectations en place. C'est la distinction que demande
le critère d'acceptation de WP-06 — une politique archivée cesse de reconduire,
elle ne défait pas ce qu'elle a fait.

Le début de période s'écrit « MM-JJ », sans millésime : une politique qui
porterait une année complète devrait être réécrite chaque janvier.

L'affectation passe par un upsert sur la clé composée : réassigner un salarié
déjà couvert ne doit ni échouer ni créer un doublon d'acquisition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 12:47:51 +02:00
1 parent db03768025
commit 8c12b08961
4 files changed
+621 -1

No files matched your search

@@ -0,0 +1,131 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
assignRttPolicyAction,
createRttPolicyAction,
type ActionState,
} from '@/server/settings/rtt-policies';
const empty: ActionState = {};
export function AddPolicyForm() {
const [state, action] = useActionState(createRttPolicyAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<div className="flex flex-wrap gap-3">
<Field
label="Nom"
name="name"
required
maxLength={160}
placeholder="RTT cadres"
/>
<Field
label="Jours par an"
name="daysPerYear"
type="number"
step="0.5"
min="0"
max="365"
required
defaultValue="0"
/>
<Field
label="Début de période"
name="periodStart"
required
placeholder="01-01"
pattern="(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])"
hint="Format MM-JJ : la période se reconduit d’une année sur l’autre, sans millésime à réécrire."
/>
</div>
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" name="autoRenew" defaultChecked className="mt-0.5" />
<span>
Reconduction automatique
<span className="block text-micro text-ink-3">
La politique ouvre un nouveau droit à chaque début de période.
Archivée, elle cesse de le faire.
</span>
</span>
</label>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Créer la politique</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Politique enregistrée.</span>
) : null}
</div>
</PersistentForm>
);
}
export function AssignForm({
policyId,
candidates,
assigned,
}: {
policyId: string;
candidates: Array<{ membershipId: string; name: string }>;
assigned: string[];
}) {
const [state, action] = useActionState(assignRttPolicyAction, empty);
const available = candidates.filter(
(candidate) => !assigned.includes(candidate.membershipId),
);
if (available.length === 0) {
return (
<p className="text-xs text-ink-3">
Tous les salariés actifs sont déjà couverts par cette politique.
</p>
);
}
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-2"
>
<input type="hidden" name="policyId" value={policyId} />
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Salariés à assigner</span>
<select
name="membershipIds"
multiple
size={Math.min(8, available.length)}
className="rounded-2 border border-line-2 bg-surface px-2 py-1 text-sm text-ink-1"
>
{available.map((candidate) => (
<option key={candidate.membershipId} value={candidate.membershipId}>
{candidate.name}
</option>
))}
</select>
</label>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton size="sm">Assigner</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Affectation enregistrée.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -0,0 +1,185 @@
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 {
AddPolicyForm,
AssignForm,
} from '@/app/(app)/reglages/politiques-rtt/PolicyForms';
import {
archiveRttPolicyAction,
getPolicyBoard,
unassignRttPolicyAction,
} from '@/server/settings/rtt-policies';
export const metadata = { title: 'Politiques de RTT · PlanFlow' };
export const dynamic = 'force-dynamic';
/** « 01-01 » → « 1er janvier ». */
function periodLabel(periodStart: string): string {
const [month, day] = periodStart.split('-');
if (!month || !day) return periodStart;
const date = new Date(Date.UTC(2001, Number(month) - 1, Number(day)));
const formatted = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
timeZone: 'UTC',
}).format(date);
return day === '01' ? formatted.replace(/^1 /, '1er ') : formatted;
}
export default async function PolitiquesRttPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const board = await getPolicyBoard(showArchived);
const active = board.policies.filter((policy) => policy.status === 'ACTIVE');
return (
<PageBody>
<PageHeader
title="Politiques de RTT"
subtitle={`${active.length} politique${active.length > 1 ? 's' : ''} active${active.length > 1 ? 's' : ''}`}
/>
<Card>
<CardHeader
title="Politiques"
action={
<a
href={
showArchived
? '/reglages/politiques-rtt'
: '/reglages/politiques-rtt?archives=1'
}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivées' : 'Voir les archivées'}
</a>
}
/>
{board.policies.length === 0 ? (
<EmptyState
title="Aucune politique"
description="Une politique ouvre un droit à RTT et l’inscrit au registre des compteurs à chaque début de période."
/>
) : (
<ul className="divide-y divide-line-1">
{board.policies.map((policy) => (
<li key={policy.id} className="px-4 py-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{policy.name}</span>
<Badge tone="accent" className="tnum">
{policy.daysPerYear} j/an
</Badge>
<Badge tone="neutral">
à partir du {periodLabel(policy.periodStart)}
</Badge>
{policy.autoRenew ? (
<Badge tone="ok">Reconduite</Badge>
) : (
<Badge tone="neutral">Période unique</Badge>
)}
{policy.status === 'ARCHIVED' ? (
<Badge tone="warn">Archivée</Badge>
) : null}
<span className="flex-1" />
<form action={archiveRttPolicyAction}>
<input type="hidden" name="id" value={policy.id} />
{policy.status === 'ARCHIVED' ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{policy.status === 'ARCHIVED' ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</div>
<p className="mb-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
{policy.assignedCount} salarié
{policy.assignedCount > 1 ? 's' : ''} couvert
{policy.assignedCount > 1 ? 's' : ''}
</p>
{policy.assignees.length > 0 ? (
<ul className="mb-3 flex flex-wrap gap-1.5">
{policy.assignees.map((assignee) => (
<li key={assignee.membershipId}>
<form
action={unassignRttPolicyAction}
className="inline-flex items-center gap-1 rounded-full border border-line-2 bg-surface-3 py-px pr-1 pl-2"
>
<input
type="hidden"
name="policyId"
value={policy.id}
/>
<input
type="hidden"
name="membershipId"
value={assignee.membershipId}
/>
<span className="text-micro font-semibold text-ink-2">
{assignee.name}
</span>
<button
type="submit"
aria-label={`Retirer ${assignee.name} de ${policy.name}`}
className="cursor-pointer rounded-full px-1 text-micro text-ink-3 hover:text-danger"
>
×
</button>
</form>
</li>
))}
</ul>
) : (
<p className="mb-3 text-xs text-ink-3">
Aucun salarié assigné : cette politique n’ouvre aucun droit.
</p>
)}
{policy.status === 'ACTIVE' ? (
<details className="text-sm">
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
Assigner des employés
</summary>
<div className="pt-3">
<AssignForm
policyId={policy.id}
candidates={board.candidates}
assigned={policy.assignees.map(
(assignee) => assignee.membershipId,
)}
/>
</div>
</details>
) : null}
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouvelle politique" />
<div className="p-4">
<AddPolicyForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Archiver arrête la reconduction ; les jours déjà acquis restent au
registre des compteurs et les affectations en place. Une politique
n’efface pas ce qu’elle a produit — un solde de RTT est un droit ouvert,
pas un affichage.
</p>
</PageBody>
);
}
+5 -1
View File
@@ -95,7 +95,11 @@ export const NAVIGATION: NavSection[] = [
{ id: 'attente', label: 'Demandes à traiter', href: '/absences/a-traiter' },
{ id: 'traitees', label: 'Traitées', href: '/absences/traitees' },
{ id: 'expirees', label: 'Expirées', href: '/absences/expirees' },
{ id: 'politiques', label: 'Politiques de congés' },
{
id: 'politiques',
label: 'Politiques de RTT',
href: '/reglages/politiques-rtt',
},
],
},
{
+300
View File
@@ -0,0 +1,300 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Politiques de RTT — PLAN.md §9, réglage « /reglages/politiques-rtt ».
*
* Une politique porte un nombre de jours par an et une date de début de
* période. Elle **se reconduit automatiquement** tant qu'elle est active ;
* archivée, elle cesse de le faire — c'est le critère d'acceptation de WP-06.
*
* Les deux actions de ligne relevées à l'audit sont « Assigner des employés » et
* « Archiver ». Pas de suppression : une politique a produit des acquisitions au
* registre des compteurs, et l'effacer rendrait ces écritures inexplicables.
*
* **Autorisation.** `settings.agreement.manage`, comme les types d'absence : le
* catalogue §5 ne déclare pas de code dédié, et un droit à RTT relève du
* paramétrage conventionnel. Inventer une capacité aurait créé un droit sans
* rien protéger de neuf.
*/
export interface RttPolicyRow {
id: string;
name: string;
daysPerYear: string;
/** « MM-JJ ». */
periodStart: string;
autoRenew: boolean;
status: 'ACTIVE' | 'ARCHIVED';
assignedCount: number;
assignees: Array<{ membershipId: string; name: string }>;
}
export interface PolicyBoard {
policies: RttPolicyRow[];
/** Salariés assignables, pour le formulaire d'affectation. */
candidates: Array<{ membershipId: string; name: string }>;
}
export async function getPolicyBoard(
includeArchived = false,
): Promise<PolicyBoard> {
return query('settings.access', async (db) => {
const policies = await db.rttPolicy.findMany({
where: includeArchived ? {} : { status: 'ACTIVE' },
orderBy: { name: 'asc' },
include: { assignments: true },
});
const members = await db.membership.findMany({
where: { archivedAt: null },
select: {
id: true,
employeeNumber: true,
profile: { select: { firstName: true, lastName: true } },
},
});
const nameById = new Map(
members.map((member) => [
member.id,
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
]),
);
return {
policies: policies.map((policy) => ({
id: policy.id,
name: policy.name,
daysPerYear: policy.daysPerYear.toString(),
periodStart: policy.periodStart,
autoRenew: policy.autoRenew,
status: policy.status,
assignedCount: policy.assignments.length,
assignees: policy.assignments
.map((assignment) => ({
membershipId: assignment.membershipId,
name: nameById.get(assignment.membershipId) ?? 'Salarié',
}))
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
})),
candidates: [...nameById.entries()]
.map(([membershipId, name]) => ({ membershipId, name }))
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
};
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
class DuplicatePolicy extends Error {}
const policyInput = z.object({
name: z.string().trim().min(1, 'Nom requis').max(160),
daysPerYear: z.coerce
.number()
.min(0, 'Un droit négatif n’existe pas')
.max(365, 'Un droit annuel ne dépasse pas 365 jours'),
// « MM-JJ » plutôt qu'une date complète : la période se reconduit d'une année
// sur l'autre, et porter un millésime obligerait à réécrire la politique
// chaque janvier.
periodStart: z
.string()
.trim()
.regex(/^(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/, 'Format attendu : MM-JJ'),
autoRenew: z.boolean(),
});
function readForm(formData: FormData) {
return {
name: formData.get('name'),
daysPerYear: formData.get('daysPerYear') ?? 0,
periodStart: formData.get('periodStart') ?? '',
autoRenew: formData.get('autoRenew') === 'on',
};
}
export async function createRttPolicyAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = policyInput.safeParse(readForm(formData));
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const existing = await db.rttPolicy.findFirst({
where: { name: parsed.data.name },
});
if (existing) throw new DuplicatePolicy();
const created = await db.rttPolicy.create({
data: parsed.data as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'rtt_policy.create',
entityType: 'RttPolicy',
entityId: created.id,
after: {
name: created.name,
daysPerYear: created.daysPerYear.toString(),
periodStart: created.periodStart,
autoRenew: created.autoRenew,
},
});
});
} catch (error) {
if (error instanceof DuplicatePolicy) {
return { error: 'Une politique porte déjà ce nom.' };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les politiques." };
}
throw error;
}
revalidatePath('/reglages/politiques-rtt');
return { ok: true };
}
export async function archiveRttPolicyAction(
formData: FormData,
): Promise<void> {
const id = String(formData.get('id') ?? '');
const restore = formData.get('restore') === '1';
if (!id) return;
await mutate('settings.agreement.manage', async (db, actor) => {
const before = await db.rttPolicy.findUnique({ where: { id } });
if (!before) return;
// Archiver arrête la reconduction ; les affectations restent en place, et
// les jours déjà acquis restent au registre. Une politique n'efface pas ce
// qu'elle a produit.
const status = restore ? 'ACTIVE' : 'ARCHIVED';
await db.rttPolicy.update({ where: { id }, data: { status } });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: restore ? 'rtt_policy.restore' : 'rtt_policy.archive',
entityType: 'RttPolicy',
entityId: id,
before: { status: before.status },
after: { status },
});
});
revalidatePath('/reglages/politiques-rtt');
}
const assignInput = z.object({
policyId: z.string().min(1),
membershipIds: z.array(z.string().min(1)).min(1, 'Sélectionnez un salarié'),
});
export async function assignRttPolicyAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = assignInput.safeParse({
policyId: formData.get('policyId'),
membershipIds: formData.getAll('membershipIds').map(String),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const policy = await db.rttPolicy.findUnique({
where: { id: parsed.data.policyId },
});
if (!policy) throw new AuthorizationError('settings.agreement.manage');
if (policy.status === 'ARCHIVED') throw new ArchivedPolicy();
// Le périmètre est revérifié en base : un identifiant soumis depuis le
// formulaire pourrait désigner un salarié d'un autre compte.
const members = await db.membership.findMany({
where: { id: { in: parsed.data.membershipIds } },
select: { id: true },
});
for (const member of members) {
// `upsert` sur la clé composée : réassigner un salarié déjà couvert ne
// doit ni échouer ni créer un doublon d'acquisition.
await db.rttPolicyAssignment.upsert({
where: {
rttPolicyId_membershipId: {
rttPolicyId: policy.id,
membershipId: member.id,
},
},
create: { rttPolicyId: policy.id, membershipId: member.id } as never,
update: {},
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'rtt_policy.assign',
entityType: 'RttPolicy',
entityId: policy.id,
after: { membershipIds: members.map((member) => member.id) },
});
});
} catch (error) {
if (error instanceof ArchivedPolicy) {
return {
error:
'Cette politique est archivée : elle ne produit plus d’acquisition.',
};
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les politiques." };
}
throw error;
}
revalidatePath('/reglages/politiques-rtt');
return { ok: true };
}
class ArchivedPolicy extends Error {}
export async function unassignRttPolicyAction(
formData: FormData,
): Promise<void> {
const policyId = String(formData.get('policyId') ?? '');
const membershipId = String(formData.get('membershipId') ?? '');
if (!policyId || !membershipId) return;
await mutate('settings.agreement.manage', async (db, actor) => {
const removed = await db.rttPolicyAssignment.deleteMany({
where: { rttPolicyId: policyId, membershipId },
});
if (removed.count === 0) return;
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'rtt_policy.unassign',
entityType: 'RttPolicy',
entityId: policyId,
before: { membershipId },
});
});
revalidatePath('/reglages/politiques-rtt');
}