Ouvrir les référentiels au paramétrage

Emplois, étiquettes et types d'absence vivaient en base sans écran : le seed
les posait, et rien ne permettait ensuite d'en ajouter un. Ils s'éditent
désormais depuis les réglages.

Trois choix portent la conformité plutôt que le confort :

- Archiver, jamais supprimer. Un emploi figure au registre du personnel, une
  étiquette colore des semaines closes, un type d'absence justifie des
  écritures de compteur. Supprimer l'un des trois rendrait illisible un dossier
  dont la conservation court encore.
- Le code Silae manquant est signalé. Un type sans code fait échouer l'export
  de la période ; l'écran le dit avant l'échéance de paie plutôt qu'après.
- La convention se réédite, elle ne se modifie pas. Chaque enregistrement crée
  une version datée : une paie de mars doit rester reproductible après un
  changement de seuil en juin. L'origine de chaque valeur — ordre public,
  convention, accord d'entreprise — est affichée, parce que les trois ne se
  corrigent pas de la même main.

Les mutations de types d'absence passent par settings.agreement.manage : le
catalogue §5 ne déclare pas de code dédié, et ces drapeaux relèvent du
paramétrage légal. Inventer une capacité aurait créé un droit sans rien
protéger de neuf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 12:17:50 +02:00
1 parent 9e57467138
commit 527ea2e78a
13 files changed
+2039 -2

No files matched your search

