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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in new issue
Block a user