Étoffer le référentiel des types d'absence
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
a0524c56d7
commit
804e14bf04
7 files changed
+173
-22
No files matched your search
@@ -3,6 +3,7 @@
|
|||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||||
|
import { ABSENCE_COLORS } from '@/domain/absences/colors';
|
||||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import {
|
import {
|
||||||
createAbsenceTypeAction,
|
createAbsenceTypeAction,
|
||||||
@@ -77,13 +78,24 @@ function SharedFields({ row }: { row?: AbsenceTypeRow }) {
|
|||||||
defaultValue={row?.name}
|
defaultValue={row?.name}
|
||||||
placeholder="Congé payé"
|
placeholder="Congé payé"
|
||||||
/>
|
/>
|
||||||
<Field
|
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
label="Clé de couleur"
|
<span className="text-sm font-medium">Famille de couleur</span>
|
||||||
name="colorKey"
|
<select
|
||||||
required
|
name="colorKey"
|
||||||
maxLength={40}
|
defaultValue={row?.colorKey ?? 'sans-solde'}
|
||||||
defaultValue={row?.colorKey ?? 'neutral'}
|
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||||
/>
|
>
|
||||||
|
{ABSENCE_COLORS.map((color) => (
|
||||||
|
<option key={color.key} value={color.key}>
|
||||||
|
{color.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-micro text-ink-3">
|
||||||
|
La teinte groupe, le libellé précise. Une couleur par type rendrait la
|
||||||
|
grille illisible bien avant le vingtième.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<Field
|
<Field
|
||||||
label="Délai de prévenance"
|
label="Délai de prévenance"
|
||||||
name="minNoticeDays"
|
name="minNoticeDays"
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
|
import type { AbsenceColorKey } from '@/domain/absences/colors';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
export type AbsenceKind = 'cp' | 'rtt' | 'maladie' | 'sans-solde' | 'attente';
|
/**
|
||||||
|
* Familles d'absence, pour la teinte de la barre — voir
|
||||||
|
* `src/domain/absences/colors.ts`.
|
||||||
|
*
|
||||||
|
* Le repli est `sans-solde`, gris, et c'est délibérément le plus terne : une
|
||||||
|
* teinte inconnue doit se remarquer comme une configuration incomplète, pas se
|
||||||
|
* fondre dans les congés payés.
|
||||||
|
*/
|
||||||
|
export type AbsenceKind = AbsenceColorKey;
|
||||||
|
|
||||||
const KINDS: Record<AbsenceKind, { bg: string; ink: string; edge: string }> = {
|
const KINDS: Record<AbsenceKind, { bg: string; ink: string; edge: string }> = {
|
||||||
cp: {
|
cp: {
|
||||||
@@ -18,6 +27,28 @@ const KINDS: Record<AbsenceKind, { bg: string; ink: string; edge: string }> = {
|
|||||||
ink: 'var(--color-ink-2)',
|
ink: 'var(--color-ink-2)',
|
||||||
edge: 'var(--color-line-3)',
|
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': {
|
'sans-solde': {
|
||||||
bg: 'var(--color-surface-2)',
|
bg: 'var(--color-surface-2)',
|
||||||
ink: 'var(--color-ink-2)',
|
ink: 'var(--color-ink-2)',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AbsenceBar, type AbsenceKind } from '@/components/planning/AbsenceBar';
|
import { AbsenceBar, type AbsenceKind } from '@/components/planning/AbsenceBar';
|
||||||
import { CounterStrip } from '@/components/planning/CounterStrip';
|
import { CounterStrip } from '@/components/planning/CounterStrip';
|
||||||
import { ShiftChip } from '@/components/planning/ShiftChip';
|
import { ShiftChip } from '@/components/planning/ShiftChip';
|
||||||
|
import { isAbsenceColorKey } from '@/domain/absences/colors';
|
||||||
import { computeWeekCounters } from '@/domain/counters/week';
|
import { computeWeekCounters } from '@/domain/counters/week';
|
||||||
import {
|
import {
|
||||||
displayName,
|
displayName,
|
||||||
@@ -246,8 +247,5 @@ function Row({
|
|||||||
* repli neutre : une teinte inconnue vaut mieux affichée en gris qu'absente.
|
* repli neutre : une teinte inconnue vaut mieux affichée en gris qu'absente.
|
||||||
*/
|
*/
|
||||||
function absenceKind(colorKey: string): AbsenceKind {
|
function absenceKind(colorKey: string): AbsenceKind {
|
||||||
const known: AbsenceKind[] = ['cp', 'rtt', 'maladie', 'sans-solde', 'attente'];
|
return isAbsenceColorKey(colorKey) ? colorKey : 'sans-solde';
|
||||||
return known.includes(colorKey as AbsenceKind)
|
|
||||||
? (colorKey as AbsenceKind)
|
|
||||||
: 'sans-solde';
|
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -45,16 +45,64 @@ const RETENTION: ReadonlyArray<
|
|||||||
/**
|
/**
|
||||||
* Types d'absence d'amorce.
|
* 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
|
* `silaeCode` reste **nul** : la correspondance entre un type et une rubrique
|
||||||
* de paie appartient au dossier du client. La renseigner ici imputerait des
|
* de paie appartient au dossier du client. La renseigner ici imputerait des
|
||||||
* congés à la rubrique d'un autre cabinet.
|
* 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 = [
|
const ABSENCE_TYPES = [
|
||||||
{ code: 'CP', name: 'Congés payés', colorKey: 'cp', isPaid: true, social: false, notice: 30 },
|
// --- Congés et repos ----------------------------------------------------
|
||||||
{ code: 'RTT', name: 'RTT', colorKey: 'rtt', isPaid: true, social: false, notice: 7 },
|
{ code: 'CP', name: 'Congé payé', colorKey: 'cp', isPaid: true, social: false, notice: 30, accrual: true, justify: false },
|
||||||
{ code: 'MAL', name: 'Arrêt maladie', colorKey: 'maladie', isPaid: false, social: true, notice: null },
|
{ code: 'RTT', name: 'RTT', colorKey: 'rtt', isPaid: true, social: false, notice: 7, accrual: true, justify: false },
|
||||||
{ 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, accrual: true, justify: false },
|
||||||
{ code: 'RC', name: 'Repos compensateur', colorKey: 'rtt', isPaid: true, social: false, notice: 7 },
|
{ 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;
|
] as const;
|
||||||
|
|
||||||
export async function installReferentials(
|
export async function installReferentials(
|
||||||
@@ -87,9 +135,9 @@ export async function installReferentials(
|
|||||||
colorKey: type.colorKey,
|
colorKey: type.colorKey,
|
||||||
isPaid: type.isPaid,
|
isPaid: type.isPaid,
|
||||||
countsAsWorkTime: false,
|
countsAsWorkTime: false,
|
||||||
affectsPaidLeaveAccrual: !type.social,
|
affectsPaidLeaveAccrual: type.accrual,
|
||||||
isSocialSecurity: type.social,
|
isSocialSecurity: type.social,
|
||||||
requiresJustification: type.social,
|
requiresJustification: type.justify,
|
||||||
minNoticeDays: type.notice,
|
minNoticeDays: type.notice,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AuthorizationError } from '@/domain/access/authorize';
|
import { AuthorizationError } from '@/domain/access/authorize';
|
||||||
|
import { isAbsenceColorKey } from '@/domain/absences/colors';
|
||||||
import { recordAudit } from '@/server/audit';
|
import { recordAudit } from '@/server/audit';
|
||||||
import { mutate, query } from '@/server/context';
|
import { mutate, query } from '@/server/context';
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ const createInput = z.object({
|
|||||||
.max(20)
|
.max(20)
|
||||||
.regex(/^[A-Za-z0-9_-]+$/, 'Lettres, chiffres, tirets et soulignés'),
|
.regex(/^[A-Za-z0-9_-]+$/, 'Lettres, chiffres, tirets et soulignés'),
|
||||||
name: z.string().trim().min(1, 'Nom requis').max(120),
|
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,
|
...flags,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ function readForm(formData: FormData) {
|
|||||||
return {
|
return {
|
||||||
code: formData.get('code'),
|
code: formData.get('code'),
|
||||||
name: formData.get('name'),
|
name: formData.get('name'),
|
||||||
colorKey: formData.get('colorKey') || 'neutral',
|
colorKey: formData.get('colorKey') || 'sans-solde',
|
||||||
isPaid: formData.get('isPaid') === 'on',
|
isPaid: formData.get('isPaid') === 'on',
|
||||||
countsAsWorkTime: formData.get('countsAsWorkTime') === 'on',
|
countsAsWorkTime: formData.get('countsAsWorkTime') === 'on',
|
||||||
affectsPaidLeaveAccrual: formData.get('affectsPaidLeaveAccrual') === 'on',
|
affectsPaidLeaveAccrual: formData.get('affectsPaidLeaveAccrual') === 'on',
|
||||||
@@ -179,7 +180,7 @@ export async function createAbsenceTypeAction(
|
|||||||
const updateInput = z.object({
|
const updateInput = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().trim().min(1, 'Nom requis').max(120),
|
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,
|
...flags,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user