@@ -0,0 +1,187 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import type { AgreementParameters } from '@/domain/compliance/parameters';
import {
publishAgreementVersionAction,
type ActionState,
} from '@/server/settings/agreement';
const empty: ActionState = {};
function Group({
title,
note,
children,
}: {
title: string;
note?: string;
children: React.ReactNode;
}) {
return (
<fieldset className="flex flex-col gap-2 border-t border-line-1 pt-3">
<legend className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
{title}
</legend>
{note ? <p className="text-micro text-ink-3">{note}</p> : null}
<div className="flex flex-wrap gap-3">{children}</div>
</fieldset>
);
}
function Area({
label,
name,
defaultValue,
hint,
}: {
label: string;
name: string;
defaultValue: string;
hint: string;
}) {
return (
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
<textarea
name={name}
rows={4}
defaultValue={defaultValue}
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">{hint}</span>
</label>
);
}
export function AgreementForm({
idcc,
name,
parameters,
}: {
idcc: string;
name: string;
parameters: AgreementParameters;
}) {
const [state, action] = useActionState(publishAgreementVersionAction, empty);
const p = parameters;
const tiers = p.overtime.tiers
.map((tier) => `${tier.fromMinutes};${tier.toMinutes ?? ''};${tier.ratePercent}`)
.join('\n');
const derogations = p.partTime.derogations
.map((row) => `${row.code};${row.label};${row.minWeeklyMinutes}`)
.join('\n');
return (
<PersistentForm action={action} className="flex flex-col gap-4 p-4">
<Group title="Version" note="Les durées sont en minutes entières.">
<Field label="IDCC" name="idcc" defaultValue={idcc} required maxLength={10} />
<Field label="Intitulé" name="name" defaultValue={name} required maxLength={160} />
<Field
label="En vigueur à partir du"
name="effectiveFrom"
type="date"
required
hint="La version précédente reste applicable aux semaines antérieures."
/>
<Field
label="Source"
name="source"
required
maxLength={400}
placeholder="Legifrance, texte consolidé au…"
hint="Sans source, le paramètre n’est pas opposable."
/>
<Field label="Approuvé par" name="approvedBy" maxLength={160} />
</Group>
<Group title="Durées">
<Field label="Référence hebdomadaire" name="weeklyReferenceMinutes" type="number" defaultValue={p.weeklyReferenceMinutes} required />
<Field label="Maximum quotidien" name="maxDailyWorkMinutes" type="number" defaultValue={p.maxDailyWorkMinutes} required />
<Field
label="Amplitude quotidienne"
name="maxDailyAmplitudeMinutes"
type="number"
defaultValue={p.maxDailyAmplitudeMinutes ?? ''}
hint="Vide : la convention n’en fixe pas, la règle reste muette."
/>
<Field label="Repos quotidien minimal" name="minDailyRestMinutes" type="number" defaultValue={p.minDailyRestMinutes} required />
<Field label="Repos hebdomadaire minimal" name="minWeeklyRestMinutes" type="number" defaultValue={p.minWeeklyRestMinutes} required />
<Field label="Maximum hebdomadaire absolu" name="maxWeeklyWorkAbsoluteMinutes" type="number" defaultValue={p.maxWeeklyWorkAbsoluteMinutes} required />
<Field label="Fenêtre de moyenne (semaines)" name="averagedWindowWeeks" type="number" defaultValue={p.averagedWeeklyWork.windowWeeks} required />
<Field label="Moyenne maximale" name="averagedMaxMinutes" type="number" defaultValue={p.averagedWeeklyWork.maxAverageMinutes} required />
<Field label="Jours consécutifs maximum" name="maxConsecutiveWorkDays" type="number" defaultValue={p.maxConsecutiveWorkDays} required />
<Field label="Seuil de pause" name="breakThresholdMinutes" type="number" defaultValue={p.breakAfterThreshold.thresholdMinutes} required />
<Field label="Pause minimale" name="breakMinMinutes" type="number" defaultValue={p.breakAfterThreshold.minBreakMinutes} required />
<Field label="Tolérance d’écart au contrat" name="deviationToleranceMinutes" type="number" defaultValue={p.contractDeviationToleranceMinutes} required />
</Group>
<Group title="Temps partiel">
<Field label="Minimum hebdomadaire" name="partTimeMinWeeklyMinutes" type="number" defaultValue={p.partTime.minWeeklyMinutes} required />
<Area
label="Dérogations"
name="derogations"
defaultValue={derogations}
hint="Une par ligne : code;libellé;minutes."
/>
</Group>
<Group title="Heures supplémentaires">
<Area
label="Tranches"
name="overtimeTiers"
defaultValue={tiers}
hint="Une par ligne : début;fin;taux. Fin vide = sans plafond. Cumulatives depuis le début de la semaine."
/>
<Field label="Contingent annuel" name="overtimeQuotaMinutes" type="number" defaultValue={p.overtime.annualQuotaMinutes} required />
<Field label="Repos au-delà du contingent (%)" name="overtimeBeyondRestPercent" type="number" step="0.01" defaultValue={p.overtime.beyondQuotaRestPercent} required />
</Group>
<Group title="Heures complémentaires">
<Field label="Taux première tranche (%)" name="complementaryFirstRate" type="number" step="0.01" defaultValue={p.complementaryHours.firstTierRatePercent} required />
<Field label="Taux au-delà (%)" name="complementaryBeyondRate" type="number" step="0.01" defaultValue={p.complementaryHours.beyondRatePercent} required />
<Field label="Fraction première tranche" name="complementaryFirstFraction" type="number" step="0.01" defaultValue={p.complementaryHours.firstTierFraction} required />
<Field label="Plafond (fraction)" name="complementaryCapFraction" type="number" step="0.01" defaultValue={p.complementaryHours.capFraction} required />
</Group>
<Group
title="Dimanche"
note="La majoration et le repos compensateur vont ensemble : l’un sans l’autre est un manquement à L3132-27."
>
<Field label="Majoration (%)" name="sundayPremiumPercent" type="number" step="0.01" defaultValue={p.sunday.premiumPercent} required />
<Field label="Repos compensateur (%)" name="sundayRestPercent" type="number" step="0.01" defaultValue={p.sunday.compensatoryRestPercent} required />
<Field label="Dimanches du maire par an" name="sundayMayorQuota" type="number" defaultValue={p.sunday.mayorQuotaPerYear} required />
</Group>
<Group title="Jours fériés">
<Field label="Indemnité si travaillé (%)" name="holidayWorkedPremium" type="number" step="0.01" defaultValue={p.holiday.workedPremiumPercent} required />
<Field label="1er mai travaillé (%)" name="holidayLabourDayPremium" type="number" step="0.01" defaultValue={p.holiday.labourDayPremiumPercent} required />
<Field label="Fériés chômés garantis" name="holidayPaidOffDays" type="number" defaultValue={p.holiday.paidOffDaysPerYear} required />
<Field label="Repos de substitution (%)" name="holidaySubstitutionRest" type="number" step="0.01" defaultValue={p.holiday.substitutionRestPercent} required />
</Group>
<Group title="Nuit et forfait jours">
<Field label="Début de nuit (min. depuis minuit)" name="nightStartMinutes" type="number" defaultValue={p.night.startMinutes} required />
<Field label="Fin de nuit" name="nightEndMinutes" type="number" defaultValue={p.night.endMinutes} required />
<Field label="Non imposable à partir de" name="nightNotEnforceableFromAge" type="number" defaultValue={p.night.notEnforceableFromAge} required />
<Field label="Jours de forfait maximum" name="forfaitMaxDaysPerYear" type="number" defaultValue={p.forfaitJours.maxDaysPerYear} required />
<Field label="Entretien de charge (mois)" name="forfaitReviewMonths" type="number" defaultValue={p.forfaitJours.workloadReviewIntervalMonths} required />
</Group>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Publier une nouvelle version</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">
Version enregistrée. Les semaines antérieures gardent la précédente.
</span>
) : null}
</div>
</PersistentForm>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { AgreementForm } from '@/app/(app)/reglages/convention/AgreementForm';
import {
IDCC_1517_PROVENANCE,
type ParameterOrigin,
} from '@/domain/compliance/idcc1517';
import { listAgreementVersions } from '@/server/settings/agreement';
export const metadata = { title: 'Convention collective · PlanFlow' };
export const dynamic = 'force-dynamic';
const ORIGIN_TONE: Record<ParameterOrigin, 'danger' | 'info' | 'warn'> = {
OP: 'danger',
CCN: 'info',
ENT: 'warn',
};
const ORIGIN_LABEL: Record<ParameterOrigin, string> = {
OP: 'Ordre public',
CCN: 'Convention',
ENT: 'Accord d’entreprise',
};
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
export default async function ConventionPage() {
const versions = await listAgreementVersions();
const current = versions.find((version) => version.isCurrent) ?? versions[0];
return (
<PageBody>
<PageHeader
title="Convention collective"
subtitle={
current
? `IDCC ${current.idcc} · version ${current.version}`
: 'Aucune convention chargée'
}
/>
{!current ? (
<Card>
<EmptyState
title="Aucune convention"
description="Le moteur de règles lit ses seuils en base. Sans convention chargée, aucune règle horaire ne s’applique."
/>
</Card>
) : null}
<Card>
<CardHeader
title="Origine des valeurs"
badge={<Badge tone="neutral">{IDCC_1517_PROVENANCE.length}</Badge>}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Paramètre
</th>
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Valeur
</th>
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Origine
</th>
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Source
</th>
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{IDCC_1517_PROVENANCE.map((entry) => (
<tr key={entry.key}>
<td className="px-4 py-2">{entry.label}</td>
<td className="px-4 py-2 tnum">{entry.value}</td>
<td className="px-4 py-2">
<Badge tone={ORIGIN_TONE[entry.origin]}>
{ORIGIN_LABEL[entry.origin]}
</Badge>
</td>
<td className="px-4 py-2 text-ink-2">{entry.source}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="border-t border-line-1 px-4 py-3 text-xs leading-[var(--lh-prose)] text-ink-3">
Une valeur d’ordre public ne se négocie pas ; une valeur
conventionnelle change si la convention change ; une valeur d’accord
d’entreprise suppose un accord signé. Confondre les trois fait
apparaître comme discutable ce qui s’impose, et l’inverse.
</p>
</Card>
<Card>
<CardHeader
title="Versions"
badge={<Badge tone="neutral">{versions.length}</Badge>}
/>
{versions.length === 0 ? (
<EmptyState title="Aucune version enregistrée" />
) : (
<ul className="divide-y divide-line-1">
{versions.map((version) => (
<li key={version.id} className="flex flex-wrap items-center gap-2 px-4 py-3">
<Badge tone={version.isCurrent ? 'ok' : 'neutral'}>
v{version.version}
</Badge>
<span className="text-sm font-medium">{version.name}</span>
<span className="text-sm text-ink-2">
en vigueur au {dateFormat.format(version.effectiveFrom)}
</span>
{version.isCurrent ? <Badge tone="accent">Applicable</Badge> : null}
<span className="flex-1" />
<span className="text-xs text-ink-3">
{version.approvedBy
? `Approuvée par ${version.approvedBy}`
: 'Non approuvée'}
</span>
</li>
))}
</ul>
)}
</Card>
{current ? (
<Card>
<CardHeader
title="Éditer les paramètres"
badge={<Badge tone="warn">crée une version</Badge>}
/>
<AgreementForm
idcc={current.idcc}
name={current.name}
parameters={current.parameters}
/>
</Card>
) : null}
</PageBody>
);
}
@@ -0,0 +1,67 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createJobTitleAction,
renameJobTitleAction,
type ActionState,
} from '@/server/settings/job-titles';
const empty: ActionState = {};
export function AddJobTitleForm() {
const [state, action] = useActionState(createJobTitleAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<div className="flex flex-wrap items-end gap-3">
<Field
label="Intitulé"
name="name"
required
maxLength={120}
placeholder="Hôte de caisse"
hint="L’intitulé figure au registre unique du personnel et sur le contrat."
/>
<SubmitButton>Ajouter l’emploi</SubmitButton>
</div>
<FormError>{state.error}</FormError>
{state.ok ? (
<p className="text-xs text-ok-soft-ink">Emploi enregistré.</p>
) : null}
</PersistentForm>
);
}
export function RenameJobTitleForm({
id,
name,
}: {
id: string;
name: string;
}) {
const [state, action] = useActionState(renameJobTitleAction, empty);
return (
<PersistentForm action={action} className="flex flex-1 items-end gap-2">
<input type="hidden" name="id" value={id} />
<Field
label="Intitulé"
name="name"
defaultValue={name}
required
maxLength={120}
className="h-8"
/>
<SubmitButton size="sm">Renommer</SubmitButton>
<FormError>{state.error}</FormError>
</PersistentForm>
);
}
+103
View File
@@ -0,0 +1,103 @@
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 {
AddJobTitleForm,
RenameJobTitleForm,
} from '@/app/(app)/reglages/emplois/JobTitleForms';
import {
archiveJobTitleAction,
listJobTitles,
} from '@/server/settings/job-titles';
export const metadata = { title: 'Emplois · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function EmploisPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const titles = await listJobTitles(showArchived);
const active = titles.filter((title) => title.archivedAt === null);
return (
<PageBody>
<PageHeader
title="Emplois"
subtitle={`${active.length} emploi${active.length > 1 ? 's' : ''} en vigueur`}
/>
<Card>
<CardHeader
title="Référentiel"
badge={
<Badge tone="neutral">
{titles.length} affiché{titles.length > 1 ? 's' : ''}
</Badge>
}
action={
<a
href={showArchived ? '/reglages/emplois' : '/reglages/emplois?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>
}
/>
{titles.length === 0 ? (
<EmptyState
title="Aucun emploi"
description="Un contrat porte une qualification ; créez d’abord les emplois de l’organisation."
/>
) : (
<ul className="divide-y divide-line-1">
{titles.map((title) => (
<li
key={title.id}
className="flex flex-wrap items-end gap-3 px-4 py-3"
>
<RenameJobTitleForm id={title.id} name={title.name} />
<div className="flex items-center gap-2">
<Badge tone="neutral">
{title.contractCount} contrat
{title.contractCount > 1 ? 's' : ''}
</Badge>
{title.archivedAt ? <Badge tone="warn">Archivé</Badge> : null}
</div>
<form action={archiveJobTitleAction}>
<input type="hidden" name="id" value={title.id} />
{title.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{title.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouvel emploi" />
<div className="p-4">
<AddJobTitleForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Un emploi porté par un contrat est archivé, jamais supprimé : le registre
unique du personnel doit rester lisible pendant toute la durée de
conservation, y compris pour un salarié parti.
</p>
</PageBody>
);
}
@@ -0,0 +1,85 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import { POSTE_CODES, POSTE_LABELS } from '@/lib/design/postes';
import {
createLabelAction,
updateLabelAction,
type ActionState,
} from '@/server/settings/labels';
const empty: ActionState = {};
function PaletteSelect({ defaultValue }: { defaultValue?: string }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Teinte</span>
<select
name="paletteKey"
defaultValue={defaultValue ?? POSTE_CODES[0]}
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
{POSTE_CODES.map((code) => (
<option key={code} value={code}>
{POSTE_LABELS[code]}
</option>
))}
</select>
</label>
);
}
export function AddLabelForm() {
const [state, action] = useActionState(createLabelAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<div className="flex flex-wrap items-end gap-3">
<Field
label="Code"
name="code"
required
maxLength={12}
placeholder="cai"
hint="Affiché sur le bloc de planning : la couleur ne porte jamais l’information seule."
/>
<Field label="Libellé" name="name" required maxLength={120} placeholder="Caisse" />
<PaletteSelect />
<SubmitButton>Ajouter l’étiquette</SubmitButton>
</div>
<FormError>{state.error}</FormError>
{state.ok ? (
<p className="text-xs text-ok-soft-ink">Étiquette enregistrée.</p>
) : null}
</PersistentForm>
);
}
export function EditLabelForm({
id,
name,
paletteKey,
}: {
id: string;
name: string;
paletteKey: string;
}) {
const [state, action] = useActionState(updateLabelAction, empty);
return (
<PersistentForm action={action} className="flex flex-1 flex-wrap items-end gap-2">
<input type="hidden" name="id" value={id} />
<Field label="Libellé" name="name" defaultValue={name} required maxLength={120} />
<PaletteSelect defaultValue={paletteKey} />
<SubmitButton size="sm">Enregistrer</SubmitButton>
<FormError>{state.error}</FormError>
</PersistentForm>
);
}
+125
View File
@@ -0,0 +1,125 @@
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 {
AddLabelForm,
EditLabelForm,
} from '@/app/(app)/reglages/etiquettes/LabelForms';
import { isPosteCode, posteTokens } from '@/lib/design/postes';
import { archiveLabelAction, listLabels } from '@/server/settings/labels';
export const metadata = { title: 'Étiquettes · PlanFlow' };
export const dynamic = 'force-dynamic';
function Swatch({ paletteKey, code }: { paletteKey: string; code: string }) {
if (!isPosteCode(paletteKey)) {
return <Badge tone="warn">teinte inconnue</Badge>;
}
const tokens = posteTokens(paletteKey);
return (
<span
className="inline-flex h-6 shrink-0 items-center rounded-2 border px-2 text-micro font-semibold"
style={{
background: tokens.bg,
color: tokens.fg,
borderColor: tokens.edge,
}}
>
{code.toUpperCase()}
</span>
);
}
export default async function EtiquettesPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const labels = await listLabels(showArchived);
const active = labels.filter((label) => label.archivedAt === null);
return (
<PageBody>
<PageHeader
title="Étiquettes"
subtitle={`${active.length} étiquette${active.length > 1 ? 's' : ''} en vigueur`}
/>
<Card>
<CardHeader
title="Postes de la grille"
action={
<a
href={
showArchived
? '/reglages/etiquettes'
: '/reglages/etiquettes?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>
}
/>
{labels.length === 0 ? (
<EmptyState
title="Aucune étiquette"
description="Une étiquette colore le créneau et nomme le poste tenu. Créez-en une pour commencer."
/>
) : (
<ul className="divide-y divide-line-1">
{labels.map((label) => (
<li
key={label.id}
className="flex flex-wrap items-end gap-3 px-4 py-3"
>
<Swatch paletteKey={label.paletteKey} code={label.code} />
<EditLabelForm
id={label.id}
name={label.name}
paletteKey={label.paletteKey}
/>
<div className="flex items-center gap-2">
<Badge tone="neutral">
{label.shiftCount} créneau{label.shiftCount > 1 ? 'x' : ''}
</Badge>
{label.archivedAt ? <Badge tone="warn">Archivée</Badge> : null}
</div>
<form action={archiveLabelAction}>
<input type="hidden" name="id" value={label.id} />
{label.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{label.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouvelle étiquette" />
<div className="p-4">
<AddLabelForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Les douze teintes viennent d’une palette calculée pour rester
distinguables en vision daltonienne et à l’impression en noir et blanc.
C’est pourquoi le code du poste s’affiche toujours avec la couleur, et
pourquoi la teinte se choisit dans la liste plutôt qu’à la pipette.
</p>
</PageBody>
);
}
@@ -0,0 +1,156 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createAbsenceTypeAction,
updateAbsenceTypeAction,
type AbsenceTypeRow,
type ActionState,
} from '@/server/settings/absence-types';
const empty: ActionState = {};
/** Drapeaux du type, avec ce que chacun décide réellement. */
const FLAGS = [
{
name: 'isPaid',
label: 'Rémunérée',
hint: 'L’absence est payée par l’employeur.',
},
{
name: 'countsAsWorkTime',
label: 'Compte comme temps de travail',
hint: 'Entre dans le décompte horaire, donc dans les seuils de convention.',
},
{
name: 'affectsPaidLeaveAccrual',
label: 'Acquiert des congés payés',
hint: 'La période continue d’ouvrir des droits à congés.',
},
{
name: 'isSocialSecurity',
label: 'Absence Sécurité sociale',
hint: 'Maladie, maternité, accident du travail. Donnée de santé : le motif est masqué en vue manager.',
},
{
name: 'requiresJustification',
label: 'Justificatif exigé',
hint: 'La demande est refusée sans pièce jointe.',
},
] as const;
function Flags({ row }: { row?: AbsenceTypeRow }) {
return (
<fieldset className="flex flex-col gap-2">
<legend className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Effets
</legend>
{FLAGS.map((flag) => (
<label key={flag.name} className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name={flag.name}
defaultChecked={row ? Boolean(row[flag.name]) : flag.name === 'isPaid'}
className="mt-0.5"
/>
<span>
{flag.label}
<span className="block text-micro text-ink-3">{flag.hint}</span>
</span>
</label>
))}
</fieldset>
);
}
function SharedFields({ row }: { row?: AbsenceTypeRow }) {
return (
<div className="flex flex-wrap gap-3">
<Field
label="Libellé"
name="name"
required
maxLength={120}
defaultValue={row?.name}
placeholder="Congé payé"
/>
<Field
label="Clé de couleur"
name="colorKey"
required
maxLength={40}
defaultValue={row?.colorKey ?? 'neutral'}
/>
<Field
label="Délai de prévenance"
name="minNoticeDays"
type="number"
min={0}
max={365}
defaultValue={row?.minNoticeDays ?? ''}
hint="En jours. Vide : aucun délai."
/>
<Field
label="Code Silae"
name="silaeCode"
maxLength={20}
defaultValue={row?.silaeCode ?? ''}
hint="Partie « code » de AB-<code>. Sans lui, l’export de la période échoue."
/>
</div>
);
}
export function AddAbsenceTypeForm() {
const [state, action] = useActionState(createAbsenceTypeAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-4"
>
<div className="flex flex-wrap gap-3">
<Field
label="Code"
name="code"
required
maxLength={20}
placeholder="CP"
hint="Identifiant stable du type. Il ne se renomme pas."
/>
</div>
<SharedFields />
<Flags />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Ajouter le type</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Type enregistré.</span>
) : null}
</div>
</PersistentForm>
);
}
export function EditAbsenceTypeForm({ row }: { row: AbsenceTypeRow }) {
const [state, action] = useActionState(updateAbsenceTypeAction, empty);
return (
<PersistentForm action={action} className="flex flex-col gap-4">
<input type="hidden" name="id" value={row.id} />
<SharedFields row={row} />
<Flags row={row} />
<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,149 @@
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 {
AddAbsenceTypeForm,
EditAbsenceTypeForm,
} from '@/app/(app)/reglages/types-absence/AbsenceTypeForms';
import {
archiveAbsenceTypeAction,
listAbsenceTypes,
type AbsenceTypeRow,
} from '@/server/settings/absence-types';
export const metadata = { title: 'Types d’absence · PlanFlow' };
export const dynamic = 'force-dynamic';
function Summary({ row }: { row: AbsenceTypeRow }) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<Badge tone={row.isPaid ? 'ok' : 'neutral'}>
{row.isPaid ? 'Rémunérée' : 'Non rémunérée'}
</Badge>
{row.isSocialSecurity ? <Badge tone="info">Sécurité sociale</Badge> : null}
{row.countsAsWorkTime ? <Badge tone="neutral">Temps de travail</Badge> : null}
{row.affectsPaidLeaveAccrual ? (
<Badge tone="neutral">Acquisition CP</Badge>
) : null}
{row.requiresJustification ? (
<Badge tone="warn">Justificatif</Badge>
) : null}
{row.silaeCode ? (
<Badge tone="neutral">AB-{row.silaeCode}</Badge>
) : (
<Badge tone="danger">Code Silae manquant</Badge>
)}
</div>
);
}
export default async function TypesAbsencePage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const types = await listAbsenceTypes(showArchived);
const active = types.filter((type) => type.archivedAt === null);
const missingSilae = active.filter((type) => !type.silaeCode).length;
return (
<PageBody>
<PageHeader
title="Types d’absence"
subtitle={`${active.length} type${active.length > 1 ? 's' : ''} en vigueur`}
/>
{missingSilae > 0 ? (
<p
role="status"
className="rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs text-warn-soft-ink"
>
{missingSilae} type{missingSilae > 1 ? 's' : ''} sans code Silae.
L’export d’une période contenant ces absences échouera, avec la liste
des manques — aucun fichier partiel n’est produit.
</p>
) : null}
<Card>
<CardHeader
title="Référentiel"
action={
<a
href={
showArchived
? '/reglages/types-absence'
: '/reglages/types-absence?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>
}
/>
{types.length === 0 ? (
<EmptyState
title="Aucun type d’absence"
description="Sans type, aucune demande n’est saisissable. Commencez par le congé payé."
/>
) : (
<ul className="divide-y divide-line-1">
{types.map((type) => (
<li key={type.id} className="px-4 py-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<Badge tone="accent">{type.code}</Badge>
<span className="text-sm font-semibold">{type.name}</span>
<Badge tone="neutral">
{type.timeOffCount} absence
{type.timeOffCount > 1 ? 's' : ''}
</Badge>
{type.archivedAt ? <Badge tone="warn">Archivé</Badge> : null}
<span className="flex-1" />
<form action={archiveAbsenceTypeAction}>
<input type="hidden" name="id" value={type.id} />
{type.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{type.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</div>
<div className="mb-3">
<Summary row={type} />
</div>
<details className="text-sm">
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
Modifier
</summary>
<div className="pt-3">
<EditAbsenceTypeForm row={type} />
</div>
</details>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouveau type" />
<div className="p-4">
<AddAbsenceTypeForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Le code d’un type ne se renomme pas : il est référencé par les absences
déjà saisies et par la correspondance de paie. Un type devenu inutile
s’archive — les absences passées gardent alors un libellé lisible et les
écritures de compteur restent justifiables.
</p>
</PageBody>
);
}
+12 -2
View File
@@ -90,8 +90,18 @@ export const NAVIGATION: NavSection[] = [
label: 'Durées de conservation', label: 'Durées de conservation',
href: '/reglages/conservation', href: '/reglages/conservation',
}, },
{ id: 'convention', label: 'Convention collective' }, {
{ id: 'postes', label: 'Postes et étiquettes' }, id: 'convention',
label: 'Convention collective',
href: '/reglages/convention',
},
{ id: 'emplois', label: 'Emplois', href: '/reglages/emplois' },
{ id: 'etiquettes', label: 'Étiquettes', href: '/reglages/etiquettes' },
{
id: 'types-absence',
label: 'Types d’absence',
href: '/reglages/types-absence',
},
{ id: 'roles', label: 'Rôles et permissions', href: '/reglages/roles' }, { id: 'roles', label: 'Rôles et permissions', href: '/reglages/roles' },
], ],
}, },
+266
View File
@@ -0,0 +1,266 @@
'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';
/**
* Types d'absence — PLAN.md §9, réglage « /reglages/types-absence ».
*
* Ce référentiel n'est pas décoratif : chaque drapeau porte une conséquence de
* paie ou de droit.
*
* - `isSocialSecurity` isole maladie, maternité et accident du travail. Le
* journal des absences les filtre séparément, et ce sont des **données de
* santé** : la vue manager ne doit pas en montrer le motif (matrice n° 9).
* - `affectsPaidLeaveAccrual` décide si la période acquiert des congés payés.
* - `countsAsWorkTime` décide si la durée entre dans le décompte horaire, donc
* dans les seuils de convention.
* - `silaeCode` est la partie `<code>` de `AB-<code>` à l'export (§8.2). Sans
* lui, l'export de la période **échoue** plutôt que de produire un fichier
* partiel — c'est voulu.
*
* **Autorisation.** Les mutations passent par `settings.agreement.manage` et
* non par un code dédié : le catalogue de §5 n'en déclare pas, et ces drapeaux
* relèvent du paramétrage conventionnel et légal, exactement ce que cette
* capacité gouverne. Créer un code sans mandat aurait été inventer une règle.
*/
export interface AbsenceTypeRow {
id: string;
code: string;
name: string;
colorKey: string;
isPaid: boolean;
countsAsWorkTime: boolean;
affectsPaidLeaveAccrual: boolean;
isSocialSecurity: boolean;
requiresJustification: boolean;
minNoticeDays: number | null;
silaeCode: string | null;
archivedAt: Date | null;
/** Absences saisies avec ce type — un type porté ne se supprime pas. */
timeOffCount: number;
}
export async function listAbsenceTypes(
includeArchived = false,
): Promise<AbsenceTypeRow[]> {
return query('settings.access', async (db) => {
const types = await db.absenceType.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: { code: 'asc' },
});
const counts = await db.timeOff.groupBy({
by: ['absenceTypeId'],
_count: { _all: true },
});
const byId = new Map(
counts.map((row) => [row.absenceTypeId, row._count._all] as const),
);
return types.map((type) => ({
id: type.id,
code: type.code,
name: type.name,
colorKey: type.colorKey,
isPaid: type.isPaid,
countsAsWorkTime: type.countsAsWorkTime,
affectsPaidLeaveAccrual: type.affectsPaidLeaveAccrual,
isSocialSecurity: type.isSocialSecurity,
requiresJustification: type.requiresJustification,
minNoticeDays: type.minNoticeDays,
silaeCode: type.silaeCode,
archivedAt: type.archivedAt,
timeOffCount: byId.get(type.id) ?? 0,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
class DuplicateAbsenceType extends Error {}
const flags = {
isPaid: z.coerce.boolean(),
countsAsWorkTime: z.coerce.boolean(),
affectsPaidLeaveAccrual: z.coerce.boolean(),
isSocialSecurity: z.coerce.boolean(),
requiresJustification: z.coerce.boolean(),
minNoticeDays: z
.union([z.literal(''), z.coerce.number().int().min(0).max(365)])
.transform((value) => (value === '' ? null : value)),
silaeCode: z
.string()
.trim()
.max(20)
.transform((value) => (value === '' ? null : value)),
};
const createInput = z.object({
code: z
.string()
.trim()
.min(1, 'Code requis')
.max(20)
.regex(/^[A-Za-z0-9_-]+$/, 'Lettres, chiffres, tirets et soulignés'),
name: z.string().trim().min(1, 'Nom requis').max(120),
colorKey: z.string().trim().min(1).max(40),
...flags,
});
function readForm(formData: FormData) {
return {
code: formData.get('code'),
name: formData.get('name'),
colorKey: formData.get('colorKey') || 'neutral',
isPaid: formData.get('isPaid') === 'on',
countsAsWorkTime: formData.get('countsAsWorkTime') === 'on',
affectsPaidLeaveAccrual: formData.get('affectsPaidLeaveAccrual') === 'on',
isSocialSecurity: formData.get('isSocialSecurity') === 'on',
requiresJustification: formData.get('requiresJustification') === 'on',
minNoticeDays: (formData.get('minNoticeDays') ?? '') as string,
silaeCode: (formData.get('silaeCode') ?? '') as string,
};
}
export async function createAbsenceTypeAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = createInput.safeParse(readForm(formData));
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const code = parsed.data.code.toUpperCase();
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const existing = await db.absenceType.findFirst({ where: { code } });
if (existing) throw new DuplicateAbsenceType();
const { code: _ignored, ...rest } = parsed.data;
const created = await db.absenceType.create({
data: { ...rest, code } as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'absence_type.create',
entityType: 'AbsenceType',
entityId: created.id,
after: { code: created.code, name: created.name },
});
});
} catch (error) {
if (error instanceof DuplicateAbsenceType) {
return { error: 'Ce code de type d’absence est déjà pris.' };
}
if (error instanceof AuthorizationError) {
return {
error: "Vous n'avez pas le droit de gérer les types d'absence.",
};
}
throw error;
}
revalidatePath('/reglages/types-absence');
return { ok: true };
}
const updateInput = z.object({
id: z.string().min(1),
name: z.string().trim().min(1, 'Nom requis').max(120),
colorKey: z.string().trim().min(1).max(40),
...flags,
});
export async function updateAbsenceTypeAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = updateInput.safeParse({
...readForm(formData),
id: formData.get('id'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const { id, ...data } = parsed.data;
const before = await db.absenceType.findUnique({ where: { id } });
if (!before) throw new AuthorizationError('settings.agreement.manage');
await db.absenceType.update({ where: { id }, data });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'absence_type.update',
entityType: 'AbsenceType',
entityId: id,
before: {
name: before.name,
isPaid: before.isPaid,
countsAsWorkTime: before.countsAsWorkTime,
affectsPaidLeaveAccrual: before.affectsPaidLeaveAccrual,
isSocialSecurity: before.isSocialSecurity,
requiresJustification: before.requiresJustification,
minNoticeDays: before.minNoticeDays,
silaeCode: before.silaeCode,
},
after: data,
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return {
error: "Vous n'avez pas le droit de gérer les types d'absence.",
};
}
throw error;
}
revalidatePath('/reglages/types-absence');
revalidatePath('/conges');
return { ok: true };
}
export async function archiveAbsenceTypeAction(
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.absenceType.findUnique({ where: { id } });
if (!before) return;
// Archiver, jamais supprimer : les absences passées gardent leur type, et
// les compteurs de congés s'appuient dessus pour justifier une écriture.
const archivedAt = restore ? null : new Date();
await db.absenceType.update({ where: { id }, data: { archivedAt } });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: restore ? 'absence_type.restore' : 'absence_type.archive',
entityType: 'AbsenceType',
entityId: id,
before: { archivedAt: before.archivedAt },
after: { archivedAt: archivedAt?.toISOString() ?? null },
});
});
revalidatePath('/reglages/types-absence');
revalidatePath('/conges');
}
+306
View File
@@ -0,0 +1,306 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import {
agreementParametersSchema,
parseAgreementParameters,
type AgreementParameters,
} from '@/domain/compliance/parameters';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Convention collective — PLAN.md §9, réglage « /reglages/convention ».
*
* **Une convention ne se modifie pas : elle se réédite.** Chaque enregistrement
* crée une version datée, laissant intacte la précédente. C'est l'exigence
* effectif-datée de §12.2 : une paie de mars doit rester reproductible après un
* changement de règle en juin, ce qu'une mise à jour en place rendrait
* impossible — le moteur relirait les nouveaux seuils et produirait un autre
* résultat sur les mêmes données.
*
* L'origine de chaque valeur est portée par le registre de paramétrage
* (`LegalConfigEntry`, §12.7) : ordre public, disposition conventionnelle ou
* accord d'entreprise ne se corrigent pas de la même main.
*/
export interface AgreementVersion {
id: string;
idcc: string;
name: string;
version: number;
effectiveFrom: Date;
source: string | null;
approvedBy: string | null;
approvedAt: Date | null;
createdAt: Date;
parameters: AgreementParameters;
/** Vraie pour la version applicable aujourd'hui. */
isCurrent: boolean;
}
export async function listAgreementVersions(): Promise<AgreementVersion[]> {
return query('settings.access', async (db) => {
const rows = await db.collectiveAgreement.findMany({
orderBy: [{ effectiveFrom: 'desc' }, { version: 'desc' }],
});
const today = new Date();
// La version courante est la plus récente **déjà en vigueur** : une version
// datée du mois prochain existe en base sans gouverner le calcul du jour.
const currentId = rows.find((row) => row.effectiveFrom <= today)?.id ?? null;
return rows.map((row) => ({
id: row.id,
idcc: row.idcc,
name: row.name,
version: row.version,
effectiveFrom: row.effectiveFrom,
source: row.source,
approvedBy: row.approvedBy,
approvedAt: row.approvedAt,
createdAt: row.createdAt,
parameters: parseAgreementParameters(row.parameters),
isCurrent: row.id === currentId,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
/**
* Tranches d'heures supplémentaires, saisies une par ligne : `début;fin;taux`.
*
* Un tableau de longueur variable ne se saisit pas en champs fixes, et un JSON
* brut exposerait la forme interne. Le format compact reste vérifiable à l'œil,
* ce qui compte pour une valeur que le gestionnaire de paie doit relire.
* `fin` vide signifie « jusqu'à l'infini ».
*/
function parseTiers(raw: string) {
const lines = raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return lines.map((line, index) => {
const parts = line.split(';').map((part) => part.trim());
if (parts.length !== 3) {
throw new ParameterFormatError(
`Tranche ${index + 1} : trois valeurs attendues « début;fin;taux ».`,
);
}
const [from, to, rate] = parts as [string, string, string];
const fromMinutes = Number(from);
const toMinutes = to === '' ? null : Number(to);
const ratePercent = Number(rate);
if (
!Number.isFinite(fromMinutes) ||
(toMinutes !== null && !Number.isFinite(toMinutes)) ||
!Number.isFinite(ratePercent)
) {
throw new ParameterFormatError(
`Tranche ${index + 1} : valeurs numériques attendues.`,
);
}
return { fromMinutes, toMinutes, ratePercent };
});
}
/** Dérogations de temps partiel : `code;libellé;minutes` par ligne. */
function parseDerogations(raw: string) {
return raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const parts = line.split(';').map((part) => part.trim());
if (parts.length !== 3) {
throw new ParameterFormatError(
`Dérogation ${index + 1} : « code;libellé;minutes » attendu.`,
);
}
const [code, label, min] = parts as [string, string, string];
const minWeeklyMinutes = Number(min);
if (!code || !label || !Number.isFinite(minWeeklyMinutes)) {
throw new ParameterFormatError(
`Dérogation ${index + 1} : code, libellé et minutes requis.`,
);
}
return { code, label, minWeeklyMinutes };
});
}
class ParameterFormatError extends Error {}
const metaInput = z.object({
idcc: z.string().trim().min(1, 'IDCC requis').max(10),
name: z.string().trim().min(1, 'Intitulé requis').max(160),
effectiveFrom: z.coerce.date(),
source: z.string().trim().max(400),
approvedBy: z.string().trim().max(160),
});
function num(formData: FormData, key: string): number {
return Number(formData.get(key) ?? Number.NaN);
}
/**
* Reconstruit le jeu complet depuis le formulaire, puis le valide.
*
* La validation passe par le **même** schéma que le chargement du moteur : un
* jeu accepté ici est donc, par construction, un jeu que le moteur saura lire.
*/
function readParameters(formData: FormData): AgreementParameters {
const candidate = {
weeklyReferenceMinutes: num(formData, 'weeklyReferenceMinutes'),
maxDailyWorkMinutes: num(formData, 'maxDailyWorkMinutes'),
maxDailyAmplitudeMinutes:
String(formData.get('maxDailyAmplitudeMinutes') ?? '') === ''
? null
: num(formData, 'maxDailyAmplitudeMinutes'),
minDailyRestMinutes: num(formData, 'minDailyRestMinutes'),
minWeeklyRestMinutes: num(formData, 'minWeeklyRestMinutes'),
maxWeeklyWorkAbsoluteMinutes: num(formData, 'maxWeeklyWorkAbsoluteMinutes'),
averagedWeeklyWork: {
windowWeeks: num(formData, 'averagedWindowWeeks'),
maxAverageMinutes: num(formData, 'averagedMaxMinutes'),
},
maxConsecutiveWorkDays: num(formData, 'maxConsecutiveWorkDays'),
breakAfterThreshold: {
thresholdMinutes: num(formData, 'breakThresholdMinutes'),
minBreakMinutes: num(formData, 'breakMinMinutes'),
},
partTime: {
minWeeklyMinutes: num(formData, 'partTimeMinWeeklyMinutes'),
derogations: parseDerogations(String(formData.get('derogations') ?? '')),
},
contractDeviationToleranceMinutes: num(formData, 'deviationToleranceMinutes'),
overtime: {
tiers: parseTiers(String(formData.get('overtimeTiers') ?? '')),
annualQuotaMinutes: num(formData, 'overtimeQuotaMinutes'),
beyondQuotaRestPercent: num(formData, 'overtimeBeyondRestPercent'),
},
complementaryHours: {
firstTierRatePercent: num(formData, 'complementaryFirstRate'),
beyondRatePercent: num(formData, 'complementaryBeyondRate'),
firstTierFraction: num(formData, 'complementaryFirstFraction'),
capFraction: num(formData, 'complementaryCapFraction'),
},
sunday: {
premiumPercent: num(formData, 'sundayPremiumPercent'),
compensatoryRestPercent: num(formData, 'sundayRestPercent'),
mayorQuotaPerYear: num(formData, 'sundayMayorQuota'),
},
holiday: {
workedPremiumPercent: num(formData, 'holidayWorkedPremium'),
labourDayPremiumPercent: num(formData, 'holidayLabourDayPremium'),
paidOffDaysPerYear: num(formData, 'holidayPaidOffDays'),
substitutionRestPercent: num(formData, 'holidaySubstitutionRest'),
},
night: {
startMinutes: num(formData, 'nightStartMinutes'),
endMinutes: num(formData, 'nightEndMinutes'),
notEnforceableFromAge: num(formData, 'nightNotEnforceableFromAge'),
},
forfaitJours: {
maxDaysPerYear: num(formData, 'forfaitMaxDaysPerYear'),
workloadReviewIntervalMonths: num(formData, 'forfaitReviewMonths'),
},
};
const parsed = agreementParametersSchema.safeParse(candidate);
if (!parsed.success) {
const issue = parsed.error.issues[0];
throw new ParameterFormatError(
`${issue?.path.join('.') ?? 'paramètre'} : ${issue?.message ?? 'valeur refusée'}`,
);
}
return parsed.data;
}
export async function publishAgreementVersionAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const meta = metaInput.safeParse({
idcc: formData.get('idcc'),
name: formData.get('name'),
effectiveFrom: formData.get('effectiveFrom'),
source: formData.get('source') ?? '',
approvedBy: formData.get('approvedBy') ?? '',
});
if (!meta.success) {
return { error: meta.error.issues[0]?.message ?? 'Formulaire invalide' };
}
let parameters: AgreementParameters;
try {
parameters = readParameters(formData);
} catch (error) {
if (error instanceof ParameterFormatError) return { error: error.message };
throw error;
}
if (!meta.data.source) {
// Une valeur sans source n'est pas opposable : le registre de paramétrage
// exige de dire d'où elle vient avant de l'appliquer (§12.7).
return {
error:
'Indiquez la source du texte : sans elle, le paramètre n’est pas opposable.',
};
}
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const last = await db.collectiveAgreement.findFirst({
where: { idcc: meta.data.idcc },
orderBy: { version: 'desc' },
});
const created = await db.collectiveAgreement.create({
data: {
idcc: meta.data.idcc,
name: meta.data.name,
parameters: parameters as never,
version: (last?.version ?? 0) + 1,
effectiveFrom: meta.data.effectiveFrom,
source: meta.data.source,
approvedBy: meta.data.approvedBy || null,
approvedAt: meta.data.approvedBy ? new Date() : null,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'agreement.publish_version',
entityType: 'CollectiveAgreement',
entityId: created.id,
before: last
? { version: last.version, effectiveFrom: last.effectiveFrom }
: null,
after: {
version: created.version,
effectiveFrom: created.effectiveFrom,
source: created.source,
},
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer la convention." };
}
throw error;
}
revalidatePath('/reglages/convention');
return { ok: true };
}
+208
View File
@@ -0,0 +1,208 @@
'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';
/**
* Emplois — PLAN.md §9, réglage « /reglages/emplois ».
*
* L'emploi est la qualification portée par le contrat, et il figure au registre
* unique du personnel. Il est donc **archivé, jamais supprimé** : un emploi
* effacé rendrait illisible un contrat clos dont la conservation court encore.
*/
export interface JobTitleRow {
id: string;
name: string;
archivedAt: Date | null;
/** Contrats qui s'y rattachent — un emploi porté n'est pas un doublon à purger. */
contractCount: number;
}
export async function listJobTitles(
includeArchived = false,
): Promise<JobTitleRow[]> {
return query('settings.access', async (db) => {
const titles = await db.jobTitle.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: { name: 'asc' },
});
const counts = await db.userContract.groupBy({
by: ['jobTitleId'],
_count: { _all: true },
});
const byId = new Map(
counts.map((row) => [row.jobTitleId, row._count._all] as const),
);
return titles.map((title) => ({
id: title.id,
name: title.name,
archivedAt: title.archivedAt,
contractCount: byId.get(title.id) ?? 0,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
const nameInput = z.object({
name: z.string().trim().min(1, 'Nom requis').max(120),
});
export async function createJobTitleAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = nameInput.safeParse({ name: formData.get('name') });
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.jobtitles.manage', async (db, actor) => {
// Un emploi archivé porte le même nom qu'un emploi à recréer : on le
// réveille plutôt que d'échouer sur la contrainte d'unicité, sans quoi le
// nom deviendrait définitivement inutilisable.
const existing = await db.jobTitle.findFirst({
where: { name: parsed.data.name },
});
if (existing && existing.archivedAt === null) {
throw new DuplicateJobTitle();
}
if (existing) {
await db.jobTitle.update({
where: { id: existing.id },
data: { archivedAt: null },
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'job_title.restore',
entityType: 'JobTitle',
entityId: existing.id,
before: { archivedAt: existing.archivedAt },
after: { archivedAt: null },
});
return;
}
const created = await db.jobTitle.create({
data: { name: parsed.data.name } as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'job_title.create',
entityType: 'JobTitle',
entityId: created.id,
after: { name: created.name },
});
});
} catch (error) {
if (error instanceof DuplicateJobTitle) {
return { error: 'Cet emploi existe déjà.' };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les emplois." };
}
throw error;
}
revalidatePath('/reglages/emplois');
return { ok: true };
}
class DuplicateJobTitle extends Error {}
const renameInput = z.object({
id: z.string().min(1),
name: z.string().trim().min(1, 'Nom requis').max(120),
});
export async function renameJobTitleAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = renameInput.safeParse({
id: formData.get('id'),
name: formData.get('name'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.jobtitles.manage', async (db, actor) => {
const before = await db.jobTitle.findUnique({
where: { id: parsed.data.id },
});
if (!before) throw new AuthorizationError('settings.jobtitles.manage');
const clash = await db.jobTitle.findFirst({
where: { name: parsed.data.name, id: { not: before.id } },
});
if (clash) throw new DuplicateJobTitle();
await db.jobTitle.update({
where: { id: before.id },
data: { name: parsed.data.name },
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'job_title.rename',
entityType: 'JobTitle',
entityId: before.id,
before: { name: before.name },
after: { name: parsed.data.name },
});
});
} catch (error) {
if (error instanceof DuplicateJobTitle) {
return { error: 'Un autre emploi porte déjà ce nom.' };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les emplois." };
}
throw error;
}
revalidatePath('/reglages/emplois');
return { ok: true };
}
export async function archiveJobTitleAction(formData: FormData): Promise<void> {
const id = String(formData.get('id') ?? '');
const restore = formData.get('restore') === '1';
if (!id) return;
await mutate('settings.jobtitles.manage', async (db, actor) => {
const before = await db.jobTitle.findUnique({ where: { id } });
if (!before) return;
const archivedAt = restore ? null : new Date();
await db.jobTitle.update({ where: { id }, data: { archivedAt } });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: restore ? 'job_title.restore' : 'job_title.archive',
entityType: 'JobTitle',
entityId: id,
before: { archivedAt: before.archivedAt },
after: { archivedAt: archivedAt?.toISOString() ?? null },
});
});
revalidatePath('/reglages/emplois');
}
+230
View File
@@ -0,0 +1,230 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { isPosteCode } from '@/lib/design/postes';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Étiquettes de planning — PLAN.md §9, réglage « /reglages/etiquettes ».
*
* L'étiquette est le poste coloré de la grille. Sa teinte vient de la palette
* catégorielle, jamais d'une couleur libre : douze teintes calculées pour
* rester distinguables en vision daltonienne, et un code affiché avec le bloc
* pour que la couleur ne porte jamais l'information seule.
*/
export interface LabelRow {
id: string;
code: string;
name: string;
paletteKey: string;
position: number;
archivedAt: Date | null;
/** Créneaux qui la portent — une étiquette utilisée ne se supprime pas. */
shiftCount: number;
}
export async function listLabels(includeArchived = false): Promise<LabelRow[]> {
return query('settings.access', async (db) => {
const labels = await db.label.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: [{ position: 'asc' }, { code: 'asc' }],
});
const counts = await db.shift.groupBy({
by: ['labelId'],
_count: { _all: true },
});
const byId = new Map(
counts.map((row) => [row.labelId, row._count._all] as const),
);
return labels.map((label) => ({
id: label.id,
code: label.code,
name: label.name,
paletteKey: label.paletteKey,
position: label.position,
archivedAt: label.archivedAt,
shiftCount: byId.get(label.id) ?? 0,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
class DuplicateLabel extends Error {}
const labelInput = z.object({
code: z
.string()
.trim()
.min(1, 'Code requis')
.max(12)
.regex(/^[A-Za-z0-9-]+$/, 'Lettres, chiffres et tirets seulement'),
name: z.string().trim().min(1, 'Nom requis').max(120),
paletteKey: z.string().refine(isPosteCode, 'Teinte inconnue'),
});
export async function createLabelAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = labelInput.safeParse({
code: formData.get('code'),
name: formData.get('name'),
paletteKey: formData.get('paletteKey'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const code = parsed.data.code.toLowerCase();
try {
await mutate('planning.labels.manage', async (db, actor) => {
const existing = await db.label.findFirst({ where: { code } });
if (existing && existing.archivedAt === null) throw new DuplicateLabel();
if (existing) {
await db.label.update({
where: { id: existing.id },
data: {
archivedAt: null,
name: parsed.data.name,
paletteKey: parsed.data.paletteKey,
},
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'label.restore',
entityType: 'Label',
entityId: existing.id,
before: { archivedAt: existing.archivedAt, name: existing.name },
after: { archivedAt: null, name: parsed.data.name },
});
return;
}
const position = await db.label.count();
const created = await db.label.create({
data: {
code,
name: parsed.data.name,
paletteKey: parsed.data.paletteKey,
position,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'label.create',
entityType: 'Label',
entityId: created.id,
after: {
code: created.code,
name: created.name,
paletteKey: created.paletteKey,
},
});
});
} catch (error) {
if (error instanceof DuplicateLabel) {
return { error: 'Ce code d’étiquette est déjà pris.' };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les étiquettes." };
}
throw error;
}
revalidatePath('/reglages/etiquettes');
revalidatePath('/planning/etiquettes');
return { ok: true };
}
const updateInput = z.object({
id: z.string().min(1),
name: z.string().trim().min(1, 'Nom requis').max(120),
paletteKey: z.string().refine(isPosteCode, 'Teinte inconnue'),
});
export async function updateLabelAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = updateInput.safeParse({
id: formData.get('id'),
name: formData.get('name'),
paletteKey: formData.get('paletteKey'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('planning.labels.manage', async (db, actor) => {
const before = await db.label.findUnique({ where: { id: parsed.data.id } });
if (!before) throw new AuthorizationError('planning.labels.manage');
await db.label.update({
where: { id: before.id },
data: { name: parsed.data.name, paletteKey: parsed.data.paletteKey },
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'label.update',
entityType: 'Label',
entityId: before.id,
before: { name: before.name, paletteKey: before.paletteKey },
after: { name: parsed.data.name, paletteKey: parsed.data.paletteKey },
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les étiquettes." };
}
throw error;
}
revalidatePath('/reglages/etiquettes');
revalidatePath('/planning/etiquettes');
return { ok: true };
}
export async function archiveLabelAction(formData: FormData): Promise<void> {
const id = String(formData.get('id') ?? '');
const restore = formData.get('restore') === '1';
if (!id) return;
await mutate('planning.labels.manage', async (db, actor) => {
const before = await db.label.findUnique({ where: { id } });
if (!before) return;
// Archiver, pas supprimer : les créneaux passés portent cette étiquette et
// les rapports d'heures s'y adossent. Une suppression laisserait des
// semaines closes sans poste lisible.
const archivedAt = restore ? null : new Date();
await db.label.update({ where: { id }, data: { archivedAt } });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: restore ? 'label.restore' : 'label.archive',
entityType: 'Label',
entityId: id,
before: { archivedAt: before.archivedAt },
after: { archivedAt: archivedAt?.toISOString() ?? null },
});
});
revalidatePath('/reglages/etiquettes');
revalidatePath('/planning/etiquettes');
}