Author SHA1 Message Date
MichaelandClaude Opus 5 49c311423d Rattraper le référentiel des absences des instances déjà installées
installReferentials() pose les types d'absence depuis WP-02, mais ne tourne
qu'à la création du compte. Une instance installée avant garde zéro type : le
menu « Type » du formulaire de demande reste vide et le module de congés est
inutilisable, sans qu'aucune erreur ne le signale. Déployer le correctif ne
suffisait donc pas.

La migration ne touche que les comptes dont le référentiel est entièrement
vide — un type délibérément archivé ne doit pas réapparaître. Elle lève
FORCE ROW LEVEL SECURITY le temps de la transaction, faute de compte courant.

Le formulaire dit désormais que le référentiel manque et renvoie aux réglages,
plutôt que d'afficher un menu vide qui laisse saisir une demande impossible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:52:13 +02:00
MichaelandClaude Opus 5 dd01fb58f4 Supprimer l'ascenseur des onglets, et rendre les réglages à un rail
Les onglets de section défilaient horizontalement dès que la liste dépassait la
largeur. Une barre de défilement cache la moitié des entrées derrière un geste
que rien n'annonce : on ne sait pas ce qu'on ne voit pas. Ils passent à la ligne
— deux rangées coûtent seize pixels et montrent tout.

Les réglages sortent des onglets pour un rail vertical, et eux seuls. Ce n'est
pas un retour de la barre latérale : partout ailleurs la section tient en
quelques entrées et le contenu garde toute la largeur. Les réglages en comptent
une vingtaine ; en onglets, ils occuperaient trois rangées au-dessus de chaque
écran et changeraient de forme d'une page à l'autre selon la longueur des
libellés.

Le rail les groupe dans l'ordre d'un paramétrage — société, organisation,
travail, paie, accès, conformité — et non par ordre alphabétique : quelqu'un qui
installe l'instance descend la liste. Une entrée ajoutée sans groupe reste
visible en fin de rail plutôt que de disparaître.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:50:38 +02:00
MichaelandClaude Opus 5 9356c6e3f4 Refondre le thème autour du registre et du chiffre
L'ancien thème était crème et vert forêt, corps à 14 px, libellés secondaires à
11 px. Deux défauts s'y cumulaient : ça se lisait mal huit heures par jour, et
le fond chaud décalait la perception des aplats — un jaune de poste virait au
vert, un rouge d'alerte s'éteignait.

Le nouveau part de ce que l'écran est vraiment : un registre qu'on lit en
chiffres. Papier légèrement bleuté, encre bleu-ardoise plus dense, et un seul
bleu d'acte réservé à trois emplois — le filet actif, les liens, le bouton qui
engage. Tout le reste est en gris et en état, ce qui rend les alertes visibles
parce qu'elles sont les seules choses colorées de la page.

Lisibilité d'abord. Le corps monte à 15 px, rien ne descend sous 12, et `ink-3`
passe de 0,545 à 0,50 de clarté : à l'ancienne valeur, les libellés secondaires
tombaient sous 4,5:1 sur ce papier.

Typographie : IBM Plex Sans, et Plex Mono pour les codes. Le choix vient du
contenu — cet écran affiche des identifiants, AB-300, HS-HS25, un matricule, un
département 2A. Plex a été dessiné pour la documentation technique : le 1 porte
un empattement, le l est courbé, le 0 se distingue du O, et ces trois détails
décident si un gestionnaire recopie le bon code. Le zéro barré est activé
partout. Les fichiers sont téléchargés à la compilation et servis par
l'application : `font-src 'self'` tient, et aucune requête ne part vers un tiers.

La structure est portée par un **filet de marge** de 2 px plutôt que par des
bordures et des aplats gris — la marge du cahier, appliquée à une interface. Il
marque l'onglet ouvert, l'en-tête de carte, la section active. Il ne décore
jamais : un filet dit une appartenance.

Le dimanche a le sien, doublé, et une colonne teintée. C'est la seule colonne du
planning qui porte une conséquence de droit — L3132-27 impose une rémunération
au moins double **et** un repos compensateur équivalent. La grille le montre
avant que la paie ne le découvre. Le marquage ne remplace aucune alerte : le
moteur de règles reste seul juge.

La barre d'application devient collante, et les boutons passent de 26 à 32 px de
haut : sur une grille de quatre-vingts lignes, remonter changer de semaine est
le geste le plus répété, et 26 px était sous le seuil confortable au pointeur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:37:11 +02:00
MichaelandClaude Opus 5 d90afe53a0 Recueillir à l'embauche ce qu'exige le contrat
Trois corrections sur le formulaire d'embauche, dont une qui explique le
blocage rencontré sur le planning.

L'équipe devient **requise** dès qu'un contrat est ouvert. Elle était
facultative, avec un « Aucune pour l'instant » qui paraissait anodin : le
planning s'ordonnant par équipe, le salarié n'apparaissait alors sur aucune
grille, et rien ne le signalait avant la première semaine à couvrir. Quand
l'établissement choisi n'a aucune équipe, le champ le dit et renvoie vers les
réglages plutôt que de laisser deviner. Le contrôle est repris côté serveur : la
validation du navigateur ne protège pas une action.

Le type de contrat n'a plus de valeur par défaut, et les neuf valeurs sont
rangées par ordre alphabétique. Un CDI pré-rempli est un type que personne n'a
choisi, alors qu'il commande les règles de durée et la déclaration ; et le
classement par fréquence supposée fait chercher les huit autres.

L'envoi des plannings par SMS disparaît, formulaire, dossier et colonne. Le
drapeau recueillait un consentement pour un canal qui n'a jamais existé et qu'il
n'est pas prévu de brancher. Un consentement conservé sans finalité est une
donnée collectée sans base légale : la minimisation impose de la supprimer, pas
de la garder au cas où.

Une divergence assumée avec le produit audité : la case « ouvrir un contrat
maintenant » reste. Il faut pouvoir créer un administrateur ou un manager invité
sans lui inventer un contrat de travail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:07:11 +02:00
MichaelandClaude Opus 5 791dd523eb Donner une équipe à tout établissement neuf
Un établissement naissait sans équipe. Le planning s'ordonnant par équipe, il
n'avait alors aucune ligne où poser un créneau — même avec des salariés
rattachés. L'écran annonçait « cet établissement n'a pas encore d'équipe », ce
qui se lit « il n'y a personne », et rien ne disait où en créer une.

Deux corrections. La création d'un établissement pose une première équipe, à
l'installation comme depuis les réglages ; elle se renomme, et rien n'empêche
d'en ajouter d'autres. Et le message du planning dit désormais où aller : créer
l'équipe dans les réglages, puis rattacher les salariés depuis l'onglet
« Planification et accès » de leur fiche.

Les établissements déjà créés gardent leur situation : le correctif empêche d'en
produire de nouveaux sans équipe, il ne rattrape pas l'existant. Une équipe
s'ajoute en deux clics depuis Réglages · Établissements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:02:15 +02:00
MichaelandClaude Opus 5 ef3dd5c027 Fermer les listes de pays et de départements
Nationalité, pays de naissance, département de naissance et pays de l'adresse
étaient des champs libres. « France », « FRANCE », « Française », « FR » et
« france » désignent la même chose et se retrouvaient toutes en base. Le
registre unique du personnel les imprime telles quelles, et un contrôle y lit
cinq pays.

Le code ISO 3166-1 est désormais stocké, jamais le libellé : il ne change pas
d'orthographe, tient dans deux caractères, et c'est lui que reprennent les
déclarations sociales. Le libellé n'est plus qu'un affichage, et le registre
imprime le gentilé résolu depuis le code.

La liste des pays couvre ce qu'un commerce de détail français rencontre —
Europe, pourtour méditerranéen, Afrique francophone, principaux pays d'origine
relevés par l'INSEE. Elle n'est pas universelle et ne le prétend pas : un pays
absent s'ajoute en une ligne, plutôt que de rouvrir la saisie libre pour tout le
monde. La France est en tête du sélecteur, le reste par ordre alphabétique ; un
classement par fréquence supposée aurait été deviné, et se lirait comme un
jugement.

Les départements portent leur code officiel, Corse comprise — 2A et 2B, jamais
20, qui n'existe plus depuis 1976 mais survit dans les saisies manuelles. Les
trois départements du régime local d'Alsace-Moselle sont identifiés à part :
deux jours fériés de plus et un régime de maintien de salaire distinct, ce qui
change la planification d'un établissement à quinze kilomètres d'un autre.

Un dossier saisi avant la liste porte encore un libellé en clair. Le sélecteur
n'y trouve pas d'option, affiche « non renseigné », et la première correction du
dossier écrit le code. La valeur n'est ni perdue en silence ni opposée à qui
vient corriger un champ voisin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:00:07 +02:00
MichaelandClaude Opus 5 fab497acdc Détailler les pauses d'un créneau, et prévenir le salarié
Un créneau portait un total de pause. Deux pauses de vingt minutes et une
coupure de deux heures s'écrivaient toutes deux « 120 », et rien ne permettait
de les distinguer — ni à l'écran, ni au contrôle. Or elles ne se planifient pas
de la même façon : la première satisfait la pause minimale au-delà de six
heures, la seconde interroge l'amplitude de la journée.

Chaque pause a désormais sa ligne : durée, début facultatif, libellé, et si elle
est rémunérée. Le début reste facultatif à dessein — beaucoup de pauses se
prennent « quand c'est calme », et imposer une heure inventerait une précision
que le planning n'a pas et qu'un contrôle prendrait pour un engagement.

Le créneau garde deux totaux dérivés, et la distinction porte de l'argent.
`breakMinutes` reste ce qu'il était, la part non rémunérée déduite du temps de
travail : la paie, les compteurs et l'export continuent de le lire sans changer
d'un caractère. `paidBreakMinutes` s'ajoute à côté, et la règle de pause
minimale regarde la somme des deux — sans quoi un créneau dont la pause de vingt
minutes est payée passerait pour un créneau sans pause, et l'employeur le plus
généreux serait le seul à recevoir une alerte.

La migration reprend les créneaux existants : leur total devient une pause
unique, non rémunérée. Laisser la table vide aurait fait disparaître de l'écran
des pauses qui comptent pourtant toujours dans les heures payées.

En modification, les pauses sont remplacées et non fusionnées : le formulaire
porte l'état voulu au complet, et rapprocher ligne à ligne ferait survivre une
pause qu'on vient de retirer.

L'avis d'affectation part enfin, sous sa propre nature d'envoi. Il est différé à
la fin de la transaction : un refus sur le dernier jour d'une répétition annule
les autres, et personne ne doit être prévenu d'un créneau qui n'existe pas. La
case est décochée par défaut, à l'inverse du produit audité — c'est la
publication de la semaine qui prévient, et cette case sert au créneau ajouté
après coup, le cas où le salarié ne verrait rien sans elle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:32:03 +02:00
MichaelandClaude Opus 5 aa7336d749 Répéter un créneau sur plusieurs jours
Le panneau créait un créneau, pour un jour. Or un même horaire se répète d'un
jour à l'autre bien plus souvent qu'il ne varie : ressaisir cinq fois
« 9 h–17 h, pause 30 » était le geste le plus répété de la semaine, et le plus
exposé à la faute de frappe.

Les sept jours sont désormais cochables. Le jour ouvert est coché et verrouillé
— on ne crée pas ailleurs qu'ici depuis la case d'un jour donné — et voyage dans
un champ caché, puisqu'une case désactivée n'est pas envoyée.

La création est **tout ou rien**. Un jour refusé, pour période close ou
chevauchement, annule les autres : une répétition à demi appliquée laisserait un
planning que personne n'a voulu, et qu'il faudrait défaire à la main pour le
refaire. Le contrôle de période porte donc sur tous les jours demandés avant la
première écriture.

Trois champs du modèle étaient inatteignables depuis l'écran : le nombre de
repas, la note du créneau, et l'étiquette en modification. Cette dernière était
un vrai défaut — une étiquette posée de travers se corrigeait en supprimant le
créneau pour le refaire, ce qui perdait sa validation et son historique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:54:26 +02:00
MichaelandClaude Opus 5 804e14bf04 É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>
2026-08-11 13:45:53 +02:00
MichaelandClaude Opus 5 a0524c56d7 Sortir la démonstration du chemin d'installation
Une instance neuve passait par le seed pour être utilisable. Or le seed installe
une fiction : « Maison Rivage », des salariés inventés, quatre semaines de
planning, des absences. Sur une instance de travail, ces noms se confondent avec
de vrais salariés dans l'annuaire et au registre du personnel.

Le trou qu'il bouchait était réel : après les migrations, une instance avait le
schéma et rien à quoi l'accrocher. Aucun type d'absence, donc aucune demande
saisissable. Aucune étiquette, donc aucun créneau nommé. Aucune convention, donc
un moteur de règles muet qui laisse passer une semaine de soixante heures sans
rien dire.

L'écran d'installation pose donc désormais les référentiels : les douze
étiquettes, cinq types d'absence, la convention d'amorce IDCC 1517 avec
l'origine de chacun de ses paramètres, les durées de conservation et les jours
fériés des deux prochaines années. Ce ne sont pas des exemples mais des minima,
tous modifiables ensuite depuis les réglages.

Trois choses restent délibérément absentes. Aucun dimanche du maire : la liste
vient d'un arrêté municipal, et en inventer rendrait opposable un quota que
personne n'a accordé. Aucun code Silae sur les types d'absence : la
correspondance appartient au dossier du client. Aucun salarié, aucun créneau,
aucune absence.

Le seed devient `prisma/seed-demo.ts`, réservé au harnais Playwright. `db:seed`
disparaît au profit de `db:seed:demo` : il n'y a plus rien à semer pour
démarrer, et un nom qui le dit vaut mieux qu'un commentaire qui l'explique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:37:19 +02:00
MichaelandClaude Opus 5 0a916ff73e Retirer la barre latérale au profit d'onglets de section
Deux défauts la condamnaient. Elle rognait la largeur utile au moment précis où
la grille de planning en manque le plus. Et elle donnait à la navigation deux
grammaires : des onglets sur la fiche salarié, une liste verticale partout
ailleurs. Une grammaire se retient, deux se cherchent.

Les entrées de la section courante s'affichent donc en onglets, dans la même
forme que la fiche salarié. Une entrée sans écran construit reste visible et
mène à l'écran d'attente : la masquer ferait croire le produit plus étroit que
ce qu'il vise, la laisser inerte ferait croire la navigation cassée.

La colonne centrale passe en largeur fixe. Une largeur qui suit la fenêtre fait
bouger les colonnes du planning d'un poste à l'autre : la même semaine ne se lit
pas au même endroit sur deux écrans, et l'œil doit se réorienter à chaque
ouverture. Une largeur arrêtée rend la page reconnaissable ; sur une fenêtre
étroite, elle défile horizontalement plutôt que de comprimer la grille.

À l'impression, cette largeur n'a plus de sens et déborderait de la feuille :
elle y est relâchée, comme les autres largeurs minimales de la grille.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:31:23 +02:00
MichaelandClaude Opus 5 8033553222 Merge branch 'claude/referentiels-wp02'
Dix lots : référentiels, absences par état, vues de planning manquantes,
journal d'activité, analyses RH, modèles de documents et RGPD, politiques de
RTT, codes Silae du dossier, compte et préférences, correction du bandeau de
compteurs.

Trois migrations à appliquer avant démarrage : modèles de documents,
préférences de compte, impression et productivité.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:20:49 +02:00
55 changed files with 2506 additions and 301 deletions

No files matched your search

