diff --git a/src/app/(app)/reglages/convention/AgreementForm.tsx b/src/app/(app)/reglages/convention/AgreementForm.tsx new file mode 100644 index 0000000..5bbc28a --- /dev/null +++ b/src/app/(app)/reglages/convention/AgreementForm.tsx @@ -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 ( +
+ ); +} + +function Area({ + label, + name, + defaultValue, + hint, +}: { + label: string; + name: string; + defaultValue: string; + hint: string; +}) { + return ( + + ); +} + +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 ( +| + Paramètre + | ++ Valeur + | ++ Origine + | ++ Source + | +
|---|---|---|---|
| {entry.label} | +{entry.value} | +
+ |
+ {entry.source} | +
+ 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. +
+Emploi enregistré.
+ ) : null} ++ 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. +
+Étiquette enregistrée.
+ ) : null} ++ 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. +
++ {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. +
+ ) : null} + ++ 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. +
+` de `AB-` à 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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');
+}
diff --git a/src/server/settings/agreement.ts b/src/server/settings/agreement.ts
new file mode 100644
index 0000000..1e52558
--- /dev/null
+++ b/src/server/settings/agreement.ts
@@ -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 {
+ 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 {
+ 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 };
+}
diff --git a/src/server/settings/job-titles.ts b/src/server/settings/job-titles.ts
new file mode 100644
index 0000000..b3e74f8
--- /dev/null
+++ b/src/server/settings/job-titles.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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');
+}
diff --git a/src/server/settings/labels.ts b/src/server/settings/labels.ts
new file mode 100644
index 0000000..52147ca
--- /dev/null
+++ b/src/server/settings/labels.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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');
+}