diff --git a/src/app/(app)/reglages/types-absence/AbsenceTypeForms.tsx b/src/app/(app)/reglages/types-absence/AbsenceTypeForms.tsx
index b4770ac..987b059 100644
--- a/src/app/(app)/reglages/types-absence/AbsenceTypeForms.tsx
+++ b/src/app/(app)/reglages/types-absence/AbsenceTypeForms.tsx
@@ -3,6 +3,7 @@
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
+import { ABSENCE_COLORS } from '@/domain/absences/colors';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createAbsenceTypeAction,
@@ -77,13 +78,24 @@ function SharedFields({ row }: { row?: AbsenceTypeRow }) {
defaultValue={row?.name}
placeholder="Congé payé"
/>
-
+
= {
cp: {
@@ -18,6 +27,28 @@ const KINDS: Record = {
ink: 'var(--color-ink-2)',
edge: 'var(--color-line-3)',
},
+ famille: {
+ bg: 'var(--post-rss-bg)',
+ ink: 'var(--post-rss-fg)',
+ edge: 'var(--post-rss-edge)',
+ },
+ formation: {
+ bg: 'var(--post-for-bg)',
+ ink: 'var(--post-for-fg)',
+ edge: 'var(--post-for-edge)',
+ },
+ ferie: {
+ bg: 'var(--post-enc-bg)',
+ ink: 'var(--post-enc-fg)',
+ edge: 'var(--post-enc-edge)',
+ },
+ // Une sanction se distingue d'une absence ordinaire : elle est subie, elle se
+ // conteste, et elle ne se lit pas comme un congé.
+ sanction: {
+ bg: 'var(--color-danger-soft)',
+ ink: 'var(--color-danger-soft-ink)',
+ edge: 'var(--color-danger)',
+ },
'sans-solde': {
bg: 'var(--color-surface-2)',
ink: 'var(--color-ink-2)',
diff --git a/src/components/planning/WeekGrid.tsx b/src/components/planning/WeekGrid.tsx
index 9646fb6..7e6d6c6 100644
--- a/src/components/planning/WeekGrid.tsx
+++ b/src/components/planning/WeekGrid.tsx
@@ -1,6 +1,7 @@
import { AbsenceBar, type AbsenceKind } from '@/components/planning/AbsenceBar';
import { CounterStrip } from '@/components/planning/CounterStrip';
import { ShiftChip } from '@/components/planning/ShiftChip';
+import { isAbsenceColorKey } from '@/domain/absences/colors';
import { computeWeekCounters } from '@/domain/counters/week';
import {
displayName,
@@ -246,8 +247,5 @@ function Row({
* repli neutre : une teinte inconnue vaut mieux affichée en gris qu'absente.
*/
function absenceKind(colorKey: string): AbsenceKind {
- const known: AbsenceKind[] = ['cp', 'rtt', 'maladie', 'sans-solde', 'attente'];
- return known.includes(colorKey as AbsenceKind)
- ? (colorKey as AbsenceKind)
- : 'sans-solde';
+ return isAbsenceColorKey(colorKey) ? colorKey : 'sans-solde';
}
diff --git a/src/domain/absences/colors.ts b/src/domain/absences/colors.ts
new file mode 100644
index 0000000..1472f6d
--- /dev/null
+++ b/src/domain/absences/colors.ts
@@ -0,0 +1,33 @@
+/**
+ * Familles de couleur des types d'absence.
+ *
+ * Une famille, pas un type. Le client crée autant de types qu'il lui en faut —
+ * une vingtaine dans un commerce de détail — et leur donner à chacun sa teinte
+ * rendrait la grille illisible bien avant la fin de la liste. Le libellé porte
+ * l'information exacte, la couleur ne fait que grouper.
+ *
+ * Liste fermée pour cette raison : ouvrir le champ à une couleur libre
+ * reviendrait à laisser un utilisateur casser la lisibilité de la grille depuis
+ * un écran de réglage.
+ */
+export const ABSENCE_COLORS = [
+ { key: 'cp', label: 'Congé payé' },
+ { key: 'rtt', label: 'RTT et repos' },
+ { key: 'maladie', label: 'Santé et accident' },
+ { key: 'famille', label: 'Famille' },
+ { key: 'formation', label: 'Formation' },
+ { key: 'ferie', label: 'Jour férié' },
+ { key: 'sanction', label: 'Sanction' },
+ { key: 'sans-solde', label: 'Sans solde et non justifiée' },
+ { key: 'attente', label: 'En attente de décision' },
+] as const;
+
+export type AbsenceColorKey = (typeof ABSENCE_COLORS)[number]['key'];
+
+export const ABSENCE_COLOR_KEYS: readonly string[] = ABSENCE_COLORS.map(
+ (color) => color.key,
+);
+
+export function isAbsenceColorKey(value: string): value is AbsenceColorKey {
+ return ABSENCE_COLOR_KEYS.includes(value);
+}
diff --git a/src/server/install/referentials.ts b/src/server/install/referentials.ts
index e760176..a8e9a7f 100644
--- a/src/server/install/referentials.ts
+++ b/src/server/install/referentials.ts
@@ -45,16 +45,64 @@ const RETENTION: ReadonlyArray<
/**
* Types d'absence d'amorce.
*
+ * Liste relevée sur le sélecteur du produit audité, recoupée avec les rubriques
+ * du dossier de paie. Elle couvre ce qu'un commerce de détail rencontre
+ * réellement — un « congé sans solde » et un « congé payé » ne suffisent pas à
+ * tenir un registre.
+ *
+ * **Les codes sont internes et stables.** Ils ne sont montrés nulle part et ne
+ * correspondent à aucune nomenclature externe : ils servent à référencer un
+ * type sans dépendre de son libellé, qui se renomme.
+ *
* `silaeCode` reste **nul** : la correspondance entre un type et une rubrique
* de paie appartient au dossier du client. La renseigner ici imputerait des
* congés à la rubrique d'un autre cabinet.
+ *
+ * `À VALIDER` — les drapeaux sont des valeurs d'amorce. `isSocialSecurity` suit
+ * la nature de l'arrêt et ne se discute pas ; l'acquisition de congés pendant
+ * une absence, en revanche, dépend de la convention et se règle depuis
+ * `/reglages/types-absence`.
+ *
+ * « Repos hebdomadaire » figure au sélecteur du produit audité et **pas ici** :
+ * c'est un repos légal, porté par le modèle `Rest`, pas une absence qui se
+ * demande. En faire un type d'absence laisserait un manager refuser une
+ * obligation.
*/
const ABSENCE_TYPES = [
- { code: 'CP', name: 'Congés payés', colorKey: 'cp', isPaid: true, social: false, notice: 30 },
- { code: 'RTT', name: 'RTT', colorKey: 'rtt', isPaid: true, social: false, notice: 7 },
- { code: 'MAL', name: 'Arrêt maladie', colorKey: 'maladie', isPaid: false, social: true, notice: null },
- { code: 'SS', name: 'Congé sans solde', colorKey: 'sans-solde', isPaid: false, social: false, notice: 15 },
- { code: 'RC', name: 'Repos compensateur', colorKey: 'rtt', isPaid: true, social: false, notice: 7 },
+ // --- Congés et repos ----------------------------------------------------
+ { code: 'CP', name: 'Congé payé', colorKey: 'cp', isPaid: true, social: false, notice: 30, accrual: true, justify: false },
+ { code: 'RTT', name: 'RTT', colorKey: 'rtt', isPaid: true, social: false, notice: 7, accrual: true, justify: false },
+ { code: 'RC', name: 'Repos compensateur', colorKey: 'rtt', isPaid: true, social: false, notice: 7, accrual: true, justify: false },
+ { code: 'RC_NUIT', name: 'Repos compensateur de nuit', colorKey: 'rtt', isPaid: true, social: false, notice: 7, accrual: true, justify: false },
+ { code: 'RC_HAB', name: "Repos compensateur d'habillement", colorKey: 'rtt', isPaid: true, social: false, notice: 7, accrual: true, justify: false },
+ { code: 'RECUP_JF', name: 'Récupération jour férié', colorKey: 'rtt', isPaid: true, social: false, notice: 7, accrual: true, justify: false },
+ { code: 'JF', name: 'Jour férié', colorKey: 'ferie', isPaid: true, social: false, notice: null, accrual: true, justify: false },
+ { code: 'CSS', name: 'Congé sans solde', colorKey: 'sans-solde', isPaid: false, social: false, notice: 15, accrual: false, justify: false },
+
+ // --- Santé et accidents — catégories particulières -----------------------
+ { code: 'MAL', name: 'Arrêt maladie', colorKey: 'maladie', isPaid: false, social: true, notice: null, accrual: false, justify: true },
+ { code: 'MP', name: 'Maladie professionnelle', colorKey: 'maladie', isPaid: false, social: true, notice: null, accrual: true, justify: true },
+ { code: 'AT', name: 'Accident du travail', colorKey: 'maladie', isPaid: false, social: true, notice: null, accrual: true, justify: true },
+ { code: 'AT_TRAJET', name: 'Accident de trajet', colorKey: 'maladie', isPaid: false, social: true, notice: null, accrual: true, justify: true },
+ { code: 'VM', name: 'Visite médicale', colorKey: 'maladie', isPaid: true, social: false, notice: null, accrual: true, justify: true },
+
+ // --- Famille -------------------------------------------------------------
+ { code: 'MAT', name: 'Congé maternité', colorKey: 'famille', isPaid: false, social: true, notice: null, accrual: true, justify: true },
+ { code: 'PAT', name: 'Congé paternité', colorKey: 'famille', isPaid: false, social: true, notice: null, accrual: true, justify: true },
+ { code: 'PAR', name: 'Congé parental', colorKey: 'famille', isPaid: false, social: false, notice: 30, accrual: false, justify: true },
+ { code: 'NAISS', name: 'Congé supplémentaire de naissance', colorKey: 'famille', isPaid: true, social: false, notice: null, accrual: true, justify: true },
+ { code: 'EVT_FAM', name: 'Évènement familial', colorKey: 'famille', isPaid: true, social: false, notice: null, accrual: true, justify: true },
+
+ // --- Formation et obligations -------------------------------------------
+ { code: 'FORM', name: 'Formation', colorKey: 'formation', isPaid: true, social: false, notice: 15, accrual: true, justify: false },
+ { code: 'EXAM', name: 'Congé pour examen', colorKey: 'formation', isPaid: true, social: false, notice: 15, accrual: true, justify: true },
+
+ // --- Absences non planifiées et sanctions -------------------------------
+ { code: 'ABS_JUS', name: 'Absence justifiée', colorKey: 'sans-solde', isPaid: false, social: false, notice: null, accrual: false, justify: true },
+ { code: 'ABS_INJ', name: 'Absence injustifiée', colorKey: 'sans-solde', isPaid: false, social: false, notice: null, accrual: false, justify: false },
+ { code: 'INDISPO', name: 'Indisponibilité ponctuelle', colorKey: 'sans-solde', isPaid: false, social: false, notice: null, accrual: false, justify: false },
+ { code: 'MAP_D', name: 'Mise à pied disciplinaire', colorKey: 'sanction', isPaid: false, social: false, notice: null, accrual: false, justify: true },
+ { code: 'MAP_C', name: 'Mise à pied conservatoire', colorKey: 'sanction', isPaid: false, social: false, notice: null, accrual: false, justify: true },
] as const;
export async function installReferentials(
@@ -87,9 +135,9 @@ export async function installReferentials(
colorKey: type.colorKey,
isPaid: type.isPaid,
countsAsWorkTime: false,
- affectsPaidLeaveAccrual: !type.social,
+ affectsPaidLeaveAccrual: type.accrual,
isSocialSecurity: type.social,
- requiresJustification: type.social,
+ requiresJustification: type.justify,
minNoticeDays: type.notice,
},
});
diff --git a/src/server/settings/absence-types.ts b/src/server/settings/absence-types.ts
index 9ff2397..9c9ac2f 100644
--- a/src/server/settings/absence-types.ts
+++ b/src/server/settings/absence-types.ts
@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
+import { isAbsenceColorKey } from '@/domain/absences/colors';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
@@ -112,7 +113,7 @@ const createInput = z.object({
.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),
+ colorKey: z.string().refine(isAbsenceColorKey, 'Famille de couleur inconnue'),
...flags,
});
@@ -120,7 +121,7 @@ function readForm(formData: FormData) {
return {
code: formData.get('code'),
name: formData.get('name'),
- colorKey: formData.get('colorKey') || 'neutral',
+ colorKey: formData.get('colorKey') || 'sans-solde',
isPaid: formData.get('isPaid') === 'on',
countsAsWorkTime: formData.get('countsAsWorkTime') === 'on',
affectsPaidLeaveAccrual: formData.get('affectsPaidLeaveAccrual') === 'on',
@@ -179,7 +180,7 @@ export async function createAbsenceTypeAction(
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),
+ colorKey: z.string().refine(isAbsenceColorKey, 'Famille de couleur inconnue'),
...flags,
});
diff --git a/tests/unit/absence-colors.test.ts b/tests/unit/absence-colors.test.ts
new file mode 100644
index 0000000..4ce3d02
--- /dev/null
+++ b/tests/unit/absence-colors.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ ABSENCE_COLOR_KEYS,
+ ABSENCE_COLORS,
+ isAbsenceColorKey,
+} from '@/domain/absences/colors';
+
+describe('familles de couleur des absences', () => {
+ it('n’a pas deux fois la même clé', () => {
+ expect(new Set(ABSENCE_COLOR_KEYS).size).toBe(ABSENCE_COLOR_KEYS.length);
+ });
+
+ it('donne un libellé à chaque famille', () => {
+ for (const color of ABSENCE_COLORS) {
+ expect(color.label, `libellé manquant pour ${color.key}`).toBeTruthy();
+ }
+ });
+
+ it('refuse une clé hors liste', () => {
+ // La grille se replie alors sur le gris. Le contrôle sert à empêcher qu'une
+ // clé fautive s'enregistre en base : elle y resterait invisible jusqu'à ce
+ // qu'un planning affiche une barre grise sans qu'on sache pourquoi.
+ expect(isAbsenceColorKey('neutral')).toBe(false);
+ expect(isAbsenceColorKey('')).toBe(false);
+ expect(isAbsenceColorKey('cp')).toBe(true);
+ });
+});