From 804e14bf04d989c4ca5f30684566b0a9b160eef8 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 11 Aug 2026 13:45:53 +0200 Subject: [PATCH] =?UTF-8?q?=C3=89toffer=20le=20r=C3=A9f=C3=A9rentiel=20des?= =?UTF-8?q?=20types=20d'absence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinq types d'amorce ne tiennent pas un registre. Un commerce de détail rencontre un accident de trajet, une mise à pied, un congé pour examen, une visite médicale — et faute de type, ces absences se saisissent en « congé sans solde », ce qui fausse à la fois le décompte et la paie. Vingt-quatre types désormais, relevés sur le sélecteur du produit audité et recoupés avec les rubriques du dossier de paie. Les drapeaux sont des valeurs d'amorce, modifiables depuis les réglages : la nature Sécurité sociale ne se discute pas, mais l'acquisition de congés pendant une absence dépend de la convention. « Repos hebdomadaire » figure au sélecteur 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. Les familles de couleur passent de cinq à neuf, et deviennent une liste fermée avec ses libellés. Le champ des réglages était libre : une clé fautive s'y enregistrait sans bruit et la grille affichait un gris inexplicable. Quatre familles s'ajoutent — famille, formation, jour férié, sanction. Une sanction se distingue d'une absence ordinaire : elle est subie, elle se conteste, elle ne se lit pas comme un congé. La couleur groupe, le libellé précise. Donner sa teinte à chacun des vingt-quatre types rendrait la grille illisible bien avant la fin de la liste. Co-Authored-By: Claude Opus 5 --- .../types-absence/AbsenceTypeForms.tsx | 26 +++++--- src/components/planning/AbsenceBar.tsx | 33 +++++++++- src/components/planning/WeekGrid.tsx | 6 +- src/domain/absences/colors.ts | 33 ++++++++++ src/server/install/referentials.ts | 62 ++++++++++++++++--- src/server/settings/absence-types.ts | 7 ++- tests/unit/absence-colors.test.ts | 28 +++++++++ 7 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 src/domain/absences/colors.ts create mode 100644 tests/unit/absence-colors.test.ts 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); + }); +});