+2 -2
View File
@@ -67,8 +67,8 @@ jobs:
- name: Appliquer les migrations
run: pnpm db:deploy
- name: Installer le jeu de données
run: pnpm db:seed
- name: Installer le jeu de démonstration (Playwright)
run: pnpm db:seed:demo
- run: pnpm typecheck
+3 -1
View File
@@ -104,10 +104,12 @@ docker compose restart app
### Première installation
Les migrations posent le schéma, rien de plus : une instance neuve n'a **aucun compte et aucun utilisateur**. Le jeu de données de démonstration (`pnpm db:seed`) n'y remédie pas et refuse de tourner en production, à raison — personne ne veut de « Maison Rivage » et de salariés fictifs dans son registre du personnel.
Les migrations posent le schéma, rien de plus : une instance neuve n'a **aucun compte et aucun utilisateur**. Il n'y a rien à semer pour démarrer — et surtout pas le jeu de démonstration (`pnpm db:seed:demo`), réservé au harnais de tests : des salariés inventés dans un annuaire réel se confondent avec de vrais salariés, et il refuse de toute façon de tourner en production.
À la place, la première visite est redirigée vers `/installation`. L'écran demande le nom de l'entreprise, un premier établissement avec son fuseau horaire, et le compte qui administrera l'instance. Il crée le compte, le catalogue des capacités, les cinq rôles fournis, et vous connecte.
Il pose aussi les **référentiels** sans lesquels rien ne s'accroche : les douze étiquettes de planning, cinq types d'absence, la convention d'amorce IDCC 1517 avec l'origine de chacun de ses paramètres, les durées de conservation et les jours fériés des deux prochaines années. Ce ne sont pas des exemples mais des minima : sans type d'absence aucune demande n'est saisissable, et sans convention le moteur de règles laisse passer une semaine de soixante heures sans rien dire. Tout se modifie ensuite depuis les réglages — la convention notamment, qui se réédite en version datée.
Deux choses à savoir :
- **L'écran ne se rouvre pas.** Il crée un propriétaire sans demander de session ; le laisser accessible ensuite reviendrait à offrir tous les droits au premier visiteur. Une ligne en base marque l'installation, et cette ligne ne se modifie ni ne s'efface depuis l'application. Remettre une instance à zéro est un geste d'exploitant, fait depuis la base.
+2 -2
View File
@@ -19,11 +19,11 @@
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"mfa:reset": "tsx scripts/mfa-reset.ts",
"retention:purge": "tsx scripts/retention-purge.ts",
"verify": "pnpm typecheck && pnpm lint && pnpm test"
"verify": "pnpm typecheck && pnpm lint && pnpm test",
"db:seed:demo": "tsx prisma/seed-demo.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
@@ -0,0 +1,61 @@
-- ---------------------------------------------------------------------------
-- Pauses détaillées d'un créneau — PLAN.md §4.4.
--
-- `Shift.breakMinutes` garde son sens : la somme des pauses **non rémunérées**,
-- déduite du temps de travail. Tous les calculs de paie et de compteurs
-- continuent de la lire sans changer d'un caractère.
--
-- `paidBreakMinutes` s'ajoute à côté. Une pause payée n'est pas déduite du
-- temps travaillé mais reste une pause : sans cette colonne, la règle de pause
-- minimale au-delà de six heures verrait zéro et alerterait à tort sur un
-- créneau qui respecte la convention.
-- ---------------------------------------------------------------------------
ALTER TABLE "Shift" ADD COLUMN "paidBreakMinutes" INTEGER NOT NULL DEFAULT 0;
CREATE TABLE "ShiftBreak" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"shiftId" TEXT NOT NULL,
"startMinutes" INTEGER,
"durationMinutes" INTEGER NOT NULL,
"isPaid" BOOLEAN NOT NULL DEFAULT false,
"label" TEXT,
"position" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "ShiftBreak_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ShiftBreak_accountId_idx" ON "ShiftBreak"("accountId");
CREATE INDEX "ShiftBreak_shiftId_idx" ON "ShiftBreak"("shiftId");
ALTER TABLE "ShiftBreak" ADD CONSTRAINT "ShiftBreak_shiftId_fkey"
FOREIGN KEY ("shiftId") REFERENCES "Shift"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Reprise des créneaux existants : leur total devient une pause unique, non
-- rémunérée et non située. Laisser la table vide ferait disparaître de l'écran
-- des pauses qui comptent pourtant toujours dans les heures payées.
INSERT INTO "ShiftBreak" ("id", "accountId", "shiftId", "durationMinutes", "isPaid", "position")
SELECT
gen_random_uuid()::text,
"accountId",
"id",
"breakMinutes",
false,
0
FROM "Shift"
WHERE "breakMinutes" > 0;
-- ---------------------------------------------------------------------------
-- Isolation par compte — une pause dit à quelle heure quelqu'un s'absente de
-- son poste. Même défense en profondeur que le créneau qui la porte.
-- ---------------------------------------------------------------------------
ALTER TABLE "ShiftBreak" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ShiftBreak" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "ShiftBreak"
USING ("accountId" = planflow_current_account());
CREATE POLICY tenant_insert ON "ShiftBreak"
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
@@ -0,0 +1,14 @@
-- ---------------------------------------------------------------------------
-- Nature d'envoi distincte pour l'affectation d'un créneau.
--
-- Réutiliser PLANNING_PUBLISHED aurait rendu le journal d'envois illisible : le
-- jour où un salarié affirme n'avoir pas été prévenu, il faut pouvoir
-- distinguer « la semaine a été publiée » de « un créneau a été ajouté après
-- coup ».
--
-- Dans sa propre migration : PostgreSQL refuse qu'une valeur d'énumération soit
-- ajoutée puis employée dans la même transaction. Les séparer évite d'avoir à
-- s'en souvenir le jour où la migration suivante voudra s'en servir.
-- ---------------------------------------------------------------------------
ALTER TYPE "EmailKind" ADD VALUE IF NOT EXISTS 'SHIFT_ASSIGNED';
@@ -0,0 +1,11 @@
-- ---------------------------------------------------------------------------
-- Retrait de l'envoi des plannings par SMS.
--
-- Le drapeau était un consentement recueilli pour un canal qui n'a jamais
-- existé : aucun envoi de SMS n'est branché, et il n'est pas prévu de l'être.
-- Un consentement conservé sans finalité est une donnée collectée sans base
-- légale — la minimisation impose de la supprimer, pas de la garder « au cas
-- où ».
-- ---------------------------------------------------------------------------
ALTER TABLE "EmployeeProfile" DROP COLUMN IF EXISTS "smsSchedules";
@@ -0,0 +1,77 @@
-- Types d'absence sur les comptes installés avant WP-02 — PLAN.md §7.
--
-- `installReferentials()` pose le référentiel des absences depuis WP-02, mais
-- il ne tourne qu'à la création du compte. Une instance installée **avant**
-- garde donc zéro type d'absence, et le menu « Type » du formulaire de demande
-- reste vide : le module de congés est inutilisable, sans qu'aucune erreur ne
-- le signale. Déployer le correctif ne suffit pas — d'où ce rattrapage.
--
-- Il ne s'applique qu'aux comptes dont le référentiel est **entièrement** vide.
-- Un compte qui a renommé ou archivé ses types garde les siens : personne ne
-- doit voir réapparaître un type qu'il a délibérément retiré.
--
-- La liste double celle de `referentials.ts`, à dessein. Une migration est
-- figée par nature — elle décrit l'état à rattraper au 12 août 2026, pas la
-- liste courante. Les évolutions ultérieures du référentiel se font depuis
-- `/reglages/types-absence`, pas en rééditant ce fichier.
-- `AbsenceType` porte FORCE ROW LEVEL SECURITY : la politique d'insertion vaut
-- pour le propriétaire de la table comme pour tout le monde, et une migration
-- n'a pas de compte courant. La levée tient le temps de la transaction — un
-- échec l'annule, la table ne peut pas rester ouverte.
ALTER TABLE "AbsenceType" NO FORCE ROW LEVEL SECURITY;
INSERT INTO "AbsenceType" (
"id", "accountId", "code", "name", "colorKey",
"isPaid", "countsAsWorkTime", "affectsPaidLeaveAccrual",
"isSocialSecurity", "requiresJustification", "minNoticeDays"
)
SELECT
gen_random_uuid()::text,
a."id",
t."code", t."name", t."colorKey",
t."isPaid", FALSE, t."accrual",
t."social", t."justify", t."notice"
FROM "Account" a
CROSS JOIN (
VALUES
-- Congés et repos
('CP', 'Congé payé', 'cp', TRUE, TRUE, FALSE, FALSE, 30),
('RTT', 'RTT', 'rtt', TRUE, TRUE, FALSE, FALSE, 7),
('RC', 'Repos compensateur', 'rtt', TRUE, TRUE, FALSE, FALSE, 7),
('RC_NUIT', 'Repos compensateur de nuit', 'rtt', TRUE, TRUE, FALSE, FALSE, 7),
('RC_HAB', 'Repos compensateur d''habillement', 'rtt', TRUE, TRUE, FALSE, FALSE, 7),
('RECUP_JF', 'Récupération jour férié', 'rtt', TRUE, TRUE, FALSE, FALSE, 7),
('JF', 'Jour férié', 'ferie', TRUE, TRUE, FALSE, FALSE, NULL),
('CSS', 'Congé sans solde', 'sans-solde', FALSE, FALSE, FALSE, FALSE, 15),
-- Santé et accidents — catégories particulières au sens du RGPD
('MAL', 'Arrêt maladie', 'maladie', FALSE, FALSE, TRUE, TRUE, NULL),
('MP', 'Maladie professionnelle', 'maladie', FALSE, TRUE, TRUE, TRUE, NULL),
('AT', 'Accident du travail', 'maladie', FALSE, TRUE, TRUE, TRUE, NULL),
('AT_TRAJET', 'Accident de trajet', 'maladie', FALSE, TRUE, TRUE, TRUE, NULL),
('VM', 'Visite médicale', 'maladie', TRUE, TRUE, FALSE, TRUE, NULL),
-- Famille
('MAT', 'Congé maternité', 'famille', FALSE, TRUE, TRUE, TRUE, NULL),
('PAT', 'Congé paternité', 'famille', FALSE, TRUE, TRUE, TRUE, NULL),
('PAR', 'Congé parental', 'famille', FALSE, FALSE, FALSE, TRUE, 30),
('NAISS', 'Congé supplémentaire de naissance', 'famille', TRUE, TRUE, FALSE, TRUE, NULL),
('EVT_FAM', 'Évènement familial', 'famille', TRUE, TRUE, FALSE, TRUE, NULL),
-- Formation et obligations
('FORM', 'Formation', 'formation', TRUE, TRUE, FALSE, FALSE, 15),
('EXAM', 'Congé pour examen', 'formation', TRUE, TRUE, FALSE, TRUE, 15),
-- Absences non planifiées et sanctions
('ABS_JUS', 'Absence justifiée', 'sans-solde', FALSE, FALSE, FALSE, TRUE, NULL),
('ABS_INJ', 'Absence injustifiée', 'sans-solde', FALSE, FALSE, FALSE, FALSE, NULL),
('INDISPO', 'Indisponibilité ponctuelle', 'sans-solde', FALSE, FALSE, FALSE, FALSE, NULL),
('MAP_D', 'Mise à pied disciplinaire', 'sanction', FALSE, FALSE, FALSE, TRUE, NULL),
('MAP_C', 'Mise à pied conservatoire', 'sanction', FALSE, FALSE, FALSE, TRUE, NULL)
) AS t ("code", "name", "colorKey", "isPaid", "accrual", "social", "justify", "notice")
WHERE NOT EXISTS (
SELECT 1 FROM "AbsenceType" existing WHERE existing."accountId" = a."id"
);
ALTER TABLE "AbsenceType" FORCE ROW LEVEL SECURITY;
+37 -4
View File
@@ -603,10 +603,6 @@ model EmployeeProfile {
landline String?
personalEmail String?
/// Envoi des plannings par SMS. Un consentement, donc explicitement faux
/// par défaut : la charge de la preuve pèse sur l'employeur.
smsSchedules Boolean @default(false)
socialSecurityNumberEnc Bytes?
ibanEnc Bytes?
bicEnc Bytes?
@@ -819,7 +815,13 @@ model Shift {
startAt DateTime @db.Timestamptz
endAt DateTime @db.Timestamptz
/// Somme des pauses **non rémunérées**, déduite du temps de travail. C'est
/// le total qui compte en paie ; le détail vit dans `ShiftBreak`.
breakMinutes Int @default(0)
/// Somme des pauses **rémunérées**. Non déduite du temps travaillé, mais
/// comptée comme repos : la règle de pause minimale regarde les deux, sans
/// quoi une pause payée passerait pour une absence de pause.
paidBreakMinutes Int @default(0)
actualStartAt DateTime? @db.Timestamptz
actualEndAt DateTime? @db.Timestamptz
actualBreakMinutes Int?
@@ -833,12 +835,41 @@ model Shift {
version Int @default(0)
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
breaks ShiftBreak[]
@@index([accountId])
@@index([weeklyScheduleId, localDate])
@@index([membershipId, localDate])
}
/// Pause d'un créneau.
///
/// Plusieurs pauses par créneau, et pas un total unique : une coupure de deux
/// heures en milieu de journée et deux pauses de vingt minutes ne se planifient
/// pas de la même façon et ne se contrôlent pas pareil — la seconde satisfait
/// la pause minimale de six heures, la première interroge l'amplitude.
///
/// `startMinutes` est facultatif. Beaucoup de pauses se prennent « quand c'est
/// calme » : imposer une heure obligerait à inventer une précision que le
/// planning n'a pas, et qu'un contrôle prendrait pour un engagement.
model ShiftBreak {
id String @id @default(cuid())
accountId String
shiftId String
/// Minutes depuis le début du créneau. Null = pause non située.
startMinutes Int?
durationMinutes Int
/// Rémunérée : la durée n'est pas déduite du temps de travail effectif.
isPaid Boolean @default(false)
label String?
position Int @default(0)
shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade)
@@index([accountId])
@@index([shiftId])
}
/// Repos de journée — hebdomadaire ou compensateur. Les pauses *dans* un
/// créneau sont `Shift.breakMinutes`.
model Rest {
@@ -1430,6 +1461,8 @@ enum EmailKind {
INVITATION
PASSWORD_RESET
PLANNING_PUBLISHED
/// Créneau ajouté ou déplacé hors d'une publication de semaine.
SHIFT_ASSIGNED
TIMEOFF_DECISION
LEAVE_NOTICE
}
+13 -4
View File
@@ -31,11 +31,20 @@ import { evaluateSchedule } from '../src/server/compliance/evaluate';
import { withTenant } from '../src/server/tenant';
/**
* Jeu de données de départ — PLAN.md §11.
* Jeu de démonstration — PLAN.md §11.
*
* **Entièrement fictif.** Deux établissements, des équipes, et les rôles
* fournis. Le mot de passe de démonstration n'a de sens qu'en développement ;
* il est refusé si NODE_ENV vaut production.
* **Entièrement fictif, et réservé aux tests.** Deux établissements, des
* salariés inventés, quatre semaines de planning, des absences. Il sert au
* harnais Playwright et à qui veut voir l'application peuplée ; il n'a rien à
* faire sur une instance de travail, fût-elle de développement — des noms
* inventés dans un annuaire réel se confondent avec de vrais salariés.
*
* Une instance neuve ne passe donc **plus** par ce fichier : l'écran de
* première installation pose le compte, le propriétaire et les référentiels
* (`src/server/install/referentials.ts`). Il n'y a rien à semer pour démarrer.
*
* Le mot de passe de démonstration n'a de sens qu'en développement ; il est
* refusé si NODE_ENV vaut production.
*/
const DEMO_PASSWORD = 'planflow-demo-2026';
+60 -25
View File
@@ -25,18 +25,35 @@ import type {
const empty: ActionState = {};
/**
* Les neuf types de contrat, par ordre alphabétique.
*
* Pas de « CDI en premier » : le classement par fréquence supposée fait chercher
* les huit autres, et se lit comme un défaut à accepter. L'ordre alphabétique
* ne privilégie rien et se parcourt sans réfléchir.
*/
const CONTRACT_TYPES = [
['CDI', 'CDI'],
['CDD', 'CDD'],
['APPRENTISSAGE', 'Apprentissage'],
['STAGIAIRE', 'Stagiaire'],
['SAISONNIER', 'Saisonnier'],
['EXTRA', 'Extra'],
['INTERIM', 'Intérim'],
['CDD', 'CDD'],
['CDI', 'CDI'],
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
['EXTRA', 'Extra'],
['INTERIM', 'Intérim'],
['SAISONNIER', 'Saisonnier'],
['STAGIAIRE', 'Stagiaire'],
] as const;
/** Marque d'obligation, annoncée aussi aux lecteurs d'écran. */
function Required() {
return (
<span className="text-danger">
<span aria-hidden>*</span>
<span className="sr-only"> (requis)</span>
</span>
);
}
const selectClass =
'h-9 min-w-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
@@ -136,20 +153,6 @@ export function AddEmployeeForm({
/>
<Field label="Téléphone mobile" name="phone" type="tel" />
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="smsSchedules"
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">Envoyer les plannings par SMS</span>
<span className="block text-micro text-ink-3">
Consentement du salarié : à recueillir avant de l’activer.
</span>
</span>
</label>
<Field label="Téléphone fixe" name="landline" type="tel" />
</Section>
@@ -186,9 +189,22 @@ export function AddEmployeeForm({
/>
</div>
{/* Aucune valeur par défaut : un type de contrat pré-rempli est
un type que personne n'a choisi, et il commande les règles de
durée comme la déclaration. */}
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">Type de contrat</span>
<select name="contractType" className={selectClass}>
<span className="text-sm font-medium">
Type de contrat <Required />
</span>
<select
name="contractType"
required
defaultValue=""
className={selectClass}
>
<option value="" disabled>
Sélectionnez un type
</option>
{CONTRACT_TYPES.map(([value, label]) => (
<option key={value} value={value}>
{label}
@@ -279,16 +295,35 @@ export function AddEmployeeForm({
</select>
</label>
{/* L'équipe est **requise** avec un contrat. Le planning
s'ordonne par équipe : un salarié rattaché à l'établissement
mais à aucune équipe n'apparaît sur aucune grille, et rien ne
le signale avant la première semaine à couvrir. */}
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">Équipe</span>
<select name="teamId" className={selectClass}>
<option value="">Aucune pour l’instant</option>
<span className="text-sm font-medium">
Équipe <Required />
</span>
<select
name="teamId"
required
defaultValue=""
className={selectClass}
>
<option value="" disabled>
Sélectionnez une équipe
</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
{teams.length === 0 ? (
<span className="text-micro text-danger">
Cet établissement n’a aucune équipe : créez-en une dans
Réglages · Établissements avant d’ouvrir un contrat ici.
</span>
) : null}
</label>
<label className="flex min-w-0 flex-col gap-1.5">
+32 -27
View File
@@ -11,6 +11,13 @@ import {
genderLabel,
maritalStatusLabel,
} from '@/domain/hr/civil-status';
import {
COUNTRY_OPTIONS,
NATIONALITY_OPTIONS,
countryLabel,
nationalityLabel,
} from '@/domain/legal/countries';
import { DEPARTMENTS, departmentLabel } from '@/domain/legal/departments';
import {
updateProfileAction,
updateSensitiveAction,
@@ -41,7 +48,6 @@ export interface ProfileFields {
personalEmail: string;
phone: string;
landline: string;
smsSchedules: boolean;
addressLine1: string;
addressLine2: string;
postalCode: string;
@@ -153,22 +159,31 @@ export function PersonalInfoPanel({
defaultValue={profile.birthName}
/>
<Field label="Nom de famille" name="lastName" required defaultValue={profile.lastName} />
<Field label="Nationalité" name="nationality" defaultValue={profile.nationality} />
<Select
label="Nationalité"
name="nationality"
defaultValue={profile.nationality}
options={NATIONALITY_OPTIONS}
/>
<Field
label="Date de naissance"
name="birthDate"
type="date"
defaultValue={profile.birthDate}
/>
<Field
<Select
label="Pays de naissance"
name="birthCountry"
defaultValue={profile.birthCountry}
options={COUNTRY_OPTIONS}
/>
<Field
{/* Renseigné pour une naissance en France ; laissé vide
ailleurs, où la notion n'existe pas. */}
<Select
label="Département de naissance"
name="birthDepartment"
defaultValue={profile.birthDepartment}
options={DEPARTMENTS}
/>
<Field
label="Commune de naissance"
@@ -201,20 +216,6 @@ export function PersonalInfoPanel({
defaultValue={profile.personalEmail}
/>
<Field label="Téléphone mobile" name="phone" type="tel" defaultValue={profile.phone} />
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="smsSchedules"
defaultChecked={profile.smsSchedules}
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">Notifications SMS</span>
<span className="block text-micro text-ink-3">
Consentement du salarié : à recueillir avant de l’activer.
</span>
</span>
</label>
<Field label="Téléphone fixe" name="landline" type="tel" defaultValue={profile.landline} />
<Field label="Adresse" name="addressLine1" defaultValue={profile.addressLine1} />
<Field
@@ -226,7 +227,12 @@ export function PersonalInfoPanel({
<Field label="Code postal" name="postalCode" defaultValue={profile.postalCode} />
<Field label="Ville" name="city" defaultValue={profile.city} />
</div>
<Field label="Pays" name="country" defaultValue={profile.country} />
<Select
label="Pays"
name="country"
defaultValue={profile.country}
options={COUNTRY_OPTIONS}
/>
</div>
</InfoCard>
@@ -267,10 +273,13 @@ export function PersonalInfoPanel({
<InfoRow label="Prénom" value={profile.firstName} />
<InfoRow label="Nom de naissance" value={profile.birthName} />
<InfoRow label="Nom de famille" value={profile.lastName} />
<InfoRow label="Nationalité" value={profile.nationality} />
<InfoRow label="Nationalité" value={nationalityLabel(profile.nationality || null)} />
<InfoRow label="Date de naissance" value={readableDate(profile.birthDate)} tnum />
<InfoRow label="Pays de naissance" value={profile.birthCountry} />
<InfoRow label="Département de naissance" value={profile.birthDepartment} />
<InfoRow label="Pays de naissance" value={countryLabel(profile.birthCountry || null)} />
<InfoRow
label="Département de naissance"
value={departmentLabel(profile.birthDepartment || null)}
/>
<InfoRow label="Commune de naissance" value={profile.birthPlace} />
<InfoRow
label="Situation familiale"
@@ -286,16 +295,12 @@ export function PersonalInfoPanel({
<InfoCard title="Coordonnées">
<InfoRow label="Adresse électronique personnelle" value={profile.personalEmail} />
<InfoRow label="Téléphone mobile" value={profile.phone} tnum />
<InfoRow
label="Notifications SMS"
value={profile.smsSchedules ? 'Oui' : 'Non'}
/>
<InfoRow label="Téléphone fixe" value={profile.landline} tnum />
<InfoRow label="Adresse" value={profile.addressLine1} />
<InfoRow label="Complément d’adresse" value={profile.addressLine2} />
<InfoRow label="Code postal" value={profile.postalCode} tnum />
<InfoRow label="Ville" value={profile.city} />
<InfoRow label="Pays" value={profile.country} />
<InfoRow label="Pays" value={countryLabel(profile.country || null)} />
</InfoCard>
<InfoCard title="Contact d’urgence">
-1
View File
@@ -52,7 +52,6 @@ export default async function PersonalTab({
personalEmail: text(employee.profile.personalEmail),
phone: text(employee.profile.phone),
landline: text(employee.profile.landline),
smsSchedules: employee.profile.smsSchedules,
addressLine1: text(employee.profile.addressLine1),
addressLine2: text(employee.profile.addressLine2),
postalCode: text(employee.profile.postalCode),
+19 -1
View File
@@ -100,10 +100,28 @@ export default async function SemainePage({ searchParams }: PageProps) {
) : null}
</div>
{/* Le planning s'ordonne par équipe : sans équipe, il n'y a pas de ligne
où poser un créneau, même quand des salariés sont rattachés à
l'établissement. Dire ce qui manque ne suffit pas — l'écran dit aussi
où le créer, sans quoi le message se lit « il n'y a personne ». */}
{board.sections.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
<div className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
<p className="mb-1 font-medium text-ink-1">
Cet établissement n’a pas encore d’équipe.
</p>
<p>
Le planning se construit équipe par équipe : chacune porte son propre
état de publication. Créez-en une dans{' '}
<Link
href="/reglages/etablissements"
className="underline underline-offset-2"
>
Réglages · Établissements
</Link>
, puis rattachez-y les salariés depuis l’onglet « Planification et
accès » de leur fiche.
</p>
</div>
) : null}
{board.sections.map((section) => (
@@ -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é"
/>
<Field
label="Clé de couleur"
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Famille de couleur</span>
<select
name="colorKey"
required
maxLength={40}
defaultValue={row?.colorKey ?? 'neutral'}
/>
defaultValue={row?.colorKey ?? 'sans-solde'}
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
label="Délai de prévenance"
name="minNoticeDays"
+177 -58
View File
@@ -14,74 +14,98 @@
========================================================================== */
@theme {
--font-ui: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, 'Cascadia Mono', 'Segoe UI Mono',
'Liberation Mono', Menlo, monospace;
/* IBM Plex — servi depuis l'application, jamais depuis un CDN (voir
src/app/layout.tsx). Le repli système suit en cas d'échec de chargement :
un planning doit rester lisible même quand la fonte ne l'est pas. */
--font-ui: var(--font-plex-sans), ui-sans-serif, system-ui, -apple-system,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: var(--font-plex-mono), ui-monospace, SFMono-Regular,
'Cascadia Mono', 'Segoe UI Mono', 'Liberation Mono', Menlo, monospace;
--text-micro: 11px;
--text-xs: 12px;
--text-sm: 13px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 20px;
--text-2xl: 26px;
--text-3xl: 32px;
/* Échelle relevée d'un cran : l'ancienne partait de 14 px de corps et 11 px
de micro-libellé, ce qui se lit mal huit heures par jour sur un écran de
back-office. Le corps est à 15 px, et rien ne descend sous 12. */
--text-micro: 12px;
--text-xs: 13px;
--text-sm: 14px;
--text-md: 15px;
--text-lg: 17px;
--text-xl: 21px;
--text-2xl: 27px;
--text-3xl: 34px;
--radius-1: 3px;
--radius-2: 6px;
--radius-3: 10px;
--radius-4: 14px;
/* Rayons courts. Un planning est un tableau de bord, pas une carte de
visite : au-delà de 8 px les cellules perdent leur alignement optique. */
--radius-1: 2px;
--radius-2: 5px;
--radius-3: 8px;
--radius-4: 12px;
--radius-full: 999px;
--ease-organic: cubic-bezier(0.2, 0.7, 0.3, 1);
--ease-organic-out: cubic-bezier(0, 0.6, 0.35, 1);
/* Neutres chauds — la base « organique » du thème. */
--color-canvas: oklch(0.976 0.008 85);
--color-surface: oklch(0.996 0.005 85);
--color-surface-2: oklch(0.958 0.01 85);
--color-surface-3: oklch(0.932 0.012 85);
--color-line-1: oklch(0.905 0.012 85);
--color-line-2: oklch(0.852 0.014 85);
--color-line-3: oklch(0.735 0.016 85);
--color-ink-1: oklch(0.255 0.02 140);
--color-ink-2: oklch(0.445 0.016 135);
--color-ink-3: oklch(0.545 0.014 120);
/* ---- Neutres froids ---------------------------------------------------
Papier légèrement bleuté plutôt que crème. Le crème réchauffe et fatigue
moins sur un texte long ; ici l'écran porte des tableaux de chiffres et des
aplats colorés, et un fond chaud décale la perception de ces aplats — un
jaune de poste vire au vert, un rouge d'alerte s'éteint. Le neutre froid
laisse la couleur dire ce qu'elle dit.
--------------------------------------------------------------------- */
--color-canvas: oklch(0.979 0.004 250);
--color-surface: oklch(1 0 0);
--color-surface-2: oklch(0.965 0.005 250);
--color-surface-3: oklch(0.938 0.008 250);
--color-line-1: oklch(0.912 0.008 250);
--color-line-2: oklch(0.858 0.011 250);
--color-line-3: oklch(0.72 0.015 250);
--color-accent: oklch(0.44 0.078 158);
--color-accent-hover: oklch(0.385 0.08 158);
--color-accent-press: oklch(0.335 0.072 158);
--color-accent-ink: oklch(0.985 0.008 158);
--color-accent-soft: oklch(0.935 0.03 158);
--color-accent-soft-ink: oklch(0.315 0.06 158);
/* Encre plus dense qu'avant, et franchement bleutée. `ink-3` monte à 0.50 :
à 0.545 sur ce papier, les libellés secondaires passaient sous 4,5:1. */
--color-ink-1: oklch(0.215 0.018 258);
--color-ink-2: oklch(0.405 0.018 256);
--color-ink-3: oklch(0.50 0.017 254);
--color-focus: oklch(0.52 0.16 252);
/* Bleu d'acte. Il ne sert qu'à trois choses : le filet actif, les liens, et
le bouton qui engage. Tout le reste du produit est en gris et en état. */
--color-accent: oklch(0.435 0.135 258);
--color-accent-hover: oklch(0.375 0.135 258);
--color-accent-press: oklch(0.325 0.12 258);
--color-accent-ink: oklch(0.99 0.004 258);
--color-accent-soft: oklch(0.945 0.028 258);
--color-accent-soft-ink: oklch(0.335 0.11 258);
--color-danger: oklch(0.475 0.145 30);
--color-danger-hover: oklch(0.415 0.145 30);
--color-danger-ink: oklch(0.985 0.006 30);
--color-danger-soft: oklch(0.94 0.035 30);
--color-danger-soft-ink: oklch(0.355 0.105 30);
/* Focus en cyan : distinct du bleu d'accent, sinon l'anneau disparaît sur le
bouton primaire, c'est-à-dire là où il compte le plus. */
--color-focus: oklch(0.58 0.155 218);
--color-warn: oklch(0.745 0.125 78);
--color-warn-ink: oklch(0.255 0.055 60);
--color-warn-soft: oklch(0.945 0.045 82);
--color-warn-soft-ink: oklch(0.375 0.085 60);
--color-danger: oklch(0.505 0.175 27);
--color-danger-hover: oklch(0.445 0.175 27);
--color-danger-ink: oklch(0.99 0.005 27);
--color-danger-soft: oklch(0.955 0.03 27);
--color-danger-soft-ink: oklch(0.395 0.13 27);
--color-ok: oklch(0.475 0.095 148);
--color-ok-soft: oklch(0.935 0.035 148);
--color-ok-soft-ink: oklch(0.315 0.07 148);
--color-warn: oklch(0.70 0.145 65);
--color-warn-ink: oklch(0.24 0.06 55);
--color-warn-soft: oklch(0.958 0.045 78);
--color-warn-soft-ink: oklch(0.415 0.10 55);
--color-info: oklch(0.475 0.105 245);
--color-info-soft: oklch(0.935 0.035 245);
--color-info-soft-ink: oklch(0.325 0.08 245);
--color-ok: oklch(0.505 0.115 158);
--color-ok-soft: oklch(0.955 0.033 158);
--color-ok-soft-ink: oklch(0.365 0.085 158);
--shadow-e1: 0 1px 2px oklch(0.3 0.02 85 / 0.08);
--color-info: oklch(0.505 0.115 240);
--color-info-soft: oklch(0.955 0.03 240);
--color-info-soft-ink: oklch(0.365 0.09 240);
/* Ombres froides et courtes. Une ombre longue sur un tableau dense fait
flotter les cartes les unes au-dessus des autres et brouille la lecture
des colonnes. */
--shadow-e1: 0 1px 1px oklch(0.35 0.03 258 / 0.06);
--shadow-e2:
0 1px 2px oklch(0.3 0.02 85 / 0.06), 0 6px 14px oklch(0.3 0.02 85 / 0.07);
0 1px 2px oklch(0.35 0.03 258 / 0.07), 0 4px 10px oklch(0.35 0.03 258 / 0.06);
--shadow-e3:
0 2px 6px oklch(0.3 0.02 85 / 0.08), 0 16px 40px oklch(0.3 0.02 85 / 0.14);
0 2px 4px oklch(0.35 0.03 258 / 0.08), 0 14px 34px oklch(0.35 0.03 258 / 0.12);
}
:root {
@@ -111,12 +135,16 @@
toujours écrit dans le bloc (voir ShiftChip).
--------------------------------------------------------------------- */
--tier-dir: -1;
--chip-bg-l: 0.905;
--chip-bg-c: 0.042;
--chip-fg-l: 0.315;
--chip-fg-c: 0.055;
--chip-edge-l: 0.52;
--chip-edge-c: 0.12;
/* Retendus pour le papier froid : fonds un cran plus clairs et moins
chargés, encre un cran plus sombre. Sur l'ancien fond crème, les aplats
tiraient tous vers le même beige moyen et la grille devenait un patchwork ;
ici ils restent des repères, et le texte du bloc passe 7:1. */
--chip-bg-l: 0.928;
--chip-bg-c: 0.038;
--chip-fg-l: 0.29;
--chip-fg-c: 0.062;
--chip-edge-l: 0.545;
--chip-edge-c: 0.115;
--post-cai-h: 250;
--post-cai-t: 0;
@@ -336,6 +364,30 @@ body {
font-size: var(--text-md);
line-height: var(--lh-ui);
-webkit-font-smoothing: antialiased;
/* Zéro barré partout. Sur un écran couvert de codes — AB-300, HS-HS25, un
matricule — la confusion entre 0 et O se paie en ligne de paie fausse. */
font-variant-numeric: slashed-zero;
}
/* Les titres se resserrent, les micro-libellés s'écartent. Cette respiration
inverse est ce qui distingue une hiérarchie d'un simple changement de corps :
à 27 px, l'espacement par défaut d'une grotesque paraît lâche ; à 12 px, il
colle. */
h1,
h2,
h3 {
letter-spacing: -0.02em;
line-height: var(--lh-tight);
}
/* Les codes se lisent en chasse fixe : ils se recopient caractère par
caractère, souvent à la main, dans un autre logiciel. */
code,
kbd,
samp {
font-family: var(--font-mono);
font-size: 0.95em;
font-variant-numeric: slashed-zero;
}
*,
@@ -357,6 +409,66 @@ body {
font-variant-numeric: tabular-nums;
}
/* ==========================================================================
Le filet — élément de structure du produit
Un planning est lu comme un registre : des lignes, des colonnes, et des
filets qui disent où commence quoi. Plutôt que d'entourer chaque bloc d'une
bordure complète, la structure est portée par un **filet de marge** de 2 px
à gauche de ce qui est actif ou de ce qui compte. C'est la marge rouge du
cahier, appliquée à une interface.
Deux règles, pour que ce ne soit pas un ornement :
- un filet marque une **appartenance** (cette section, cet onglet), jamais
une décoration ;
- il ne double jamais une bordure existante.
========================================================================== */
.spine {
border-left: 2px solid var(--color-accent);
padding-left: 0.875rem;
}
.spine-quiet {
border-left: 2px solid var(--color-line-2);
padding-left: 0.875rem;
}
/* Sur-titre : le petit libellé qui nomme une zone sans lui voler la vedette.
Interlettré large et gradé haut — à cette taille, la graisse porte mieux la
lisibilité que le corps. */
.eyebrow {
font-size: var(--text-micro);
font-weight: 650;
letter-spacing: 0.085em;
text-transform: uppercase;
color: var(--color-ink-3);
}
/* Un chiffre qu'on vient lire, par opposition à un chiffre dans une phrase :
compteur d'heures, effectif, écart. Tabulaire, dense, sans ambiguïté entre
le zéro et le O. */
.figure {
font-variant-numeric: tabular-nums lining-nums slashed-zero;
letter-spacing: -0.01em;
font-weight: 600;
color: var(--color-ink-1);
}
/* ---- Le dimanche ---------------------------------------------------------
La seule colonne du planning qui porte une conséquence de droit : L3132-27
impose une rémunération au moins double **et** un repos compensateur
équivalent. Elle est donc marquée dans la grille — fond très légèrement
teinté, et un filet double à sa gauche, celui qu'on trace dans un registre
pour séparer ce qui se totalise à part.
Le marquage ne remplace aucune alerte : le moteur de règles reste seul juge.
Il évite de découvrir un dimanche planifié au moment de la paie.
------------------------------------------------------------------------ */
.col-sunday {
background: var(--color-warn-soft);
box-shadow: inset 2px 0 0 var(--color-warn), inset 5px 0 0 var(--color-surface);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -411,6 +523,13 @@ body {
min-width: 0 !important;
}
/* La colonne centrale est de largeur fixe à l'écran — c'est ce qui rend la
grille reconnaissable d'un poste à l'autre. Sur une feuille, cette largeur
n'a plus de sens et déborderait de la page. */
.w-\[1280px\] {
width: auto !important;
}
[data-print='hide'],
nav,
aside {
+42 -1
View File
@@ -1,10 +1,46 @@
import type { Metadata } from 'next';
import { IBM_Plex_Mono, IBM_Plex_Sans } from 'next/font/google';
import { headers } from 'next/headers';
import { THEME_INIT_SCRIPT } from '@/components/shell/ThemeToggle';
import './globals.css';
/**
* IBM Plex Sans, et Plex Mono pour les codes.
*
* Le choix vient de ce que l'écran affiche vraiment : des **identifiants**.
* `AB-300`, `HS-HS25`, un matricule `E0004`, un code de poste, un SIRET, un
* département `2A`. Plex a été dessiné pour la documentation technique — le
* 1 porte un empattement, le l est courbé, le 0 se distingue du O — et ces
* trois détails décident si un gestionnaire de paie recopie le bon code.
*
* Ses formes légèrement carrées tiennent aussi dans une cellule de tableau
* dense, là où une grotesque géométrique se referme et où deux lignes de
* chiffres cessent de s'aligner à l'œil.
*
* `next/font` télécharge les fichiers **à la compilation** et les sert depuis
* l'application : la directive `font-src 'self'` reste satisfaite, et aucune
* requête ne part vers un tiers à l'exécution — ce que la charte de télémétrie
* interdit de toute façon.
*/
const plexSans = IBM_Plex_Sans({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
variable: '--font-plex-sans',
display: 'swap',
// Métriques ajustées sur la fonte de repli : sans cela, la bascule au
// chargement décale toute la grille d'un demi-caractère.
adjustFontFallback: true,
});
const plexMono = IBM_Plex_Mono({
subsets: ['latin'],
weight: ['400', '500'],
variable: '--font-plex-mono',
display: 'swap',
});
export const metadata: Metadata = {
title: 'PlanFlow',
description: 'Gestion du personnel, des plannings et des temps.',
@@ -19,7 +55,12 @@ export default async function RootLayout({
const nonce = (await headers()).get('x-nonce') ?? undefined;
return (
<html lang="fr" data-theme="light" suppressHydrationWarning>
<html
lang="fr"
data-theme="light"
className={`${plexSans.variable} ${plexMono.variable}`}
suppressHydrationWarning
>
<head>
<script
nonce={nonce}
+16
View File
@@ -33,6 +33,22 @@ export function RequestForm({
empty,
);
// Un menu « Type » vide ne se voit pas : le formulaire paraît complet, la
// demande part sans type et échoue au serveur. Dire que le référentiel manque
// coûte moins qu'une saisie qui ne pouvait pas aboutir.
if (absenceTypes.length === 0) {
return (
<p className="rounded-3 border border-line-2 bg-surface-2 p-3 text-sm text-ink-2">
Aucun type d’absence n’est défini : une demande ne peut pas être
qualifiée, donc pas enregistrée. Le référentiel se pose depuis{' '}
<a className="underline" href="/reglages/types-absence">
Réglages · Types d’absence
</a>
.
</p>
);
}
return (
<PersistentForm
resetAfter={state.ok ? state : null}
+32 -1
View File
@@ -1,6 +1,15 @@
import type { AbsenceColorKey } from '@/domain/absences/colors';
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 }> = {
cp: {
@@ -18,6 +27,28 @@ const KINDS: Record<AbsenceKind, { bg: string; ink: string; edge: string }> = {
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)',
+243 -8
View File
@@ -7,6 +7,7 @@ import { WeekGrid } from '@/components/planning/WeekGrid';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { PersistentForm } from '@/components/ui/PersistentForm';
import { cx } from '@/lib/cx';
import { formatMinutes } from '@/domain/counters/week';
import {
displayName,
@@ -205,6 +206,8 @@ export function TeamSection({
rows={section.rows}
localDate={dates[target.dayIndex] ?? ''}
dayLabel={days[target.dayIndex] ?? ''}
dates={dates}
days={days}
target={target}
onClose={() => setTarget(null)}
/>
@@ -350,6 +353,8 @@ function ShiftComposer({
rows,
localDate,
dayLabel,
dates,
days,
target,
onClose,
}: {
@@ -359,6 +364,8 @@ function ShiftComposer({
rows: BoardRow[];
localDate: string;
dayLabel: string;
dates: string[];
days: string[];
target: Target;
onClose: () => void;
}) {
@@ -437,26 +444,34 @@ function ShiftComposer({
/>
</Field>
<Field label="Pause (min)" htmlFor="composer-break">
<BreakRows
initial={editing ? target.shift.breaks : []}
fallbackMinutes={editing ? target.shift.breakMinutes : 0}
/>
<Field label="Repas" htmlFor="composer-meals">
<input
id="composer-break"
name="breakMinutes"
id="composer-meals"
name="mealCount"
type="number"
min={0}
max={600}
step={5}
defaultValue={editing ? target.shift.breakMinutes : 0}
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
max={5}
defaultValue={editing ? target.shift.mealCount : 0}
className="h-8 w-16 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
{editing ? null : (
{/* L'étiquette est modifiable à la correction aussi : posée de travers,
elle se corrigeait en supprimant le créneau pour le refaire — ce qui
perdait sa validation et son historique. */}
<Field label="Poste" htmlFor="composer-label">
<select
id="composer-label"
name="labelId"
defaultValue={editing ? (target.shift.labelId ?? '') : ''}
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
<option value="">Aucune étiquette</option>
{labels.map((label) => (
<option key={label.id} value={label.id}>
{label.name}
@@ -464,6 +479,32 @@ function ShiftComposer({
))}
</select>
</Field>
<Field label="Notes" htmlFor="composer-note">
<input
id="composer-note"
name="note"
type="text"
maxLength={500}
defaultValue={editing ? (target.shift.note ?? '') : ''}
placeholder="Consigne du jour"
className="h-8 w-56 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
{editing ? null : (
<>
<RepeatDays dates={dates} days={days} selected={localDate} />
{/* Décoché par défaut, à l'inverse du produit audité. Un avis par
créneau posé remplit vite une boîte : c'est la publication de la
semaine qui prévient, et cette case sert au créneau ajouté après
coup — le cas où le salarié ne verrait rien sans elle. */}
<label className="flex items-center gap-2 text-sm text-ink-2">
<input type="checkbox" name="notify" />
Prévenir le salarié par courriel
</label>
</>
)}
<div className="flex items-center gap-2">
@@ -484,6 +525,200 @@ function ShiftComposer({
);
}
/**
* Pauses d'un créneau, une ligne par pause.
*
* Un total unique ne disait pas ce qui composait la journée : deux pauses de
* vingt minutes et une coupure de deux heures s'écrivaient « 120 », et rien ne
* permettait de les distinguer — ni à l'écran, ni au contrôle.
*
* Le début est facultatif. Beaucoup de pauses se prennent « quand c'est
* calme » : imposer une heure obligerait à inventer une précision que le
* planning n'a pas, et qu'un contrôle prendrait pour un engagement.
*/
function BreakRows({
initial,
fallbackMinutes,
}: {
initial: ReadonlyArray<{
startMinutes: number | null;
durationMinutes: number;
isPaid: boolean;
label: string | null;
}>;
fallbackMinutes: number;
}) {
// Un créneau enregistré avant le détail des pauses n'a qu'un total : il est
// repris comme une pause unique plutôt que perdu.
const seeded =
initial.length > 0
? initial
: fallbackMinutes > 0
? [
{
startMinutes: null,
durationMinutes: fallbackMinutes,
isPaid: false,
label: null,
},
]
: [];
const [rows, setRows] = useState(seeded);
return (
<fieldset className="flex w-full flex-col gap-1.5">
<legend className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Pauses
</legend>
{rows.length === 0 ? (
<p className="text-micro text-ink-3">Aucune pause.</p>
) : null}
{rows.map((row, index) => (
<div key={index} className="flex flex-wrap items-center gap-2">
<input
name="breakDuration"
type="number"
min={1}
max={600}
step={5}
required
defaultValue={row.durationMinutes}
aria-label={`Durée de la pause ${index + 1}, en minutes`}
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
<span className="text-micro text-ink-3">min</span>
<input
name="breakStart"
type="number"
min={0}
step={5}
defaultValue={row.startMinutes ?? ''}
placeholder="début"
aria-label={`Début de la pause ${index + 1}, en minutes après la prise de poste`}
className="h-8 w-24 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
<input
name="breakLabel"
type="text"
maxLength={60}
defaultValue={row.label ?? ''}
placeholder="libellé"
aria-label={`Libellé de la pause ${index + 1}`}
className="h-8 w-28 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
<label className="flex items-center gap-1 text-micro text-ink-2">
{/* La valeur porte l'index : une case non cochée n'est pas envoyée,
et « on » seul ne dirait pas quelle ligne est payée. */}
<input
type="checkbox"
name="breakPaid"
value={String(index)}
defaultChecked={row.isPaid}
/>
rémunérée
</label>
<button
type="button"
onClick={() => setRows(rows.filter((_, at) => at !== index))}
className="rounded-2 px-1.5 py-0.5 text-micro text-ink-3 hover:text-danger"
>
Retirer
<span className="sr-only">{` la pause ${index + 1}`}</span>
</button>
</div>
))}
<div>
<Button
type="button"
size="sm"
onClick={() =>
setRows([
...rows,
{
startMinutes: null,
durationMinutes: 20,
isPaid: false,
label: null,
},
])
}
>
+ Ajouter une pause
</Button>
</div>
</fieldset>
);
}
/**
* Répétition du créneau sur plusieurs jours.
*
* Le jour ouvert est coché et **verrouillé** : on ne crée pas « ailleurs qu'ici
* » depuis la case d'un jour donné, et le décocher laisserait un formulaire qui
* ne produit rien tout en paraissant valide.
*
* La création est tout ou rien côté serveur : un jour refusé — période close,
* chevauchement — annule les autres. Une répétition à demi appliquée laisserait
* un planning que personne n'a voulu.
*/
function RepeatDays({
dates,
days,
selected,
}: {
dates: string[];
days: string[];
selected: string;
}) {
return (
<fieldset className="flex flex-col gap-1">
<legend className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Répéter sur
</legend>
<div className="flex flex-wrap gap-1">
{dates.map((date, index) => {
const locked = date === selected;
return (
<label
key={date}
className={cx(
'flex cursor-pointer items-center gap-1 rounded-2 border px-2 py-1 text-micro',
locked
? 'border-accent bg-accent-soft text-accent-soft-ink'
: 'border-line-2 bg-surface text-ink-2 hover:bg-surface-2',
)}
>
<input
type="checkbox"
name="localDates"
value={date}
defaultChecked={locked}
disabled={locked}
className="size-3"
/>
{/* Un champ désactivé n'est pas envoyé : le jour ouvert voyage
donc dans un champ caché, sans quoi cocher zéro case
produirait une création vide. */}
{locked ? (
<input type="hidden" name="localDates" value={date} />
) : null}
{(days[index] ?? date).slice(0, 3)}
</label>
);
})}
</div>
</fieldset>
);
}
function Field({
label,
htmlFor,
+2 -4
View File
@@ -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';
}
+88 -73
View File
@@ -4,18 +4,16 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';
import { SectionTabs } from '@/components/shell/SectionTabs';
import { SettingsRail } from '@/components/shell/SettingsRail';
import { ThemeToggle } from '@/components/shell/ThemeToggle';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cx } from '@/lib/cx';
import { signOutAction } from '@/server/auth/actions';
import {
isActive,
NAVIGATION,
placeholderHref,
sectionForPath,
sectionHref,
type NavItem,
} from '@/components/shell/navigation';
export interface AppShellProps {
@@ -35,19 +33,36 @@ export function AppShell({
}: AppShellProps) {
const pathname = usePathname();
const section = sectionForPath(pathname);
const isSettings = section.id === 'reglages';
return (
<div className="flex min-h-dvh flex-col bg-canvas text-ink-1">
<header data-print="hide" className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
{/*
Barre d'application, collante.
Elle reste au-dessus quand la page défile : sur une grille de quatre-vingts
lignes, revenir en haut pour changer de semaine ou d'établissement est le
geste qu'on répète le plus.
*/}
<header
data-print="hide"
className="sticky top-0 z-20 flex h-14 flex-none items-center gap-6 border-b border-line-2 bg-surface px-6"
>
<Link
href="/"
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.015em]"
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.02em] text-ink-1"
>
<span aria-hidden className="size-5 rounded-2 bg-accent" />
{/* Deux barres inégales : une semaine planifiée, pas un carré de
couleur. La marque du produit est sa grille. */}
<span aria-hidden className="flex h-4 items-end gap-[3px]">
<span className="h-4 w-[3px] rounded-full bg-accent" />
<span className="h-2.5 w-[3px] rounded-full bg-accent/55" />
<span className="h-3.5 w-[3px] rounded-full bg-accent/80" />
</span>
PlanFlow
</Link>
<nav aria-label="Sections" className="flex h-full gap-0.5">
<nav aria-label="Sections" className="flex h-full">
{NAVIGATION.map((entry) => {
const current = entry.id === section.id;
return (
@@ -56,10 +71,15 @@ export function AppShell({
href={sectionHref(entry)}
aria-current={current ? 'page' : undefined}
className={cx(
'flex h-full items-center border-b-2 px-3.5 text-sm font-medium whitespace-nowrap',
'relative flex h-full items-center px-4 text-sm whitespace-nowrap',
'transition-colors duration-[var(--d-2)] ease-organic',
// Le filet actif est en bas de la barre, à l'aplomb de
// l'onglet : c'est le même trait que celui qui marque la
// section ouverte plus bas dans la page.
'after:absolute after:inset-x-3 after:bottom-0 after:h-[2px] after:rounded-t-full',
current
? 'border-accent text-ink-1'
: 'border-transparent text-ink-3 hover:text-ink-1',
? 'font-semibold text-ink-1 after:bg-accent'
: 'font-medium text-ink-3 after:bg-transparent hover:text-ink-1',
)}
>
{entry.label}
@@ -70,85 +90,80 @@ export function AppShell({
<span className="flex-1" />
<Button size="sm">
Nantes Atlantis
<span aria-hidden className="text-[10px] text-ink-3">
▾
</span>
</Button>
<ThemeToggle />
<span className="h-6 w-px bg-line-1" aria-hidden />
<span
className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-ink"
className="flex items-center gap-2.5"
title={`${fullName} · ${roleName}`}
>
<span aria-hidden>{initials}</span>
<span
aria-hidden
className="flex size-8 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-ink"
>
{initials}
</span>
<span className="flex flex-col leading-tight">
<span className="text-xs font-medium text-ink-1">{fullName}</span>
<span className="text-micro text-ink-3">{roleName}</span>
</span>
<span className="sr-only">{`${fullName}, ${roleName}`}</span>
</span>
<form action={signOutAction}>
<Button size="sm" type="submit">
<Button size="sm" variant="ghost" type="submit">
Déconnexion
</Button>
</form>
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-57 flex-none flex-col gap-0.5 overflow-auto border-r border-line-1 bg-surface p-3 pt-4">
<p className="px-2.5 pb-2.5 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
{section.label}
</p>
{section.items.map((item) => (
<SidebarLink key={item.id} item={item} pathname={pathname} />
))}
<span className="flex-1" />
<p className="border-t border-line-1 px-2.5 pt-3 text-micro leading-relaxed text-ink-3">
Instance auto-hébergée
<br />
{accountName}
</p>
</aside>
{/*
Colonne centrale de **largeur fixe**, pas fluide.
<main className="min-w-0 flex-1 overflow-auto">{children}</main>
Une largeur qui suit la fenêtre fait bouger les colonnes du planning
d'un poste à l'autre : la même semaine ne se lit pas au même endroit sur
deux écrans, et l'œil doit se réorienter à chaque ouverture. Une largeur
arrêtée rend la page reconnaissable, et laisse la fenêtre défiler
horizontalement plutôt que de comprimer la grille.
*/}
<main className="min-h-0 flex-1 overflow-auto">
<div className="mx-auto flex min-h-full w-[1280px] flex-col">
{/*
Deux navigations de section, pour deux usages.
Partout, des onglets : la section tient en quelques entrées et le
contenu garde toute la largeur. Aux réglages, un rail vertical : une
vingtaine d'entrées en onglets prendraient trois rangées au-dessus de
chaque écran, et changeraient de forme d'une page à l'autre selon la
longueur des libellés. On traverse les réglages, on ne fait pas
d'aller-retour entre eux pendant qu'on travaille.
*/}
{isSettings ? (
// Le rail porte la marge gauche ; le contenu garde la sienne, celle
// de `PageBody`. Les additionner ferait dériver la colonne des
// réglages par rapport à toutes les autres pages.
<div className="flex min-h-full flex-1 gap-2 pl-6">
<SettingsRail section={section} pathname={pathname} />
<div className="min-w-0 flex-1">{children}</div>
</div>
</div>
);
}
function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
const current = item.href ? isActive(item.href, pathname) : false;
const className = cx(
'flex w-full items-center justify-between gap-2 rounded-2 px-2.5 py-1.5 text-left text-sm',
'transition-colors duration-[var(--d-1)] ease-organic',
current
? 'bg-accent-soft font-semibold text-accent-soft-ink'
: 'text-ink-2 hover:bg-surface-2',
);
const content = (
) : (
<>
<span className="truncate">{item.label}</span>
{item.badge ? (
<Badge subtle tone={current ? 'neutral' : 'neutral'}>
{item.badge}
</Badge>
) : null}
<div className="px-6 pt-6">
<SectionTabs section={section} pathname={pathname} />
</div>
{children}
</>
);
)}
if (!item.href) {
return (
<Link href={placeholderHref(item.label)} className={cx(className, 'text-ink-3')}>
{content}
</Link>
);
}
return (
<Link
href={item.href}
aria-current={current ? 'page' : undefined}
className={className}
<p
data-print="hide"
className="mt-auto border-t border-line-1 px-6 py-4 text-micro leading-relaxed text-ink-3"
>
{content}
</Link>
Instance auto-hébergée · {accountName}
</p>
</div>
</main>
</div>
);
}
+7 -5
View File
@@ -8,11 +8,11 @@ export interface PageHeaderProps {
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
return (
<div className="flex flex-wrap items-end gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-[-0.018em]">{title}</h1>
<div className="flex flex-wrap items-end gap-4 border-b border-line-1 pb-4">
<div className="min-w-0">
<h1 className="text-2xl font-semibold">{title}</h1>
{subtitle ? (
<p className="mt-1 text-sm text-ink-2">{subtitle}</p>
<p className="mt-1.5 text-sm text-ink-2">{subtitle}</p>
) : null}
</div>
<span className="flex-1" />
@@ -22,7 +22,9 @@ export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
}
export function PageBody({ children }: { children: ReactNode }) {
// `gap-6` plutôt que `gap-5` : sur un écran de cartes empilées, l'ancien
// écart laissait deux cartes se lire comme une seule.
return (
<div className="flex flex-col gap-5 px-6 pt-5 pb-16">{children}</div>
<div className="flex flex-col gap-6 px-6 pt-6 pb-20">{children}</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
'use client';
import Link from 'next/link';
import { Badge } from '@/components/ui/Badge';
import {
isActive,
placeholderHref,
type NavSection,
} from '@/components/shell/navigation';
import { cx } from '@/lib/cx';
/**
* Entrées de la section courante, en onglets.
*
* Elles vivaient dans une barre latérale. Deux raisons de l'avoir retirée :
* elle rognait la largeur utile au moment précis où la grille de planning en
* manque, et elle donnait à la navigation deux grammaires — des onglets sur la
* fiche salarié, une liste verticale ailleurs. Une seule grammaire se retient ;
* deux se cherchent.
*
* Une entrée sans `href` désigne un écran du périmètre qui n'est pas encore
* construit. Elle reste affichée et mène à un écran d'attente qui le dit : la
* masquer ferait croire le produit plus étroit qu'il n'est visé, et la laisser
* inerte ferait croire la navigation cassée.
*/
export function SectionTabs({
section,
pathname,
}: {
section: NavSection;
pathname: string;
}) {
if (section.items.length === 0) return null;
return (
// Les onglets **passent à la ligne**, ils ne défilent pas. Une barre de
// défilement horizontale cache la moitié des entrées derrière un geste que
// rien n'annonce : on ne sait pas ce qu'on ne voit pas. Deux rangées
// prennent seize pixels de plus et montrent tout.
<nav
aria-label={`Section ${section.label}`}
data-print="hide"
className="flex flex-wrap gap-1 rounded-3 border border-line-1 bg-surface p-1.5"
>
{section.items.map((item) => {
const current = item.href ? isActive(item.href, pathname) : false;
return (
<Link
key={item.id}
href={item.href ?? placeholderHref(item.label)}
aria-current={current ? 'page' : undefined}
className={cx(
'flex shrink-0 items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium whitespace-nowrap',
'transition-colors duration-[var(--d-1)] ease-organic',
current
? 'bg-surface-3 text-ink-1'
: item.href
? 'text-ink-2 hover:bg-surface-2 hover:text-ink-1'
: 'text-ink-3 hover:bg-surface-2',
)}
>
{item.label}
{item.badge ? (
<Badge subtle tone="neutral">
{item.badge}
</Badge>
) : null}
</Link>
);
})}
</nav>
);
}
+106
View File
@@ -0,0 +1,106 @@
'use client';
import Link from 'next/link';
import {
isActive,
placeholderHref,
type NavSection,
} from '@/components/shell/navigation';
import { cx } from '@/lib/cx';
/**
* Rail latéral des réglages — et **seulement** des réglages.
*
* Partout ailleurs, la section tient en une poignée d'entrées et se présente en
* onglets, qui laissent toute la largeur au contenu. Les réglages en comptent
* une vingtaine : en onglets, ils occuperaient trois rangées au-dessus de
* chaque écran, et la liste changerait de forme d'une page à l'autre selon la
* longueur des libellés.
*
* Une liste verticale les tient dans un ordre stable, groupés par domaine. Ce
* n'est pas la même navigation qu'ailleurs parce que ce n'est pas le même
* usage : on traverse les réglages, on ne fait pas d'aller-retour entre eux
* pendant qu'on travaille.
*/
/**
* Regroupement des entrées, dans l'ordre d'un paramétrage.
*
* L'identité de l'entreprise, puis ce qui organise le travail, puis ce qui le
* paie, puis ce qui l'encadre. Un client qui installe l'instance descend la
* liste ; c'est cet ordre-là, pas l'alphabet.
*/
const GROUPS: ReadonlyArray<{ label: string; ids: readonly string[] }> = [
{ label: 'Société', ids: ['compte', 'sites', 'convention'] },
{ label: 'Organisation', ids: ['emplois', 'etiquettes', 'types-absence'] },
{ label: 'Travail', ids: ['preferences', 'impression', 'productivite'] },
{ label: 'Paie', ids: ['paie-reglages'] },
{ label: 'Accès', ids: ['roles', 'securite', 'email'] },
{
label: 'Conformité',
ids: ['registre', 'conservation', 'rgpd', 'modeles'],
},
];
export function SettingsRail({
section,
pathname,
}: {
section: NavSection;
pathname: string;
}) {
const byId = new Map(section.items.map((item) => [item.id, item]));
// Une entrée ajoutée à la navigation sans être rangée dans un groupe reste
// visible, en fin de liste : la perdre serait pire que de mal la classer.
const grouped = new Set(GROUPS.flatMap((group) => group.ids));
const rest = section.items.filter((item) => !grouped.has(item.id));
const groups = [
...GROUPS.map((group) => ({
label: group.label,
items: group.ids
.map((id) => byId.get(id))
.filter((item) => item !== undefined),
})),
...(rest.length > 0 ? [{ label: 'Autres', items: rest }] : []),
].filter((group) => group.items.length > 0);
return (
<nav
aria-label="Réglages"
data-print="hide"
className="flex w-56 flex-none flex-col gap-5 pt-6 pb-4"
>
{groups.map((group) => (
<div key={group.label} className="flex flex-col gap-0.5">
<p className="eyebrow px-2.5 pb-1.5">{group.label}</p>
{group.items.map((item) => {
const current = item.href ? isActive(item.href, pathname) : false;
return (
<Link
key={item.id}
href={item.href ?? placeholderHref(item.label)}
aria-current={current ? 'page' : undefined}
className={cx(
'rounded-2 border-l-2 px-2.5 py-1.5 text-sm',
'transition-colors duration-[var(--d-2)] ease-organic',
current
? 'border-accent bg-accent-soft font-semibold text-accent-soft-ink'
: item.href
? 'border-transparent text-ink-2 hover:bg-surface-2 hover:text-ink-1'
: 'border-transparent text-ink-3 hover:bg-surface-2',
)}
>
{item.label}
</Link>
);
})}
</div>
))}
</nav>
);
}
+9 -5
View File
@@ -6,19 +6,23 @@ export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
export type ButtonSize = 'sm' | 'md';
const VARIANTS: Record<ButtonVariant, string> = {
// Le primaire porte une ombre courte : c'est le seul élément de l'interface
// qui se soulève, et il n'y en a qu'un par écran.
primary:
'border-accent bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-press',
'border-accent bg-accent text-accent-ink shadow-e1 hover:bg-accent-hover active:bg-accent-press active:shadow-none',
secondary:
'border-line-3 bg-surface text-ink-1 hover:bg-surface-2 active:bg-surface-3',
'border-line-2 bg-surface text-ink-1 hover:border-line-3 hover:bg-surface-2 active:bg-surface-3',
ghost:
'border-transparent bg-transparent text-ink-2 hover:bg-surface-2 hover:text-ink-1',
danger:
'border-danger bg-danger text-danger-ink hover:bg-danger-hover active:bg-danger-hover',
'border-danger bg-danger text-danger-ink shadow-e1 hover:bg-danger-hover active:bg-danger-hover active:shadow-none',
};
/* Cibles plus hautes : 26 px était sous le seuil confortable au pointeur, et
nettement sous les 44 px recommandés au doigt sur la version installable. */
const SIZES: Record<ButtonSize, string> = {
sm: 'h-[26px] px-2.5 text-xs',
md: 'h-8 px-3.5 text-sm',
sm: 'h-8 px-3 text-xs',
md: 'h-9 px-4 text-sm',
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
+7 -1
View File
@@ -12,6 +12,7 @@ export function Card({ children, className }: CardProps) {
<section
className={cx(
'overflow-hidden rounded-3 border border-line-1 bg-surface',
'shadow-e1',
className,
)}
>
@@ -29,8 +30,13 @@ export interface CardHeaderProps {
export function CardHeader({ title, badge, action }: CardHeaderProps) {
return (
// Le filet de marge marque l'en-tête, comme la marge d'un registre marque
// le début d'une écriture. Il remplace le fond gris qu'on met d'ordinaire
// là : un aplat de plus sur un écran de tableaux ajoute du bruit, un trait
// de 2 px suffit à dire « ici commence quelque chose ».
<header className="flex items-center gap-2.5 border-b border-line-1 px-4 py-3">
<h2 className="text-md font-semibold">{title}</h2>
<span aria-hidden className="-ml-4 h-5 w-[2px] rounded-r-full bg-accent" />
<h2 className="text-md font-semibold tracking-[-0.015em]">{title}</h2>
{badge}
<span className="flex-1" />
{action}
+33
View File
@@ -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);
}
+8 -3
View File
@@ -363,16 +363,21 @@ export const minBreakAfterThreshold: Rule = (context) => {
(shift.endAt.getTime() - shift.startAt.getTime()) / MINUTE,
);
if (span <= thresholdMinutes) return [];
if (shift.breakMinutes >= minBreakMinutes) return [];
// Rémunérée ou non, une pause est une pause. Ne compter que la part
// déduite ferait alerter un créneau dont la pause de vingt minutes est
// payée — c'est-à-dire précisément l'employeur le plus généreux.
const restMinutes = shift.breakMinutes + shift.paidBreakMinutes;
if (restMinutes >= minBreakMinutes) return [];
return [
violation(
'MIN_BREAK_AFTER_THRESHOLD',
`${shift.breakMinutes} min de pause pour ${hours(span)} de présence : minimum ${minBreakMinutes} min au-delà de ${hours(thresholdMinutes)}.`,
`${restMinutes} min de pause pour ${hours(span)} de présence : minimum ${minBreakMinutes} min au-delà de ${hours(thresholdMinutes)}.`,
{
localDate: zonedDate(shift.startAt, context.timeZone),
context: {
breakMinutes: shift.breakMinutes,
breakMinutes: restMinutes,
requiredMinutes: minBreakMinutes,
spanMinutes: span,
},
+9
View File
@@ -98,7 +98,16 @@ export interface ComplianceShift {
id: string;
startAt: Date;
endAt: Date;
/** Pauses non rémunérées, déduites du temps de travail. */
breakMinutes: number;
/**
* Pauses rémunérées.
*
* Elles ne se déduisent pas du temps travaillé, mais restent du repos : la
* règle de pause minimale les compte. Sans elles, un créneau où la pause de
* vingt minutes est payée passerait pour un créneau sans pause.
*/
paidBreakMinutes: number;
/** Faux pour un besoin non couvert : aucune règle de personne ne s'applique. */
assigned: boolean;
}
+37
View File
@@ -161,6 +161,43 @@ export function testMessage(to: string, accountName: string): Message {
return { to, subject: 'Test d’envoi PlanFlow', text, html };
}
export interface ShiftAssignedInput {
firstName: string;
/** Jour rédigé, « lundi 10 août ». */
day: string;
start: string;
end: string;
teamName: string;
}
/**
* Avis d'affectation d'un créneau.
*
* Le message dit le jour, l'horaire et l'équipe, et rien de plus : ni le motif,
* ni le reste du planning. Un avis qui recopierait la semaine entière ferait
* sortir de l'application des données que le destinataire n'a pas demandées, et
* qu'un courriel transfère sans contrôle.
*/
export function shiftAssignedMessage(
to: string,
input: ShiftAssignedInput,
): Message {
const subject = `Nouveau créneau le ${input.day}`;
const { text, html } = render({
title: subject,
intro: `Bonjour ${input.firstName},`,
body: [
`Un créneau vous a été ajouté le ${input.day}, de ${input.start} à ${input.end}.`,
`Équipe : ${input.teamName}.`,
'Votre planning à jour reste consultable dans PlanFlow.',
],
footer:
'Avis automatique — en cas d’erreur, adressez-vous à votre responsable.',
});
return { to, subject, text, html };
}
/**
* Message d'erreur d'envoi, débarrassé de tout secret.
*
+184
View File
@@ -0,0 +1,184 @@
/**
* Pays et nationalités — pour le dossier salarié et le registre du personnel.
*
* Liste fermée plutôt que champ libre. « France », « FRANCE », « Française »,
* « FR » et « france » désignaient la même chose et se retrouvaient toutes en
* base ; le registre unique du personnel les imprime telles quelles, et un
* contrôle y lit cinq pays.
*
* Le **code ISO 3166-1 alpha-2** est stocké, jamais le libellé : il ne change
* pas d'orthographe, tient dans deux caractères, et c'est lui que reprennent
* les déclarations sociales. Le libellé reste un affichage.
*
* La liste couvre les nationalités rencontrées dans un commerce de détail
* français : l'Europe, le pourtour méditerranéen, l'Afrique francophone, et les
* principaux pays d'origine relevés par l'INSEE. Elle n'est pas universelle et
* ne prétend pas l'être — un pays absent s'ajoute ici, en une ligne, plutôt que
* de rouvrir la saisie libre pour tout le monde.
*/
export interface Country {
/** ISO 3166-1 alpha-2. */
code: string;
name: string;
/** Gentilé au féminin singulier, forme retenue au registre. */
nationality: string;
}
export const COUNTRIES: readonly Country[] = [
{ code: 'FR', name: 'France', nationality: 'Française' },
{ code: 'DE', name: 'Allemagne', nationality: 'Allemande' },
{ code: 'AD', name: 'Andorre', nationality: 'Andorrane' },
{ code: 'AO', name: 'Angola', nationality: 'Angolaise' },
{ code: 'DZ', name: 'Algérie', nationality: 'Algérienne' },
{ code: 'AR', name: 'Argentine', nationality: 'Argentine' },
{ code: 'AM', name: 'Arménie', nationality: 'Arménienne' },
{ code: 'AU', name: 'Australie', nationality: 'Australienne' },
{ code: 'AT', name: 'Autriche', nationality: 'Autrichienne' },
{ code: 'BE', name: 'Belgique', nationality: 'Belge' },
{ code: 'BJ', name: 'Bénin', nationality: 'Béninoise' },
{ code: 'BY', name: 'Biélorussie', nationality: 'Biélorusse' },
{ code: 'BO', name: 'Bolivie', nationality: 'Bolivienne' },
{ code: 'BA', name: 'Bosnie-Herzégovine', nationality: 'Bosnienne' },
{ code: 'BR', name: 'Brésil', nationality: 'Brésilienne' },
{ code: 'BG', name: 'Bulgarie', nationality: 'Bulgare' },
{ code: 'BF', name: 'Burkina Faso', nationality: 'Burkinabè' },
{ code: 'KH', name: 'Cambodge', nationality: 'Cambodgienne' },
{ code: 'CM', name: 'Cameroun', nationality: 'Camerounaise' },
{ code: 'CA', name: 'Canada', nationality: 'Canadienne' },
{ code: 'CV', name: 'Cap-Vert', nationality: 'Cap-verdienne' },
{ code: 'CL', name: 'Chili', nationality: 'Chilienne' },
{ code: 'CN', name: 'Chine', nationality: 'Chinoise' },
{ code: 'CY', name: 'Chypre', nationality: 'Chypriote' },
{ code: 'CO', name: 'Colombie', nationality: 'Colombienne' },
{ code: 'KM', name: 'Comores', nationality: 'Comorienne' },
{ code: 'CG', name: 'Congo', nationality: 'Congolaise' },
{ code: 'CD', name: 'Congo (RDC)', nationality: 'Congolaise (RDC)' },
{ code: 'KR', name: 'Corée du Sud', nationality: 'Sud-coréenne' },
{ code: 'CI', name: "Côte d'Ivoire", nationality: 'Ivoirienne' },
{ code: 'HR', name: 'Croatie', nationality: 'Croate' },
{ code: 'DK', name: 'Danemark', nationality: 'Danoise' },
{ code: 'DJ', name: 'Djibouti', nationality: 'Djiboutienne' },
{ code: 'EG', name: 'Égypte', nationality: 'Égyptienne' },
{ code: 'AE', name: 'Émirats arabes unis', nationality: 'Émirienne' },
{ code: 'EC', name: 'Équateur', nationality: 'Équatorienne' },
{ code: 'ES', name: 'Espagne', nationality: 'Espagnole' },
{ code: 'EE', name: 'Estonie', nationality: 'Estonienne' },
{ code: 'US', name: 'États-Unis', nationality: 'Américaine' },
{ code: 'ET', name: 'Éthiopie', nationality: 'Éthiopienne' },
{ code: 'FI', name: 'Finlande', nationality: 'Finlandaise' },
{ code: 'GA', name: 'Gabon', nationality: 'Gabonaise' },
{ code: 'GE', name: 'Géorgie', nationality: 'Géorgienne' },
{ code: 'GH', name: 'Ghana', nationality: 'Ghanéenne' },
{ code: 'GR', name: 'Grèce', nationality: 'Grecque' },
{ code: 'GN', name: 'Guinée', nationality: 'Guinéenne' },
{ code: 'HT', name: 'Haïti', nationality: 'Haïtienne' },
{ code: 'HU', name: 'Hongrie', nationality: 'Hongroise' },
{ code: 'IN', name: 'Inde', nationality: 'Indienne' },
{ code: 'ID', name: 'Indonésie', nationality: 'Indonésienne' },
{ code: 'IQ', name: 'Irak', nationality: 'Irakienne' },
{ code: 'IR', name: 'Iran', nationality: 'Iranienne' },
{ code: 'IE', name: 'Irlande', nationality: 'Irlandaise' },
{ code: 'IS', name: 'Islande', nationality: 'Islandaise' },
{ code: 'IL', name: 'Israël', nationality: 'Israélienne' },
{ code: 'IT', name: 'Italie', nationality: 'Italienne' },
{ code: 'JP', name: 'Japon', nationality: 'Japonaise' },
{ code: 'JO', name: 'Jordanie', nationality: 'Jordanienne' },
{ code: 'KZ', name: 'Kazakhstan', nationality: 'Kazakhe' },
{ code: 'KE', name: 'Kenya', nationality: 'Kényane' },
{ code: 'XK', name: 'Kosovo', nationality: 'Kosovare' },
{ code: 'LV', name: 'Lettonie', nationality: 'Lettone' },
{ code: 'LB', name: 'Liban', nationality: 'Libanaise' },
{ code: 'LY', name: 'Libye', nationality: 'Libyenne' },
{ code: 'LT', name: 'Lituanie', nationality: 'Lituanienne' },
{ code: 'LU', name: 'Luxembourg', nationality: 'Luxembourgeoise' },
{ code: 'MK', name: 'Macédoine du Nord', nationality: 'Macédonienne' },
{ code: 'MG', name: 'Madagascar', nationality: 'Malgache' },
{ code: 'MY', name: 'Malaisie', nationality: 'Malaisienne' },
{ code: 'ML', name: 'Mali', nationality: 'Malienne' },
{ code: 'MT', name: 'Malte', nationality: 'Maltaise' },
{ code: 'MA', name: 'Maroc', nationality: 'Marocaine' },
{ code: 'MU', name: 'Maurice', nationality: 'Mauricienne' },
{ code: 'MR', name: 'Mauritanie', nationality: 'Mauritanienne' },
{ code: 'MX', name: 'Mexique', nationality: 'Mexicaine' },
{ code: 'MD', name: 'Moldavie', nationality: 'Moldave' },
{ code: 'MC', name: 'Monaco', nationality: 'Monégasque' },
{ code: 'ME', name: 'Monténégro', nationality: 'Monténégrine' },
{ code: 'MZ', name: 'Mozambique', nationality: 'Mozambicaine' },
{ code: 'NE', name: 'Niger', nationality: 'Nigérienne' },
{ code: 'NG', name: 'Nigeria', nationality: 'Nigériane' },
{ code: 'NO', name: 'Norvège', nationality: 'Norvégienne' },
{ code: 'NZ', name: 'Nouvelle-Zélande', nationality: 'Néo-zélandaise' },
{ code: 'PK', name: 'Pakistan', nationality: 'Pakistanaise' },
{ code: 'NL', name: 'Pays-Bas', nationality: 'Néerlandaise' },
{ code: 'PE', name: 'Pérou', nationality: 'Péruvienne' },
{ code: 'PH', name: 'Philippines', nationality: 'Philippine' },
{ code: 'PL', name: 'Pologne', nationality: 'Polonaise' },
{ code: 'PT', name: 'Portugal', nationality: 'Portugaise' },
{ code: 'RO', name: 'Roumanie', nationality: 'Roumaine' },
{ code: 'GB', name: 'Royaume-Uni', nationality: 'Britannique' },
{ code: 'RU', name: 'Russie', nationality: 'Russe' },
{ code: 'RW', name: 'Rwanda', nationality: 'Rwandaise' },
{ code: 'SN', name: 'Sénégal', nationality: 'Sénégalaise' },
{ code: 'RS', name: 'Serbie', nationality: 'Serbe' },
{ code: 'SG', name: 'Singapour', nationality: 'Singapourienne' },
{ code: 'SK', name: 'Slovaquie', nationality: 'Slovaque' },
{ code: 'SI', name: 'Slovénie', nationality: 'Slovène' },
{ code: 'SD', name: 'Soudan', nationality: 'Soudanaise' },
{ code: 'LK', name: 'Sri Lanka', nationality: 'Sri-lankaise' },
{ code: 'SE', name: 'Suède', nationality: 'Suédoise' },
{ code: 'CH', name: 'Suisse', nationality: 'Suisse' },
{ code: 'SY', name: 'Syrie', nationality: 'Syrienne' },
{ code: 'TW', name: 'Taïwan', nationality: 'Taïwanaise' },
{ code: 'TD', name: 'Tchad', nationality: 'Tchadienne' },
{ code: 'CZ', name: 'Tchéquie', nationality: 'Tchèque' },
{ code: 'TH', name: 'Thaïlande', nationality: 'Thaïlandaise' },
{ code: 'TG', name: 'Togo', nationality: 'Togolaise' },
{ code: 'TN', name: 'Tunisie', nationality: 'Tunisienne' },
{ code: 'TR', name: 'Turquie', nationality: 'Turque' },
{ code: 'UA', name: 'Ukraine', nationality: 'Ukrainienne' },
{ code: 'UY', name: 'Uruguay', nationality: 'Uruguayenne' },
{ code: 'VE', name: 'Venezuela', nationality: 'Vénézuélienne' },
{ code: 'VN', name: 'Viêt Nam', nationality: 'Vietnamienne' },
];
const BY_CODE = new Map(COUNTRIES.map((country) => [country.code, country]));
/**
* Options du sélecteur de pays : la France en tête, le reste par ordre
* alphabétique.
*
* Placer le pays le plus fréquent en premier évite un défilement à chaque
* saisie. L'ordre alphabétique ensuite, parce qu'un classement par fréquence
* supposée serait deviné, et se lirait comme un jugement.
*/
export const COUNTRY_OPTIONS: ReadonlyArray<readonly [string, string]> = [
['FR', 'France'],
...COUNTRIES.filter((country) => country.code !== 'FR')
.map((country) => [country.code, country.name] as const)
.sort((a, b) => a[1].localeCompare(b[1], 'fr')),
];
/** Mêmes pays, mais nommés par leur gentilé. */
export const NATIONALITY_OPTIONS: ReadonlyArray<readonly [string, string]> = [
['FR', 'Française'],
...COUNTRIES.filter((country) => country.code !== 'FR')
.map((country) => [country.code, country.nationality] as const)
.sort((a, b) => a[1].localeCompare(b[1], 'fr')),
];
export function countryLabel(code: string | null): string | null {
if (!code) return null;
// Les dossiers saisis avant la liste portent un libellé en clair : le rendre
// tel quel vaut mieux qu'afficher un vide sur une donnée qui existe.
return BY_CODE.get(code)?.name ?? code;
}
export function nationalityLabel(code: string | null): string | null {
if (!code) return null;
return BY_CODE.get(code)?.nationality ?? code;
}
export function isCountryCode(value: string): boolean {
return BY_CODE.has(value);
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Départements français — pour le lieu de naissance et le rattachement d'un
* établissement.
*
* Liste fermée plutôt que champ libre : « 54 », « Meurthe-et-Moselle »,
* « Meurthe et Moselle » et « meurthe-et-moselle » désignent le même
* département et se retrouvaient tous les quatre en base. Le registre unique du
* personnel les imprime tels quels, et un contrôle y lit quatre départements.
*
* Le code est stocké, pas le libellé : il tient dans deux caractères, ne change
* pas d'orthographe, et c'est lui que reprennent les déclarations sociales.
*
* La Corse porte `2A` et `2B` — jamais `20`, qui n'existe plus depuis 1976 mais
* survit dans beaucoup de saisies manuelles.
*/
export const DEPARTMENTS: ReadonlyArray<readonly [string, string]> = [
['01', 'Ain'],
['02', 'Aisne'],
['03', 'Allier'],
['04', 'Alpes-de-Haute-Provence'],
['05', 'Hautes-Alpes'],
['06', 'Alpes-Maritimes'],
['07', 'Ardèche'],
['08', 'Ardennes'],
['09', 'Ariège'],
['10', 'Aube'],
['11', 'Aude'],
['12', 'Aveyron'],
['13', 'Bouches-du-Rhône'],
['14', 'Calvados'],
['15', 'Cantal'],
['16', 'Charente'],
['17', 'Charente-Maritime'],
['18', 'Cher'],
['19', 'Corrèze'],
['2A', 'Corse-du-Sud'],
['2B', 'Haute-Corse'],
['21', "Côte-d'Or"],
['22', "Côtes-d'Armor"],
['23', 'Creuse'],
['24', 'Dordogne'],
['25', 'Doubs'],
['26', 'Drôme'],
['27', 'Eure'],
['28', 'Eure-et-Loir'],
['29', 'Finistère'],
['30', 'Gard'],
['31', 'Haute-Garonne'],
['32', 'Gers'],
['33', 'Gironde'],
['34', 'Hérault'],
['35', 'Ille-et-Vilaine'],
['36', 'Indre'],
['37', 'Indre-et-Loire'],
['38', 'Isère'],
['39', 'Jura'],
['40', 'Landes'],
['41', 'Loir-et-Cher'],
['42', 'Loire'],
['43', 'Haute-Loire'],
['44', 'Loire-Atlantique'],
['45', 'Loiret'],
['46', 'Lot'],
['47', 'Lot-et-Garonne'],
['48', 'Lozère'],
['49', 'Maine-et-Loire'],
['50', 'Manche'],
['51', 'Marne'],
['52', 'Haute-Marne'],
['53', 'Mayenne'],
['54', 'Meurthe-et-Moselle'],
['55', 'Meuse'],
['56', 'Morbihan'],
['57', 'Moselle'],
['58', 'Nièvre'],
['59', 'Nord'],
['60', 'Oise'],
['61', 'Orne'],
['62', 'Pas-de-Calais'],
['63', 'Puy-de-Dôme'],
['64', 'Pyrénées-Atlantiques'],
['65', 'Hautes-Pyrénées'],
['66', 'Pyrénées-Orientales'],
['67', 'Bas-Rhin'],
['68', 'Haut-Rhin'],
['69', 'Rhône'],
['70', 'Haute-Saône'],
['71', 'Saône-et-Loire'],
['72', 'Sarthe'],
['73', 'Savoie'],
['74', 'Haute-Savoie'],
['75', 'Paris'],
['76', 'Seine-Maritime'],
['77', 'Seine-et-Marne'],
['78', 'Yvelines'],
['79', 'Deux-Sèvres'],
['80', 'Somme'],
['81', 'Tarn'],
['82', 'Tarn-et-Garonne'],
['83', 'Var'],
['84', 'Vaucluse'],
['85', 'Vendée'],
['86', 'Vienne'],
['87', 'Haute-Vienne'],
['88', 'Vosges'],
['89', 'Yonne'],
['90', 'Territoire de Belfort'],
['91', 'Essonne'],
['92', 'Hauts-de-Seine'],
['93', 'Seine-Saint-Denis'],
['94', 'Val-de-Marne'],
['95', "Val-d'Oise"],
['971', 'Guadeloupe'],
['972', 'Martinique'],
['973', 'Guyane'],
['974', 'La Réunion'],
['976', 'Mayotte'],
];
const LABELS = new Map(DEPARTMENTS);
export const DEPARTMENT_CODES: readonly string[] = DEPARTMENTS.map(
([code]) => code,
);
export function departmentLabel(code: string | null): string | null {
if (!code) return null;
const label = LABELS.get(code);
return label ? `${code} — ${label}` : code;
}
export function isDepartmentCode(value: string): boolean {
return LABELS.has(value);
}
/**
* Départements du régime local d'Alsace-Moselle.
*
* Deux jours fériés de plus — Vendredi saint et 26 décembre — et un régime de
* maintien de salaire distinct. Un établissement de Moselle ne se planifie donc
* pas comme un établissement de Meurthe-et-Moselle, à quinze kilomètres de là.
*/
export const ALSACE_MOSELLE = ['57', '67', '68'] as const;
export function isAlsaceMoselle(code: string | null): boolean {
return code !== null && (ALSACE_MOSELLE as readonly string[]).includes(code);
}
+23
View File
@@ -12,13 +12,26 @@ import { shiftMinutes } from '@/domain/counters/week';
import { zonedClock, zonedDate } from '@/domain/planning/week';
import type { PosteCode } from '@/lib/design/postes';
/** Pause telle qu'affichée : le détail suffit à rouvrir le formulaire. */
export interface ShiftBreakView {
startMinutes: number | null;
durationMinutes: number;
isPaid: boolean;
label: string | null;
}
export interface BoardShiftInput {
id: string;
membershipId: string | null;
startAt: Date;
endAt: Date;
breakMinutes: number;
paidBreakMinutes: number;
breaks: ShiftBreakView[];
mealCount: number;
poste: PosteCode;
/** Étiquette telle qu'enregistrée, pour rouvrir le créneau sur la sienne. */
labelId: string | null;
isValidated: boolean;
note: string | null;
}
@@ -29,7 +42,13 @@ export interface BoardShift {
/** « 09:00–17:00 », en heure locale de l'établissement. */
time: string;
minutes: number;
/** Pauses non rémunérées, déduites du temps travaillé. */
breakMinutes: number;
/** Pauses rémunérées : comptées comme repos, pas déduites. */
paidBreakMinutes: number;
breaks: ShiftBreakView[];
mealCount: number;
labelId: string | null;
state: ShiftState;
note: string | null;
}
@@ -139,6 +158,10 @@ export function buildRows(
time: `${zonedClock(shift.startAt, timeZone)}–${zonedClock(shift.endAt, timeZone)}`,
minutes,
breakMinutes: shift.breakMinutes,
paidBreakMinutes: shift.paidBreakMinutes,
breaks: shift.breaks,
mealCount: shift.mealCount,
labelId: shift.labelId,
state: shiftState(shift.isValidated, isPublished, shift.membershipId),
note: shift.note,
};
+161
View File
@@ -0,0 +1,161 @@
/**
* Pauses d'un créneau — PLAN.md §4.4.
*
* Un créneau porte une **liste** de pauses, pas un total. Deux pauses de vingt
* minutes et une coupure de deux heures ne se planifient pas de la même façon
* et ne se contrôlent pas pareil : la première satisfait la pause minimale au
* delà de six heures, la seconde interroge l'amplitude de la journée.
*
* Le créneau conserve deux totaux dérivés, et c'est délibéré :
*
* - `breakMinutes` — les pauses **non rémunérées**, déduites du temps de
* travail. C'est ce que lisent la paie, les compteurs et l'export, sans
* changer d'un caractère.
* - `paidBreakMinutes` — les pauses **rémunérées**. Non déduites, mais bien des
* pauses : la règle de pause minimale regarde la somme des deux, sans quoi une
* pause payée passerait pour une absence de pause et alerterait à tort.
*
* Les totaux ne sont pas une optimisation : ils sont la forme sous laquelle le
* reste du produit lit déjà l'information. Les recalculer à chaque écriture
* coûte moins qu'un second chemin de lecture qui finirait par diverger.
*/
export interface BreakInput {
/** Minutes depuis le début du créneau. `null` = pause non située. */
startMinutes: number | null;
durationMinutes: number;
isPaid: boolean;
label: string | null;
}
export interface BreakTotals {
/** Déduit du temps de travail. */
breakMinutes: number;
/** Compté comme repos, pas déduit. */
paidBreakMinutes: number;
}
export class BreakError extends Error {}
const MAX_BREAKS = 6;
const MAX_DURATION = 600;
/**
* Valide et ordonne une liste de pauses.
*
* Refuse plutôt que de corriger. Une pause de durée nulle vient d'une ligne
* ajoutée puis laissée vide ; l'écarter en silence donnerait un enregistrement
* réussi et une pause disparue, ce qui est le pire des deux mondes — sauf pour
* une ligne **entièrement** vide, qui n'est pas une saisie mais un formulaire
* en cours.
*/
export function normaliseBreaks(
raw: ReadonlyArray<Partial<BreakInput>>,
shiftMinutes: number,
): BreakInput[] {
const entered = raw.filter(
(entry) =>
entry.durationMinutes !== undefined &&
entry.durationMinutes !== null &&
!Number.isNaN(entry.durationMinutes),
);
if (entered.length > MAX_BREAKS) {
throw new BreakError(
`Six pauses au plus par créneau ; ${entered.length} ont été saisies.`,
);
}
const breaks = entered.map((entry, index) => {
const duration = Number(entry.durationMinutes);
if (!Number.isInteger(duration) || duration <= 0) {
throw new BreakError(
`Pause ${index + 1} : la durée doit être un nombre de minutes positif.`,
);
}
if (duration > MAX_DURATION) {
throw new BreakError(
`Pause ${index + 1} : ${duration} min dépasse le maximum de ${MAX_DURATION} min.`,
);
}
const start =
entry.startMinutes === null || entry.startMinutes === undefined
? null
: Number(entry.startMinutes);
if (start !== null) {
if (!Number.isInteger(start) || start < 0) {
throw new BreakError(
`Pause ${index + 1} : le début doit être un nombre de minutes positif.`,
);
}
// Une pause qui déborde du créneau n'est pas une pause : c'est une
// saisie fausse, et la laisser passer fausserait l'amplitude.
if (start + duration > shiftMinutes) {
throw new BreakError(
`Pause ${index + 1} : elle sort du créneau.`,
);
}
}
return {
startMinutes: start,
durationMinutes: duration,
isPaid: Boolean(entry.isPaid),
label: entry.label?.trim() ? entry.label.trim() : null,
} satisfies BreakInput;
});
assertNoOverlap(breaks);
// Les pauses situées d'abord, dans l'ordre ; les non situées ensuite. C'est
// l'ordre de lecture d'une journée.
return [...breaks].sort((a, b) => {
if (a.startMinutes === null) return b.startMinutes === null ? 0 : 1;
if (b.startMinutes === null) return -1;
return a.startMinutes - b.startMinutes;
});
}
/**
* Deux pauses situées ne peuvent pas se chevaucher.
*
* Elles compteraient deux fois dans le total déduit, et le salarié se verrait
* retirer un temps qu'il n'a pris qu'une fois.
*/
function assertNoOverlap(breaks: BreakInput[]): void {
const placed = breaks
.filter((entry) => entry.startMinutes !== null)
.sort((a, b) => (a.startMinutes as number) - (b.startMinutes as number));
for (let index = 1; index < placed.length; index += 1) {
const previous = placed[index - 1] as BreakInput;
const current = placed[index] as BreakInput;
const previousEnd =
(previous.startMinutes as number) + previous.durationMinutes;
if ((current.startMinutes as number) < previousEnd) {
throw new BreakError(
'Deux pauses se chevauchent : le temps serait déduit deux fois.',
);
}
}
}
export function breakTotals(breaks: ReadonlyArray<BreakInput>): BreakTotals {
return breaks.reduce<BreakTotals>(
(totals, entry) => ({
breakMinutes:
totals.breakMinutes + (entry.isPaid ? 0 : entry.durationMinutes),
paidBreakMinutes:
totals.paidBreakMinutes + (entry.isPaid ? entry.durationMinutes : 0),
}),
{ breakMinutes: 0, paidBreakMinutes: 0 },
);
}
/** Durée totale de repos, rémunérée ou non — ce que regarde la règle de pause. */
export function totalRestMinutes(totals: BreakTotals): number {
return totals.breakMinutes + totals.paidBreakMinutes;
}
+4
View File
@@ -67,6 +67,7 @@ interface LoadedShift {
startAt: Date;
endAt: Date;
breakMinutes: number;
paidBreakMinutes: number;
}
/**
@@ -262,6 +263,7 @@ async function buildContexts(
startAt: true,
endAt: true,
breakMinutes: true,
paidBreakMinutes: true,
},
});
@@ -277,6 +279,7 @@ async function buildContexts(
startAt: true,
endAt: true,
breakMinutes: true,
paidBreakMinutes: true,
},
});
@@ -321,6 +324,7 @@ async function buildContexts(
startAt: shift.startAt,
endAt: shift.endAt,
breakMinutes: shift.breakMinutes,
paidBreakMinutes: shift.paidBreakMinutes,
assigned: shift.membershipId !== null,
}));
+1
View File
@@ -37,6 +37,7 @@ export type EmailKind =
| 'INVITATION'
| 'PASSWORD_RESET'
| 'PLANNING_PUBLISHED'
| 'SHIFT_ASSIGNED'
| 'TIMEOFF_DECISION'
| 'LEAVE_NOTICE';
+4 -4
View File
@@ -38,7 +38,6 @@ const employeeInput = z.object({
email: z.string().trim().email('Adresse invalide').or(z.literal('')),
phone: z.string().trim().max(30),
landline: z.string().trim().max(30),
smsSchedules: z.boolean(),
});
/**
@@ -50,7 +49,10 @@ const employeeInput = z.object({
*/
const hiringContractInput = z.object({
locationId: z.string().min(1, 'Établissement requis'),
teamId: z.string().trim(),
// Requise avec un contrat : le planning s'ordonne par équipe, et un salarié
// sans rattachement n'apparaît sur aucune grille. Le formulaire l'impose déjà,
// mais le contrôle du navigateur ne protège pas une action serveur.
teamId: z.string().trim().min(1, 'Choisissez une équipe.'),
contractType: z.enum(CONTRACT_TYPES),
startDate: z.coerce.date(),
/** Heure de prise de poste : la DPAE la demande, la date seule n'y suffit pas. */
@@ -92,7 +94,6 @@ export async function createEmployeeAction(
email: formData.get('email') ?? '',
phone: formData.get('phone') ?? '',
landline: formData.get('landline') ?? '',
smsSchedules: formData.get('smsSchedules') === 'on',
});
if (!parsed.success) {
@@ -225,7 +226,6 @@ export async function createEmployeeAction(
personalEmail: parsed.data.email || null,
phone: parsed.data.phone || null,
landline: parsed.data.landline || null,
smsSchedules: parsed.data.smsSchedules,
} as never,
});
+13 -8
View File
@@ -4,6 +4,8 @@ import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError, can } from '@/domain/access/authorize';
import { COUNTRIES } from '@/domain/legal/countries';
import { DEPARTMENT_CODES } from '@/domain/legal/departments';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
import { encryptOptional } from '@/server/crypto';
@@ -44,6 +46,8 @@ const optionalEnum = <T extends readonly string[]>(values: T) =>
)
.transform((value) => (value === '' ? null : value));
const COUNTRY_CODES = COUNTRIES.map((country) => country.code);
const profileInput = z.object({
membershipId: z.string().min(1),
gender: optionalEnum(['FEMALE', 'MALE', 'UNSPECIFIED'] as const),
@@ -52,9 +56,14 @@ const profileInput = z.object({
lastName: z.string().trim().min(1, 'Nom requis').max(80),
birthDate: optionalText(10),
birthPlace: optionalText(120),
birthCountry: optionalText(80),
birthDepartment: optionalText(80),
nationality: optionalText(80),
// Codes de liste fermée, pas des libellés. Un dossier saisi avant la liste
// porte encore « France » en clair : le sélecteur ne trouve pas d'option
// correspondante, affiche « non renseigné », et la première correction du
// dossier remplace la valeur par son code. Elle n'est ni perdue en silence
// ni opposée à qui vient corriger un champ voisin.
birthCountry: optionalEnum(COUNTRY_CODES),
birthDepartment: optionalEnum(DEPARTMENT_CODES),
nationality: optionalEnum(COUNTRY_CODES),
maritalStatus: optionalEnum([
'SINGLE',
'MARRIED',
@@ -82,12 +91,11 @@ const profileInput = z.object({
.transform((value) => (value === '' ? null : value)),
phone: optionalText(30),
landline: optionalText(30),
smsSchedules: z.boolean(),
addressLine1: optionalText(180),
addressLine2: optionalText(180),
postalCode: optionalText(12),
city: optionalText(120),
country: optionalText(80),
country: optionalEnum(COUNTRY_CODES),
emergencyContactName: optionalText(120),
emergencyContactPhone: optionalText(30),
});
@@ -123,9 +131,6 @@ export async function updateProfileAction(
'emergencyContactPhone',
].map((key) => [key, formData.get(key) ?? '']),
),
// Une case décochée n'est pas envoyée : son absence vaut « non », et la
// lire comme une chaîne vide en ferait une valeur inconnue.
smsSchedules: formData.get('smsSchedules') === 'on',
});
if (!parsed.success) {
-2
View File
@@ -285,7 +285,6 @@ export interface EmployeeProfileDetail {
country: string | null;
phone: string | null;
landline: string | null;
smsSchedules: boolean;
personalEmail: string | null;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
@@ -487,7 +486,6 @@ export const getEmployee = cache(async function getEmployee(
country: membership.profile.country,
phone: membership.profile.phone,
landline: membership.profile.landline,
smsSchedules: membership.profile.smsSchedules,
personalEmail: membership.profile.personalEmail,
emergencyContactName: membership.profile.emergencyContactName,
emergencyContactPhone: membership.profile.emergencyContactPhone,
+8 -2
View File
@@ -2,6 +2,8 @@ import 'server-only';
import { cache } from 'react';
import { nationalityLabel } from '@/domain/legal/countries';
import { genderShort } from '@/domain/hr/civil-status';
import type { RupPerson } from '@/domain/legal/rup';
import { peopleWithGaps } from '@/domain/legal/rup';
@@ -82,7 +84,9 @@ export const getRegisterData = cache(async function getRegisterData(
'',
firstName: contract.membership.profile?.firstName ?? '',
sex: genderShort(contract.membership.profile?.gender ?? null),
nationality: contract.membership.profile?.nationality ?? null,
nationality: nationalityLabel(
contract.membership.profile?.nationality ?? null,
),
birthDate: contract.membership.profile?.birthDate ?? null,
jobTitle: contract.jobTitleId
? (jobTitleNames.get(contract.jobTitleId) ?? null)
@@ -152,7 +156,9 @@ export const listRegisterLocations = cache(
'',
firstName: contract.membership.profile?.firstName ?? '',
sex: genderShort(contract.membership.profile?.gender ?? null),
nationality: contract.membership.profile?.nationality ?? null,
nationality: nationalityLabel(
contract.membership.profile?.nationality ?? null,
),
birthDate: contract.membership.profile?.birthDate ?? null,
jobTitle: contract.jobTitleId ? 'x' : null,
qualification: contract.classification,
+24 -9
View File
@@ -13,19 +13,21 @@ import {
type InstallationForm,
} from '@/domain/install/rules';
import { hashPassword } from '@/server/auth/session';
import { installReferentials } from '@/server/install/referentials';
/**
* Contenu d'une instance neuve — PLAN.md §5.
*
* Le seed, lui, installe une démonstration : deux établissements, des salariés
* fictifs, quatre semaines de planning. Il refuse de tourner en production, et
* c'est bien ainsi. Il restait donc un trou : après les migrations, une
* instance de production a le schéma et rien d'autre — pas de compte, pas
* d'utilisateur, personne pour se connecter.
* Le seed, lui, installe une démonstration : des salariés fictifs, des
* plannings, des absences. Il refuse de tourner en production, et c'est bien
* ainsi. Il restait donc un trou : après les migrations, une instance de
* production a le schéma et rien d'autre — pas de compte, pas d'utilisateur,
* personne pour se connecter.
*
* Ce module comble ce trou, et **rien de plus** : le catalogue des capacités,
* les rôles fournis, un compte, un établissement, un propriétaire. Aucune
* donnée d'exemple.
* Ce module comble ce trou : catalogue des capacités, rôles fournis, compte,
* établissement, propriétaire, puis les **référentiels** sans lesquels rien ne
* s'accroche (voir `referentials.ts`). Aucune donnée d'exemple : pas un
* salarié, pas un créneau, pas une absence.
*/
export interface InstalledInstance {
@@ -105,7 +107,7 @@ export async function installAccount(
});
}
await tx.location.create({
const location = await tx.location.create({
data: {
accountId: account.id,
name: data.locationName,
@@ -113,6 +115,19 @@ export async function installAccount(
},
});
// Une première équipe. Le planning s'ordonne par équipe : sans elle,
// l'établissement neuf n'a aucune ligne où poser un créneau, et l'écran de
// planning annonce « pas encore d'équipe » à qui vient de tout installer.
await tx.team.create({
data: { accountId: account.id, locationId: location.id, name: 'Équipe' },
});
// Étiquettes, types d'absence, convention d'amorce, conservation et jours
// fériés. Ce ne sont pas des exemples : sans eux, l'instance a le schéma et
// rien à quoi l'accrocher — pas de demande d'absence saisissable, pas de
// créneau nommé, et un moteur de règles muet.
await installReferentials(tx, account.id, location.id);
const user = await tx.user.create({
data: {
email: data.email,
+219
View File
@@ -0,0 +1,219 @@
import type { Prisma } from '@prisma/client';
import { frenchHolidays } from '@/domain/absences/holidays';
import { IDCC_1517_PARAMETERS, IDCC_1517_PROVENANCE } from '@/domain/compliance/idcc1517';
import { POSTE_CODES, POSTE_LABELS } from '@/lib/design/postes';
/**
* Référentiels d'une instance neuve — PLAN.md §5 et WP-02.
*
* Sans eux, une instance installée a le schéma et rien à quoi l'accrocher :
* aucun type d'absence, donc aucune demande saisissable ; aucune étiquette,
* donc aucun créneau nommé ; aucune convention, donc un moteur de règles muet
* qui laisse passer une semaine de 60 heures sans rien dire. Le trou se
* comblait jusqu'ici avec le seed de démonstration, qui refuse de tourner en
* production — l'instance de production restait donc inutilisable.
*
* **Ce ne sont pas des données d'exemple.** Chaque ligne posée ici est soit un
* minimum légal, soit une valeur d'amorce que le client remplace depuis les
* réglages. Rien de fictif : ni salarié, ni planning, ni absence.
*/
const AMORCE_DATE = new Date('2026-01-01');
/** Politiques de conservation — durées et justifications de §12.5. */
const RETENTION: ReadonlyArray<
readonly [string, number, string, string]
> = [
['Shift', 12, 'creation', 'Décompte des horaires : 1 an minimum (matrice n° 21).'],
['ForfaitDayEntry', 36, 'creation', 'Décompte des jours de forfait : 3 ans minimum.'],
['UserContract', 60, 'contract_end', 'Pièces contractuelles : 5 ans.'],
[
'PersonnelRegister',
60,
'employee_departure',
'Registre du personnel : 5 ans après le départ.',
],
[
'PayrollVariable',
72,
'period_end',
"Éléments d'assiette transmis au logiciel de paie : 6 ans.",
],
];
/**
* 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 = [
// --- 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(
tx: Prisma.TransactionClient,
accountId: string,
locationId: string,
): Promise<void> {
// --- Étiquettes de planning --------------------------------------------
// La palette est celle du produit, calculée pour rester distinguable en
// vision daltonienne. Les libellés se renomment depuis les réglages.
for (const [index, code] of POSTE_CODES.entries()) {
await tx.label.create({
data: {
accountId,
code: code.toUpperCase(),
name: POSTE_LABELS[code],
paletteKey: code,
position: index,
},
});
}
// --- Types d'absence ----------------------------------------------------
for (const type of ABSENCE_TYPES) {
await tx.absenceType.create({
data: {
accountId,
code: type.code,
name: type.name,
colorKey: type.colorKey,
isPaid: type.isPaid,
countsAsWorkTime: false,
affectsPaidLeaveAccrual: type.accrual,
isSocialSecurity: type.social,
requiresJustification: type.justify,
minNoticeDays: type.notice,
},
});
}
// --- Convention d'amorce ------------------------------------------------
// IDCC 1517 par défaut (PLAN.md §2), remplaçable depuis les réglages : la
// convention se réédite en version datée, elle ne se modifie pas.
await tx.collectiveAgreement.create({
data: {
accountId,
idcc: '1517',
name: 'Commerces de détail non alimentaires',
parameters: IDCC_1517_PARAMETERS as never,
version: 1,
effectiveFrom: AMORCE_DATE,
source:
'Sources secondaires publiques — À VALIDER contre le texte consolidé Legifrance',
},
});
// --- Registre de paramétrage juridique ----------------------------------
// L'origine de chaque valeur — ordre public, convention, accord d'entreprise
// — décide de ce qui s'impose et de ce qui se négocie. Elle est enregistrée
// avec sa source, pas seulement commentée dans le code (§12.7).
for (const entry of IDCC_1517_PROVENANCE) {
await tx.legalConfigEntry.create({
data: {
accountId,
domain: 'temps',
key: entry.key,
value: `${entry.label} : ${entry.value}`,
source: `[${entry.origin}] ${entry.source}`,
population: 'Tous salariés',
effectiveFrom: AMORCE_DATE,
},
});
}
// --- Durées de conservation ---------------------------------------------
for (const [objectType, durationMonths, startPoint, justification] of RETENTION) {
await tx.retentionPolicy.create({
data: {
accountId,
objectType,
durationMonths,
startPoint,
justification,
effectiveFrom: AMORCE_DATE,
},
});
}
// --- Jours fériés --------------------------------------------------------
// Calculés, pas listés : une table écrite à la main n'est juste que l'année
// où on l'écrit. Un férié manquant se décompte comme un jour de congé, et le
// salarié perd un jour sans que personne ne le voie.
//
// Seul le 1er mai est chômé de droit. Les jours garantis par la convention
// sont choisis par l'employeur : ils ne se devinent pas ici.
const currentYear = new Date().getUTCFullYear();
for (const year of [currentYear, currentYear + 1]) {
for (const holiday of frenchHolidays(year)) {
await tx.holiday.create({
data: {
accountId,
locationId,
localDate: new Date(`${holiday.isoDate}T00:00:00Z`),
name: holiday.name,
isPaidOff: holiday.isoDate.slice(5) === '05-01',
},
});
}
}
// Aucun dimanche du maire n'est posé : la liste est arrêtée par arrêté
// municipal, établissement par établissement. En inventer rendrait opposable
// un quota qui n'a été autorisé nulle part.
}
+246 -37
View File
@@ -17,7 +17,15 @@ import {
zonedDate,
zonedInstant,
} from '@/domain/planning/week';
import {
breakTotals,
normaliseBreaks,
BreakError,
type BreakInput,
} from '@/domain/planning/breaks';
import { shiftAssignedMessage } from '@/domain/email/message';
import { recordAudit } from '@/server/audit';
import { sendEmail } from '@/server/email/mailer';
import { evaluateAround, evaluateSchedule } from '@/server/compliance/evaluate';
import { mutate } from '@/server/context';
import { assertPeriodOpen, PeriodLockedError } from '@/server/payroll/periods';
@@ -46,18 +54,58 @@ class ValidationError extends Error {}
const HOUR = /^([01]\d|2[0-3]):([0-5]\d)$/;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const shiftInput = z.object({
teamId: z.string().min(1),
week: z.string().min(1),
membershipId: z.string().optional(),
localDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date invalide'),
/**
* Jours à créer, un créneau par jour.
*
* Un même horaire se répète d'un jour à l'autre bien plus souvent qu'il ne
* varie : ressaisir cinq fois « 9 h–17 h, pause 30 » est le geste le plus
* répété de la semaine, et le plus exposé à la faute de frappe.
*/
localDates: z
.array(z.string().regex(ISO_DATE, 'Date invalide'))
.min(1, 'Sélectionnez au moins un jour')
.max(7),
start: z.string().regex(HOUR, 'Heure de début invalide'),
end: z.string().regex(HOUR, 'Heure de fin invalide'),
breakMinutes: z.coerce.number().int().min(0).max(600).default(0),
mealCount: z.coerce.number().int().min(0).max(5).default(0),
labelId: z.string().optional(),
note: z.string().trim().max(500).optional(),
notify: z.boolean().default(false),
});
/**
* Pauses saisies dans le formulaire.
*
* Trois champs répétés, lus par position : la ligne *n* est faite du n-ième
* `breakDuration`, du n-ième `breakStart` et de la n-ième case `breakPaid`. Une
* case non cochée n'étant pas envoyée, la valeur de `breakPaid` porte l'index
* de sa ligne plutôt qu'un simple « on ».
*/
function readBreaks(formData: FormData): Array<Partial<BreakInput>> {
const durations = formData.getAll('breakDuration').map(String);
const starts = formData.getAll('breakStart').map(String);
const labels = formData.getAll('breakLabel').map(String);
const paid = new Set(formData.getAll('breakPaid').map(String));
return durations.map((duration, index) => {
if (duration.trim() === '') return {};
const start = starts[index]?.trim();
return {
durationMinutes: Number(duration),
startMinutes: start ? Number(start) : null,
isPaid: paid.has(String(index)),
label: labels[index]?.trim() || null,
};
});
}
async function loadTeamContext(db: ScopedClient, teamId: string) {
const team = await db.team.findUnique({
where: { id: teamId },
@@ -99,22 +147,34 @@ export async function createShiftAction(
_previous: PlanningActionState,
formData: FormData,
): Promise<PlanningActionState> {
// Les cases de répétition portent le même nom : la case du jour ouvert est
// cochée d'office, si bien qu'un envoi sans répétition renvoie exactement une
// date. Le repli sur `localDate` couvre le formulaire d'un client qui aurait
// désactivé JavaScript.
const checked = formData.getAll('localDates').map(String).filter(Boolean);
const single = String(formData.get('localDate') ?? '');
const localDates = checked.length > 0 ? [...new Set(checked)] : single ? [single] : [];
const parsed = shiftInput.safeParse({
teamId: formData.get('teamId'),
week: formData.get('week'),
membershipId: formData.get('membershipId') || undefined,
localDate: formData.get('localDate'),
localDates,
start: formData.get('start'),
end: formData.get('end'),
breakMinutes: formData.get('breakMinutes') || 0,
mealCount: formData.get('mealCount') || 0,
labelId: formData.get('labelId') || undefined,
note: formData.get('note') || undefined,
notify: formData.get('notify') === 'on',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const rawBreaks = readBreaks(formData);
const week = parseWeekParam(parsed.data.week);
if (!week) return { error: 'Semaine invalide.' };
@@ -122,37 +182,11 @@ export async function createShiftAction(
await mutate('planning.create', async (db, actor) => {
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
const startAt = zonedInstant(
parsed.data.localDate,
parsed.data.start,
location.timezone,
);
let endAt = zonedInstant(
parsed.data.localDate,
parsed.data.end,
location.timezone,
);
// Fin avant début = créneau de nuit : il finit le lendemain. Le refuser
// interdirait de planifier un inventaire 22 h–02 h.
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
// Un mois transmis au cabinet ne se modifie pas par inadvertance : le
// contrôle passe **avant** l'écriture.
await assertPeriodOpen(db, location.id, [parsed.data.localDate]);
const worked = shiftMinutes(startAt, endAt, parsed.data.breakMinutes);
if (worked <= 0) {
throw new ValidationError(
'La pause dépasse la durée du créneau — rien ne serait travaillé.',
);
}
const { from, to } = weekBounds(week, location.timezone);
if (startAt < from || startAt >= to) {
throw new ValidationError(
"Ce créneau ne commence pas dans la semaine affichée.",
);
}
// contrôle passe **avant** toute écriture, et porte sur tous les jours
// demandés — répéter sur cinq jours ne doit pas en glisser un dans une
// période close.
await assertPeriodOpen(db, location.id, parsed.data.localDates);
const schedule = await ensureSchedule(
db,
@@ -166,23 +200,104 @@ export async function createShiftAction(
assertMayEditPublished(actor);
}
const { from, to } = weekBounds(week, location.timezone);
// Les envois sont **différés à la fin de la transaction**. Écrire un
// courriel au fil des créneaux enverrait des avis pour des jours qu'un
// refus sur le dernier jour finirait par annuler — un salarié prévenu
// d'un créneau qui n'existe pas.
const notifications: Array<{
membershipId: string;
localDate: string;
start: string;
end: string;
}> = [];
// La répétition est **tout ou rien**. Un jour refusé annule la
// transaction entière : une répétition à demi appliquée laisserait un
// planning que personne n'a voulu, et qu'il faudrait défaire à la main
// pour le refaire.
for (const localDate of parsed.data.localDates) {
const startAt = zonedInstant(
localDate,
parsed.data.start,
location.timezone,
);
let endAt = zonedInstant(localDate, parsed.data.end, location.timezone);
// Fin avant début = créneau de nuit : il finit le lendemain. Le refuser
// interdirait de planifier un inventaire 22 h–02 h.
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
// Les pauses détaillées font foi quand il y en a ; le champ « total »
// reste le repli du formulaire réduit. Deux sources pour la même
// valeur, mais une seule gagne, et elle est explicite.
const span = Math.round((endAt.getTime() - startAt.getTime()) / 60_000);
const breaks = normaliseBreaks(rawBreaks, span);
const totals =
breaks.length > 0
? breakTotals(breaks)
: { breakMinutes: parsed.data.breakMinutes, paidBreakMinutes: 0 };
const worked = shiftMinutes(startAt, endAt, totals.breakMinutes);
if (worked <= 0) {
throw new ValidationError(
'La pause dépasse la durée du créneau — rien ne serait travaillé.',
);
}
if (startAt < from || startAt >= to) {
throw new ValidationError(
'Un des jours demandés ne commence pas dans la semaine affichée.',
);
}
if (parsed.data.membershipId) {
await assertNoOverlap(db, parsed.data.membershipId, startAt, endAt, null);
await assertNoOverlap(
db,
parsed.data.membershipId,
startAt,
endAt,
null,
);
}
const created = await db.shift.create({
data: {
weeklyScheduleId: schedule.id,
membershipId: parsed.data.membershipId ?? null,
localDate: new Date(`${parsed.data.localDate}T00:00:00Z`),
localDate: new Date(`${localDate}T00:00:00Z`),
startAt,
endAt,
breakMinutes: parsed.data.breakMinutes,
breakMinutes: totals.breakMinutes,
paidBreakMinutes: totals.paidBreakMinutes,
mealCount: parsed.data.mealCount,
labelId: parsed.data.labelId ?? null,
note: parsed.data.note ?? null,
} as never,
});
for (const [position, entry] of breaks.entries()) {
await db.shiftBreak.create({
data: {
shiftId: created.id,
startMinutes: entry.startMinutes,
durationMinutes: entry.durationMinutes,
isPaid: entry.isPaid,
label: entry.label,
position,
} as never,
});
}
if (parsed.data.notify && created.membershipId) {
notifications.push({
membershipId: created.membershipId,
localDate,
start: parsed.data.start,
end: parsed.data.end,
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'planning.shift.create',
@@ -194,10 +309,18 @@ export async function createShiftAction(
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
minutes: worked,
mealCount: created.mealCount,
},
});
}
await assertNoBlocking(db, schedule.id);
// Après le dernier contrôle : un créneau bloquant annule la transaction,
// et l'avis ne doit pas partir pour un planning qui n'a pas été écrit.
for (const notice of notifications) {
await notifyShiftAssigned(db, notice, team.name);
}
});
} catch (error) {
return toState(error, 'Vous ne pouvez pas créer de créneau ici.');
@@ -207,6 +330,50 @@ export async function createShiftAction(
return { ok: true };
}
const dayFormat = new Intl.DateTimeFormat('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
timeZone: 'UTC',
});
/**
* Prévient un salarié qu'un créneau lui a été posé.
*
* L'échec n'interrompt pas : un serveur SMTP indisponible ne doit pas empêcher
* de planifier. `sendEmail` journalise l'échec, et le journal d'envois répond à
* « je n'ai rien reçu » mieux qu'une transaction annulée.
*
* Un salarié sans compte n'a pas d'adresse : il est prévenu par le planning
* affiché, comme aujourd'hui.
*/
async function notifyShiftAssigned(
db: ScopedClient,
notice: { membershipId: string; localDate: string; start: string; end: string },
teamName: string,
): Promise<void> {
const membership = await db.membership.findUnique({
where: { id: notice.membershipId },
include: { user: { select: { email: true, firstName: true } } },
});
if (!membership?.user?.email) return;
const day = dayFormat.format(new Date(`${notice.localDate}T00:00:00Z`));
await sendEmail(
db,
shiftAssignedMessage(membership.user.email, {
firstName: membership.user.firstName,
day,
start: notice.start,
end: notice.end,
teamName,
}),
'SHIFT_ASSIGNED',
membership.id,
);
}
const moveInput = z.object({
shiftId: z.string().min(1),
membershipId: z.string().optional(),
@@ -214,6 +381,12 @@ const moveInput = z.object({
start: z.string().regex(HOUR, 'Heure de début invalide'),
end: z.string().regex(HOUR, 'Heure de fin invalide'),
breakMinutes: z.coerce.number().int().min(0).max(600).default(0),
mealCount: z.coerce.number().int().min(0).max(5).default(0),
// Modifiables à la correction comme à la création : une étiquette posée de
// travers se corrigeait jusqu'ici en supprimant le créneau pour le refaire,
// ce qui perdait sa validation et son historique.
labelId: z.string().optional(),
note: z.string().trim().max(500).optional(),
});
export async function updateShiftAction(
@@ -227,12 +400,17 @@ export async function updateShiftAction(
start: formData.get('start'),
end: formData.get('end'),
breakMinutes: formData.get('breakMinutes') || 0,
mealCount: formData.get('mealCount') || 0,
labelId: formData.get('labelId') || undefined,
note: formData.get('note') || undefined,
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const rawBreaks = readBreaks(formData);
try {
await mutate('planning.edit', async (db, actor) => {
const shift = await db.shift.findUnique({
@@ -268,7 +446,14 @@ export async function updateShiftAction(
);
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
if (shiftMinutes(startAt, endAt, parsed.data.breakMinutes) <= 0) {
const span = Math.round((endAt.getTime() - startAt.getTime()) / 60_000);
const breaks = normaliseBreaks(rawBreaks, span);
const totals =
breaks.length > 0
? breakTotals(breaks)
: { breakMinutes: parsed.data.breakMinutes, paidBreakMinutes: 0 };
if (shiftMinutes(startAt, endAt, totals.breakMinutes) <= 0) {
throw new ValidationError(
'La pause dépasse la durée du créneau — rien ne serait travaillé.',
);
@@ -286,11 +471,32 @@ export async function updateShiftAction(
localDate: new Date(`${parsed.data.localDate}T00:00:00Z`),
startAt,
endAt,
breakMinutes: parsed.data.breakMinutes,
breakMinutes: totals.breakMinutes,
paidBreakMinutes: totals.paidBreakMinutes,
mealCount: parsed.data.mealCount,
labelId: parsed.data.labelId ?? null,
note: parsed.data.note ?? null,
version: { increment: 1 },
},
});
// Les pauses sont **remplacées**, pas fusionnées. Le formulaire porte
// l'état voulu au complet ; rapprocher ligne à ligne inventerait une
// identité que la saisie n'a pas, et ferait survivre une pause retirée.
await db.shiftBreak.deleteMany({ where: { shiftId: shift.id } });
for (const [position, entry] of breaks.entries()) {
await db.shiftBreak.create({
data: {
shiftId: shift.id,
startMinutes: entry.startMinutes,
durationMinutes: entry.durationMinutes,
isPaid: entry.isPaid,
label: entry.label,
position,
} as never,
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'planning.shift.update',
@@ -782,6 +988,9 @@ function assertMayEditPublished(actor: Actor): void {
function toState(error: unknown, denied: string): PlanningActionState {
if (error instanceof ValidationError) return { error: error.message };
// Une pause mal saisie se corrige dans le formulaire : le message dit
// laquelle et pourquoi, plutôt qu'un refus général.
if (error instanceof BreakError) return { error: error.message };
// Le verrou de période porte son propre message, qui explique la sortie :
// déverrouiller, ou régulariser sur la période suivante.
if (error instanceof PeriodLockedError) return { error: error.message };
+20
View File
@@ -177,9 +177,20 @@ export async function getWeekBoard(
startAt: true,
endAt: true,
breakMinutes: true,
paidBreakMinutes: true,
mealCount: true,
isValidated: true,
note: true,
labelId: true,
breaks: {
orderBy: { position: 'asc' },
select: {
startMinutes: true,
durationMinutes: true,
isPaid: true,
label: true,
},
},
},
});
@@ -240,6 +251,15 @@ export async function getWeekBoard(
startAt: shift.startAt,
endAt: shift.endAt,
breakMinutes: shift.breakMinutes,
paidBreakMinutes: shift.paidBreakMinutes,
breaks: shift.breaks.map((entry) => ({
startMinutes: entry.startMinutes,
durationMinutes: entry.durationMinutes,
isPaid: entry.isPaid,
label: entry.label,
})),
mealCount: shift.mealCount,
labelId: shift.labelId,
poste: posteOf(paletteById.get(shift.labelId ?? '')),
isValidated: shift.isValidated,
note: shift.note,
+4 -3
View File
@@ -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,
});
+9
View File
@@ -101,6 +101,15 @@ export async function createLocationAction(
} as never,
});
// Une première équipe, tout de suite. Le planning s'ordonne par équipe :
// un établissement qui n'en a aucune ne se planifie pas, et rien ne le
// dit avant la première semaine à couvrir — où le message « pas encore
// d'équipe » se lit « il n'y a personne ». Elle se renomme, et rien
// n'empêche d'en ajouter d'autres.
await db.team.create({
data: { locationId: created.id, name: 'Équipe', position: 0 } as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'location.create',
+1 -1
View File
@@ -12,7 +12,7 @@ test('chaque tuile mène à ses lignes sources', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
// Les tuiles sont des liens, pas des chiffres décoratifs. On vise celle du
// contenu, pas l'entrée homonyme de la barre latérale.
// contenu, pas l'onglet de section homonyme.
const tile = page
.locator('main')
.getByRole('link', { name: /Profils incomplets/ })
+2 -1
View File
@@ -51,7 +51,8 @@ test('le thème bascule et survit à un rechargement', async ({ page }) => {
test('une entrée de navigation non construite mène à un écran explicite', async ({
page,
}) => {
// La barre latérale n'affiche que la section courante : on l'ouvre d'abord.
// Les onglets n'affichent que les entrées de la section courante : on
// l'ouvre d'abord.
await page.goto('/absences/calendrier');
// « Politiques de congés » fait partie du périmètre visé mais n'est pas
// encore construit : l'entrée reste, et mène à un écran qui le dit.
+1 -1
View File
@@ -23,7 +23,7 @@ test('un manager ne peut ni voir ni modifier les établissements', async ({
}) => {
await signIn(page, 'manager.nantes@example.test');
// La barre latérale ne propose pas la section, mais c'est un confort :
// Les onglets ne proposent pas la section, mais c'est un confort :
// le contrôle qui compte est celui du serveur, testé en accédant à l'URL.
await page.goto('/reglages/etablissements');
+39
View File
@@ -118,10 +118,49 @@ describeIfDb('première installation', () => {
});
expect(location.timezone).toBe('America/Martinique');
expect(location.name).toBe('Siège');
// Et une première équipe : sans elle, le planning n'a aucune ligne où
// poser un créneau, et l'écran annonce « pas encore d'équipe » à qui
// vient d'installer l'instance.
const teams = await tx.team.findMany({
where: { locationId: location.id },
});
expect(teams).toHaveLength(1);
},
);
});
it('pose les référentiels sans lesquels rien ne s’accroche', async () => {
// Une instance installée doit être utilisable, pas seulement peuplée d'un
// compte. Sans type d'absence aucune demande n'est saisissable ; sans
// convention, le moteur de règles laisse passer une semaine de soixante
// heures sans rien dire.
await installThenRollback(form(), async (tx, instance) => {
const where = { accountId: instance.accountId };
expect(await tx.label.count({ where })).toBeGreaterThan(0);
expect(await tx.absenceType.count({ where })).toBeGreaterThan(0);
expect(await tx.collectiveAgreement.count({ where })).toBe(1);
expect(await tx.retentionPolicy.count({ where })).toBeGreaterThan(0);
expect(await tx.holiday.count({ where })).toBeGreaterThan(0);
// Aucune donnée d'exemple en revanche : le propriétaire est le seul
// salarié, et il n'a ni planning ni absence.
expect(await tx.membership.count({ where })).toBe(1);
expect(await tx.shift.count({ where })).toBe(0);
expect(await tx.timeOff.count({ where })).toBe(0);
// Le dimanche du maire s'autorise par arrêté municipal : en poser un
// rendrait opposable un quota que personne n'a accordé.
expect(await tx.authorisedSunday.count({ where })).toBe(0);
// Le code Silae d'un type d'absence appartient au dossier du client :
// l'inventer imputerait des congés à la rubrique d'un autre cabinet.
const types = await tx.absenceType.findMany({ where });
expect(types.every((type) => type.silaeCode === null)).toBe(true);
});
});
it('rattache le propriétaire à tous les établissements', async () => {
await installThenRollback(form(), async (tx, instance) => {
const membership = await tx.membership.findUniqueOrThrow({
+28
View File
@@ -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);
});
});
+8 -1
View File
@@ -37,7 +37,14 @@ function shift(
const startAt = zonedInstant(date, start, TZ);
let endAt = zonedInstant(date, end, TZ);
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
return { id: `s${sequence}`, startAt, endAt, breakMinutes, assigned };
return {
id: `s${sequence}`,
startAt,
endAt,
breakMinutes,
paidBreakMinutes: 0,
assigned,
};
}
function context(over: Partial<ComplianceContext> = {}): ComplianceContext {
+8
View File
@@ -33,7 +33,11 @@ const shift = (
startAt: zonedInstant(date, start, TZ),
endAt: zonedInstant(date, end, TZ),
breakMinutes: 0,
paidBreakMinutes: 0,
breaks: [],
mealCount: 0,
poste: 'vte',
labelId: null,
isValidated: false,
note: null,
...over,
@@ -89,7 +93,11 @@ describe('buildRows', () => {
startAt,
endAt,
breakMinutes: 0,
paidBreakMinutes: 0,
breaks: [],
mealCount: 0,
poste: 'inv',
labelId: null,
isValidated: false,
note: null,
},
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import {
BreakError,
breakTotals,
normaliseBreaks,
totalRestMinutes,
type BreakInput,
} from '@/domain/planning/breaks';
const SHIFT = 8 * 60;
const entry = (over: Partial<BreakInput> = {}): Partial<BreakInput> => ({
startMinutes: null,
durationMinutes: 20,
isPaid: false,
label: null,
...over,
});
describe('normaliseBreaks', () => {
it('écarte les lignes vides sans échouer', () => {
// Une ligne ajoutée puis laissée vide n'est pas une saisie : c'est un
// formulaire en cours.
const breaks = normaliseBreaks([entry(), {}], SHIFT);
expect(breaks).toHaveLength(1);
});
it('refuse une durée nulle ou négative', () => {
expect(() => normaliseBreaks([entry({ durationMinutes: 0 })], SHIFT)).toThrow(
BreakError,
);
expect(() =>
normaliseBreaks([entry({ durationMinutes: -10 })], SHIFT),
).toThrow(BreakError);
});
it('refuse une pause qui sort du créneau', () => {
// Laissée passer, elle fausserait l'amplitude de la journée.
expect(() =>
normaliseBreaks(
[entry({ startMinutes: SHIFT - 10, durationMinutes: 30 })],
SHIFT,
),
).toThrow(BreakError);
});
it('refuse deux pauses situées qui se chevauchent', () => {
// Elles compteraient deux fois : le salarié se verrait retirer un temps
// qu'il n'a pris qu'une fois.
expect(() =>
normaliseBreaks(
[
entry({ startMinutes: 120, durationMinutes: 60 }),
entry({ startMinutes: 150, durationMinutes: 30 }),
],
SHIFT,
),
).toThrow(BreakError);
});
it('accepte deux pauses situées qui se touchent', () => {
const breaks = normaliseBreaks(
[
entry({ startMinutes: 120, durationMinutes: 30 }),
entry({ startMinutes: 150, durationMinutes: 30 }),
],
SHIFT,
);
expect(breaks).toHaveLength(2);
});
it('range les pauses situées avant les autres', () => {
const breaks = normaliseBreaks(
[
entry({ startMinutes: null, durationMinutes: 15 }),
entry({ startMinutes: 240, durationMinutes: 30 }),
entry({ startMinutes: 120, durationMinutes: 20 }),
],
SHIFT,
);
expect(breaks.map((row) => row.startMinutes)).toEqual([120, 240, null]);
});
it('refuse plus de six pauses', () => {
expect(() =>
normaliseBreaks(Array.from({ length: 7 }, () => entry()), SHIFT),
).toThrow(BreakError);
});
});
describe('breakTotals', () => {
it('sépare le déduit du rémunéré', () => {
// La distinction porte de l'argent : une pause payée ne se retire pas du
// temps de travail, mais reste du repos.
const totals = breakTotals([
{ startMinutes: null, durationMinutes: 30, isPaid: false, label: null },
{ startMinutes: null, durationMinutes: 20, isPaid: true, label: null },
]);
expect(totals).toEqual({ breakMinutes: 30, paidBreakMinutes: 20 });
expect(totalRestMinutes(totals)).toBe(50);
});
it('compte zéro sans pause', () => {
expect(breakTotals([])).toEqual({ breakMinutes: 0, paidBreakMinutes: 0 });
});
});
+4
View File
@@ -35,6 +35,10 @@ const shift: BoardRow['days'][number][number] = {
time: '09:00–17:00',
minutes: 480,
breakMinutes: 30,
paidBreakMinutes: 0,
breaks: [],
mealCount: 0,
labelId: null,
state: 'published',
note: null,
};