1 Commits
Author SHA1 Message Date
MichaelandClaude Opus 5 481af181fa Fermer les listes du dossier, et déclarer les titres de séjour
CI / verify (pull_request) Canceled after 0s
Quatre champs se saisissaient en texte libre là où la valeur appartient à
une liste : nationalité, pays de naissance, pays de l'adresse, département.
« Frnace » y entrait aussi bien que « France », et le registre imprimait la
faute.

Les pays sont stockés par leur code ISO 3166-1 et nommés à l'affichage par
`Intl.DisplayNames` : un nom dépend de la langue et des versions d'ICU, un
code ne bouge pas, et deux cent cinquante traductions n'ont pas à être
entretenues à la main. Les valeurs héritées — « France » en toutes lettres
— sont reconnues à la lecture puis normalisées au premier enregistrement,
plutôt que blanchies par un menu qui ne les retrouve pas.

Les départements portent la Corse et l'outre-mer : « 2A » n'est pas un
nombre, et le département de naissance sert à la déclaration sociale, qui
attend le code.

Les téléphones gagnent leur indicatif, choisi à part et recollé en un seul
champ. L'enregistrer séparément obligerait chaque lecture — annuaire,
export, SMS — à le recomposer, et un numéro à moitié lu ne joint personne.
La découpe prend l'indicatif le plus long : « +352 » commence par « +3 ».

Le titre de séjour se déclare enfin. `WorkPermit` existait depuis le début
sans qu'aucun écran ne l'alimente. L'échéance est exigée dès qu'un titre est
posé — employer quelqu'un dont l'autorisation a expiré est un délit, et sans
date rien ne peut le signaler à temps. Décocher retire le titre plutôt que
de le laisser alerter pour quelqu'un qui n'en relève plus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:48:51 +02:00
121 changed files with 1443 additions and 10495 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 démonstration (Playwright)
run: pnpm db:seed:demo
- name: Installer le jeu de données
run: pnpm db:seed
- run: pnpm typecheck
+1 -3
View File
@@ -104,12 +104,10 @@ 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**. 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.
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.
À 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",
"db:seed:demo": "tsx prisma/seed-demo.ts"
"verify": "pnpm typecheck && pnpm lint && pnpm test"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
@@ -1,51 +0,0 @@
-- ---------------------------------------------------------------------------
-- Modèles de documents — PLAN.md §4.7 et WP-10.
--
-- `templateId` sur Document est en ON DELETE SET NULL et non CASCADE : une
-- pièce déjà remise à un salarié ne doit pas disparaître parce qu'on a effacé
-- le modèle qui l'a produite. Le lien se perd, le document reste.
-- ---------------------------------------------------------------------------
CREATE TABLE "DocumentTemplate" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"bodyHtml" TEXT NOT NULL,
"availableFields" JSONB NOT NULL,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DocumentTemplate_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "DocumentTemplate_accountId_name_key" ON "DocumentTemplate"("accountId", "name");
CREATE INDEX "DocumentTemplate_accountId_idx" ON "DocumentTemplate"("accountId");
ALTER TABLE "DocumentTemplate" ADD CONSTRAINT "DocumentTemplate_accountId_fkey"
FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Document" ADD COLUMN "templateId" TEXT;
CREATE INDEX "Document_templateId_idx" ON "Document"("templateId");
ALTER TABLE "Document" ADD CONSTRAINT "Document_templateId_fkey"
FOREIGN KEY ("templateId") REFERENCES "DocumentTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- ---------------------------------------------------------------------------
-- Isolation par compte — même défense en profondeur que les autres tables.
--
-- Un modèle porte le corps d'attestations et de courriers types : sa fuite
-- livrerait la raison sociale, les mentions et la forme des actes d'un autre
-- client. L'extension Prisma filtre déjà ; ces règles valent pour le cas où
-- elle serait contournée.
-- ---------------------------------------------------------------------------
ALTER TABLE "DocumentTemplate" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DocumentTemplate" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "DocumentTemplate"
USING ("accountId" = planflow_current_account());
CREATE POLICY tenant_insert ON "DocumentTemplate"
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
@@ -1,67 +0,0 @@
-- ---------------------------------------------------------------------------
-- Siège social et préférences du compte — PLAN.md §9.
--
-- Les colonnes d'adresse vont sur Account et non sur Location : le siège figure
-- sur les documents édités et au registre du personnel, et il n'est pas
-- nécessairement celui d'un magasin.
--
-- Les préférences sont une table à part plutôt que dix colonnes de plus sur
-- Account : elles changent des calculs, se relisent ensemble, et la séparation
-- garde lisible ce qui identifie l'entreprise et ce qui paramètre l'outil.
-- ---------------------------------------------------------------------------
ALTER TABLE "Account" ADD COLUMN "addressLine" TEXT;
ALTER TABLE "Account" ADD COLUMN "postalCode" TEXT;
ALTER TABLE "Account" ADD COLUMN "city" TEXT;
ALTER TABLE "Account" ADD COLUMN "country" TEXT NOT NULL DEFAULT 'FR';
ALTER TABLE "Account" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'Europe/Paris';
CREATE TABLE "AccountPreferences" (
"accountId" TEXT NOT NULL,
"defaultMealPerShift" BOOLEAN NOT NULL DEFAULT false,
"paidBreaks" BOOLEAN NOT NULL DEFAULT false,
"lockEmployeeMeals" BOOLEAN NOT NULL DEFAULT false,
"employeesSeeOwnTotals" BOOLEAN NOT NULL DEFAULT true,
"employeesSeeTeamPlanning" BOOLEAN NOT NULL DEFAULT false,
"hideForfaitCounters" BOOLEAN NOT NULL DEFAULT true,
"eveningShiftStartMinutes" INTEGER NOT NULL DEFAULT 1200,
"employeesEditOwnProfile" BOOLEAN NOT NULL DEFAULT true,
"employeesSeeOwnTimesheets" BOOLEAN NOT NULL DEFAULT true,
"employeesSeeTeamContacts" BOOLEAN NOT NULL DEFAULT false,
"managerCreatedArePlannable" BOOLEAN NOT NULL DEFAULT false,
"directorsSharePeople" BOOLEAN NOT NULL DEFAULT false,
"directorsAccessHrDashboard" BOOLEAN NOT NULL DEFAULT true,
"directorsSeePaidLeave" BOOLEAN NOT NULL DEFAULT true,
"managersSeePaidLeave" BOOLEAN NOT NULL DEFAULT false,
"smoothOvertimeMonthly" BOOLEAN NOT NULL DEFAULT false,
"includeRestInNormalHours" BOOLEAN NOT NULL DEFAULT false,
"autoEmployeeNumber" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountPreferences_pkey" PRIMARY KEY ("accountId")
);
ALTER TABLE "AccountPreferences" ADD CONSTRAINT "AccountPreferences_accountId_fkey"
FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ---------------------------------------------------------------------------
-- Isolation par compte.
--
-- La clé primaire *est* l'identifiant de compte, si bien qu'une fuite
-- supposerait déjà de connaître l'identifiant visé. La politique reste posée :
-- la défense en profondeur ne se dispense pas au motif que l'attaque serait
-- malcommode.
-- ---------------------------------------------------------------------------
ALTER TABLE "AccountPreferences" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "AccountPreferences" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "AccountPreferences"
USING ("accountId" = planflow_current_account());
CREATE POLICY tenant_insert ON "AccountPreferences"
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
@@ -1,17 +0,0 @@
-- ---------------------------------------------------------------------------
-- Impression et productivité — PLAN.md §9, `/reglages/impression` et
-- `/reglages/productivite`.
--
-- `productivityTargetPerHour` est nullable et non « 0 par défaut » : zéro est un
-- objectif — inatteignable — alors que l'absence de valeur dit qu'aucun objectif
-- n'a été fixé. Un écran qui affiche « 0 €/h attendu » pousse à corriger un
-- réglage qui n'existe pas.
-- ---------------------------------------------------------------------------
ALTER TABLE "AccountPreferences" ADD COLUMN "printLandscape" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "AccountPreferences" ADD COLUMN "printDensity" TEXT NOT NULL DEFAULT 'large';
ALTER TABLE "AccountPreferences" ADD COLUMN "printContractTotals" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "AccountPreferences" ADD COLUMN "printOtherTeams" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "AccountPreferences" ADD COLUMN "printSunday" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "AccountPreferences" ADD COLUMN "printSignatureColumn" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "AccountPreferences" ADD COLUMN "productivityTargetPerHour" DECIMAL(10,2);
@@ -1,61 +0,0 @@
-- ---------------------------------------------------------------------------
-- 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());
@@ -1,14 +0,0 @@
-- ---------------------------------------------------------------------------
-- 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';
@@ -1,11 +0,0 @@
-- ---------------------------------------------------------------------------
-- 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";
@@ -1,77 +0,0 @@
-- 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;
+5 -159
View File
@@ -19,14 +19,6 @@ model Account {
name String
siren String?
apeCode String?
/// Siège social. Il figure sur les documents édités et au registre unique du
/// personnel ; il n'est pas déduit de l'établissement, qui peut être ailleurs.
addressLine String?
postalCode String?
city String?
country String @default("FR")
/// Fuseau du compte. Un établissement peut le surcharger (Location.timezone).
timezone String @default("Europe/Paris")
collectiveAgreementId String?
/// Surcharges d'accord d'entreprise (PLAN.md §6.3). Vide aujourd'hui :
/// l'organisation auditée n'a pas d'accord d'entreprise.
@@ -42,91 +34,8 @@ model Account {
jobTitles JobTitle[]
labels Label[]
absenceTypes AbsenceType[]
documentTemplates DocumentTemplate[]
legalConfig LegalConfigEntry[]
installation Installation?
preferences AccountPreferences?
}
/// Préférences du compte — PLAN.md §9, réglages « préférences » et « paie ».
///
/// Trois blocs typés plutôt qu'un sac de clés-valeurs. Ce n'est pas de la
/// rigueur pour la rigueur : `smoothOvertimeMonthly` change le **calcul** des
/// heures supplémentaires, et un réglage qui change un calcul doit être lisible
/// dans le schéma, migrable, et impossible à renseigner de travers. Un JSON
/// libre rendrait indétectable une clé mal orthographiée — la préférence
/// paraîtrait active et ne le serait pas.
///
/// Une ligne par compte, créée à la demande : l'absence de ligne vaut « tous
/// les défauts », et les défauts sont ceux du modèle.
model AccountPreferences {
accountId String @id
// --- Plannings ---------------------------------------------------------
/// Compter un repas par créneau sans saisie.
defaultMealPerShift Boolean @default(false)
/// Pauses payées : elles cessent alors d'être déduites du temps de travail.
paidBreaks Boolean @default(false)
/// Interdire au salarié de modifier son repas.
lockEmployeeMeals Boolean @default(false)
/// Le salarié voit son total d'heures planifiées et réelles de la semaine.
employeesSeeOwnTotals Boolean @default(true)
/// Le salarié voit le planning des autres membres de son équipe.
employeesSeeTeamPlanning Boolean @default(false)
/// Masquer les compteurs — et les alertes — des salariés au forfait jours.
/// Un forfait jours ne se compte pas en heures : afficher un écart
/// hebdomadaire sur ces lignes produit une alerte qui n'a pas de sens.
hideForfaitCounters Boolean @default(true)
/// Heure à partir de laquelle un créneau est « du soir », en minutes depuis
/// minuit. 1200 = 20:00.
eveningShiftStartMinutes Int @default(1200)
// --- Droits ------------------------------------------------------------
employeesEditOwnProfile Boolean @default(true)
employeesSeeOwnTimesheets Boolean @default(true)
/// Annuaire ouvert : téléphone et adresse e-mail de toute l'équipe.
employeesSeeTeamContacts Boolean @default(false)
/// Un salarié créé par un manager est planifiable sans validation.
managerCreatedArePlannable Boolean @default(false)
directorsSharePeople Boolean @default(false)
directorsAccessHrDashboard Boolean @default(true)
directorsSeePaidLeave Boolean @default(true)
managersSeePaidLeave Boolean @default(false)
// --- Paie --------------------------------------------------------------
/// Décompter les heures supplémentaires au mois plutôt qu'à la semaine.
///
/// **Risque juridique.** Le décompte hebdomadaire est le principe (art.
/// L3121-28) ; lisser au mois suppose un accord d'aménagement du temps de
/// travail. Faux par défaut, et l'écran le dit.
smoothOvertimeMonthly Boolean @default(false)
/// Inclure repos et absences dans les heures normales.
includeRestInNormalHours Boolean @default(false)
/// Générer le matricule à la création plutôt que de le saisir.
autoEmployeeNumber Boolean @default(false)
// --- Impression --------------------------------------------------------
/// Paysage par défaut : une semaine tient en largeur, pas en hauteur.
printLandscape Boolean @default(true)
/// Densité : « large » lisible à distance, « compact » pour un gros effectif.
printDensity String @default("large")
printContractTotals Boolean @default(true)
/// Créneaux des autres équipes, pour situer une journée d'ensemble.
printOtherTeams Boolean @default(false)
printSunday Boolean @default(true)
/// Colonne d'émargement — une signature manuscrite ne vaut pas décompte du
/// temps de travail, mais reste demandée pour l'affichage en salle.
printSignatureColumn Boolean @default(true)
// --- Productivité ------------------------------------------------------
/// Objectif de chiffre d'affaires par heure travaillée, en euros.
/// Nul tant qu'aucun objectif n'est fixé : zéro serait un objectif, pas une
/// absence d'objectif.
productivityTargetPerHour Decimal? @db.Decimal(10, 2)
updatedAt DateTime @updatedAt
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
}
/// Marqueur d'installation de l'instance — une ligne, jamais deux.
@@ -603,6 +512,10 @@ 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?
@@ -815,13 +728,7 @@ 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?
@@ -835,41 +742,12 @@ 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 {
@@ -914,33 +792,6 @@ model DailyNote {
/// pouvoir reproduire à l'identique une paie antérieure après un changement de
/// règle. Une mise à jour en place rendrait cette reproduction impossible, et
/// l'impossibilité ne se découvrirait qu'au premier contrôle.
/// Modèle de document — PLAN.md §4.7 et §9.
///
/// `bodyHtml` porte le corps avec ses variables sous la forme `{{cle}}`.
/// `availableFields` déclare les variables offertes à la rédaction : elles sont
/// **scopées par établissement**, si bien qu'un modèle rendu pour un salarié
/// résout ses variables dans le contexte de l'établissement de son contrat. Une
/// même attestation ne porte donc pas la même raison sociale d'un magasin à
/// l'autre.
///
/// Archivé plutôt que supprimé : un document généré garde `templateId`, et
/// perdre le modèle rendrait inexplicable la forme d'une pièce déjà remise.
model DocumentTemplate {
id String @id @default(cuid())
accountId String
name String
bodyHtml String
availableFields Json
archivedAt DateTime?
createdAt DateTime @default(now())
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
documents Document[]
@@unique([accountId, name])
@@index([accountId])
}
/// Pièce du dossier salarié — PLAN.md §4.7.
///
/// Le contenu vit sur disque, **chiffré**, jamais dans la base : une pièce
@@ -960,8 +811,6 @@ model Document {
/// Empreinte du clair, pour détecter une altération du fichier stocké.
checksum String
/// Donnée de santé : sa lecture est journalisée (§3.6).
/// Modèle dont la pièce est issue. Nul pour un fichier déposé à la main.
templateId String?
isSensitive Boolean @default(false)
/// Échéance de conservation. Nulle tant qu'aucune politique ne l'a fixée :
/// le plan interdit d'appliquer une durée par défaut (§12.5).
@@ -971,8 +820,7 @@ model Document {
deletedAt DateTime?
deletedBy String?
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade)
template DocumentTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade)
@@index([accountId])
@@index([membershipId])
@@ -1461,8 +1309,6 @@ 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
}
-147
View File
@@ -1,147 +0,0 @@
/**
* Correspondances Silae du dossier FROUARD DISTRIBUTION.
*
* **Données de dossier, pas de produit.** Ces codes appartiennent au
* paramétrage Silae de ce client et n'ont aucune valeur pour un autre : deux
* clients du même cabinet ne partagent pas leurs rubriques. Ils vivent donc
* dans le seed, jamais dans `src/domain/payroll` — écrire `HS-HS50` dans un
* calcul rendrait l'outil inutilisable au deuxième dossier, et incorrigible
* sans livraison le jour où le cabinet renumérote.
*
* **Source** : configuration de l'intégration Silae du compte, relevée le
* 11 août 2026. Ce ne sont pas des codes déduits d'une documentation publique —
* il n'en existe pas : Silae impose que le fichier d'import soit le miroir du
* paramétrage du dossier.
*
* `À VALIDER` — quatre rubriques du dossier portent un préfixe `AB-` **sans
* numéro** (voir `INCOMPLETE_IN_DOSSIER`). Elles ne sont pas reprises ici :
* importer un code tronqué ferait échouer l'import de paie, ou pire, le ferait
* réussir en imputant à la mauvaise rubrique.
*/
/** Rubriques laissées vides ou incomplètes dans le paramétrage du dossier. */
export const INCOMPLETE_IN_DOSSIER = [
{ label: 'Évènement familial', observed: 'AB-' },
{ label: 'Repos compensateur de nuit', observed: 'AB-' },
{ label: "Repos compensateur d'habillement", observed: 'AB-' },
{ label: 'Visite médicale', observed: 'AB-' },
] as const;
/**
* Code d'absence par type PlanFlow.
*
* La valeur est la **partie après `AB-`**, comme le stocke
* `AbsenceType.silaeCode` : le préfixe est ajouté à la sérialisation.
*/
export const ABSENCE_CODES: Record<string, string> = {
// Congés payés → AB-300
CP: '300',
// RTT → AB-310
RTT: '310',
// Arrêt maladie - Initial → AB-100. L'extension n'est pas paramétrée au
// dossier : un arrêt prolongé se saisit aujourd'hui à la main côté paie.
MAL: '100',
// Congé sans solde → AB-632
SS: '632',
// Repos compensateur de remplacement → AB-335
RC: '335',
};
/**
* Code Silae par élément de paie calculé.
*
* Les trois codes qui manquaient à la conception — 50 %, complémentaires à
* 10 % et à 25 % — sont désormais connus. `FORFAIT_DAYS` reste absent : aucune
* rubrique de forfait jours n'existe au paramétrage, et l'inventer produirait
* un export refusé au premier cadre autonome.
*/
export const ELEMENT_CODES: Record<string, string> = {
WORKED_DAYS: 'Nombre total de jours travailles',
WORKED_HOURS: 'Heures travaillees',
MISSING_HOURS: 'Heures manquantes au contrat',
SUNDAY_HOURS: 'EV-HDimanche',
HOLIDAY_HOURS: 'EV-HFerie',
OVERTIME_25: 'HS-HS25',
OVERTIME_50: 'HS-HS50',
COMPLEMENTARY_10: 'HS-HC10',
COMPLEMENTARY_25: 'HS-HC25',
};
/**
* Catalogue complet relevé au dossier.
*
* Conservé en entier — y compris les rubriques que PlanFlow ne calcule pas
* encore — parce qu'il documente ce que le dossier accepte. Le jour où les
* heures de nuit ou les paniers repas entrent au périmètre, le code est déjà
* là et n'aura pas à être redemandé au cabinet.
*
* Les taux de nuit sont recopiés **tels quels**. Leur numérotation surprend
* (`EV-HNuit24-2` pour 10 %, `EV-HNuit2-4` pour 20 %) : c'est le paramétrage du
* dossier, et le « corriger » ici imputerait la majoration à la mauvaise
* rubrique.
*/
export const DOSSIER_CATALOGUE: Array<{ label: string; code: string }> = [
{ label: 'Absence formation rémunérée', code: 'AB-410' },
{ label: 'Absence injustifiée - Non payé', code: 'AB-630' },
{ label: 'Absence injustifiée - Payé', code: 'AB-631' },
{ label: 'Absence justifiée - Non payé', code: 'AB-630' },
{ label: 'Absence justifiée - Payé', code: 'AB-631' },
{ label: 'Accident du travail - Initial', code: 'AB-120' },
{ label: 'Arrêt maladie - Initial', code: 'AB-100' },
{ label: 'Chômage partiel', code: 'AB-609' },
{ label: 'Congé maternité', code: 'AB-200' },
{ label: 'Congé parental', code: 'AB-230' },
{ label: 'Congé paternité', code: 'AB-210' },
{ label: 'Congé sans solde', code: 'AB-632' },
{ label: 'Congés payés', code: 'AB-300' },
{ label: 'Jours fériés chômés (heures)', code: 'AB-301' },
{ label: 'Maladie professionnelle - Initial', code: 'AB-110' },
{ label: 'Mise à pied conservatoire', code: 'AB-641' },
{ label: 'Mise à pied disciplinaire', code: 'AB-640' },
{ label: 'Repos compensateur de remplacement', code: 'AB-335' },
{ label: 'Retard', code: 'AB-630' },
{ label: 'RTT', code: 'AB-310' },
{ label: 'Heures complémentaires (5 %)', code: 'HS-HC5' },
{ label: 'Heures complémentaires (10 %)', code: 'HS-HC10' },
{ label: 'Heures complémentaires (25 %)', code: 'HS-HC25' },
{ label: 'Heures supplémentaires (0 %)', code: 'HS-HS00' },
{ label: 'Heures supplémentaires (10 %)', code: 'HS-HS10' },
{ label: 'Heures supplémentaires (15 %)', code: 'HS-HS15' },
{ label: 'Heures supplémentaires (20 %)', code: 'HS-HS20' },
{ label: 'Heures supplémentaires (25 %)', code: 'HS-HS25' },
{ label: 'Heures supplémentaires (50 %)', code: 'HS-HS50' },
{ label: 'Heures supplémentaires (100 %)', code: 'HS-HS100' },
{ label: 'Heures travaillées majorées (dimanche) 10 %', code: 'HS-HS10-D' },
{ label: 'Heures travaillées majorées (dimanche) 15 %', code: 'HS-HS15-D' },
{ label: 'Heures travaillées majorées (dimanche) 30 %', code: 'HS-HS30-D' },
{ label: 'Heures travaillées majorées (dimanche) 33 %', code: 'HS-HS33-D' },
{ label: 'Heures travaillées majorées (dimanche) 50 %', code: 'HS-HS50-D' },
{ label: 'Heures travaillées majorées (dimanche) 100 %', code: 'EV-HDimanche' },
{ label: 'Heures travaillées majorées (1er mai)', code: 'C01' },
{ label: 'Heures travaillées majorées (jours fériés)', code: 'EV-HFerie' },
{ label: 'Coupures', code: 'EV-nb-coupure' },
{ label: 'Heures de nuit (0 %)', code: 'EV-HNuit-0' },
{ label: 'Heures de nuit (5 %)', code: 'EV-HNuit2-5' },
{ label: 'Heures de nuit (10 %)', code: 'EV-HNuit24-2' },
{ label: 'Heures de nuit (20 %)', code: 'EV-HNuit2-4' },
{ label: 'Heures de nuit (25 %)', code: 'EV-HNuit2-6' },
{ label: 'Heures de nuit (30 %)', code: 'EV-HNuit2-3' },
{ label: 'Heures de nuit (50 %)', code: 'EV-HNuit' },
{ label: 'Repas : avantage en nature', code: 'EV-A01' },
{ label: 'Repas : indemnité repas', code: 'EV-A02' },
{ label: 'Repas : prime de panier', code: 'EV-PrimePanier' },
{ label: 'Repas : titre restaurant', code: 'EV-TitreRestaurant' },
{ label: 'Entrée / Sortie', code: 'Entree / Sortie' },
{ label: 'Heures manquantes au contrat', code: 'Heures manquantes au contrat' },
{ label: 'Heures normales', code: 'Heures normales' },
{ label: 'Heures travaillées', code: 'Heures travaillees' },
{ label: 'Heures travaillées (extra)', code: 'Heures travaillees (extra)' },
{ label: 'Nombre total de jours travaillés', code: 'Nombre total de jours travailles' },
{ label: 'Note transport', code: 'Note transport' },
{ label: 'Nouveau solde RCR', code: 'Nouveau solde RCR' },
{ label: 'RCR à payer', code: 'RCR a payer' },
{ label: 'Solde de modulation', code: 'Solde de modulation' },
];
+17 -56
View File
@@ -22,29 +22,15 @@ import {
} from '../src/domain/compliance/idcc1517';
import { frenchHolidays } from '../src/domain/absences/holidays';
import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements';
import {
ABSENCE_CODES,
ELEMENT_CODES,
INCOMPLETE_IN_DOSSIER,
} from './seed-data/silae-frouard';
import { evaluateSchedule } from '../src/server/compliance/evaluate';
import { withTenant } from '../src/server/tenant';
/**
* Jeu de démonstration — PLAN.md §11.
* Jeu de données de départ — PLAN.md §11.
*
* **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.
* **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.
*/
const DEMO_PASSWORD = 'planflow-demo-2026';
@@ -600,10 +586,9 @@ async function main() {
console.log('→ Types d’absence');
// `silaeCode` vient du paramétrage Silae du dossier, relevé le 11 août 2026
// (voir `seed-data/silae-frouard.ts`). Il n'a rien d'universel : la
// correspondance entre AB-300 et « congés payés » appartient à ce dossier, et
// un autre client du même cabinet peut la numéroter autrement.
// `silaeCode` reste **null** : les codes AB-* du dossier sont connus, leur
// signification ne l'est pas. Les associer ici reviendrait à deviner à quel
// type d'absence correspond AB-300, ce qui produirait une paie fausse.
const absenceTypes = [
{ code: 'CP', name: 'Congés payés', colorKey: 'cp', isPaid: true, social: false, notice: 30 },
{ code: 'RTT', name: 'RTT', colorKey: 'rtt', isPaid: true, social: false, notice: 7 },
@@ -615,17 +600,12 @@ async function main() {
for (const type of absenceTypes) {
await prisma.absenceType.upsert({
where: { accountId_code: { accountId: account.id, code: type.code } },
update: {
name: type.name,
colorKey: type.colorKey,
silaeCode: ABSENCE_CODES[type.code] ?? null,
},
update: { name: type.name, colorKey: type.colorKey },
create: {
accountId: account.id,
code: type.code,
name: type.name,
colorKey: type.colorKey,
silaeCode: ABSENCE_CODES[type.code] ?? null,
isPaid: type.isPaid,
countsAsWorkTime: false,
affectsPaidLeaveAccrual: !type.social,
@@ -729,18 +709,12 @@ async function main() {
console.log(` ${demoAbsences.length} absences`);
console.log('→ Correspondances Silae');
// Les codes viennent du paramétrage du dossier, actif et coché côté Combo :
// ils sont donc semés **confirmés**. Un code déduit d'un libellé le serait
// non confirmé — proposer n'est pas confirmer — mais ceux-ci sont relevés
// dans la configuration réelle, pas devinés.
//
// Ce qui n'a pas de code au dossier reste absent plutôt que semé à vide :
// l'export refuse alors de tourner en listant les manques, ce qui est le
// comportement voulu (§8).
let seededMappings = 0;
// Semées **non confirmées**, y compris quand le code se lit dans son libellé
// (`EV-HDimanche`). Proposer n'est pas confirmer : seul le gestionnaire de
// paie sait si le code est le bon dans ce dossier, et l'export refuse de
// tourner tant qu'il ne l'a pas dit.
for (const definition of PAYROLL_ELEMENT_DEFINITIONS) {
const code = ELEMENT_CODES[definition.key] ?? definition.suggestedCode;
if (!code) continue;
if (!definition.suggestedCode) continue;
const existing = await prisma.silaeCodeMapping.findFirst({
where: { accountId: account.id, sourceKey: definition.key },
});
@@ -750,29 +724,16 @@ async function main() {
data: {
accountId: account.id,
sourceKey: definition.key,
silaeCode: code,
silaeCode: definition.suggestedCode,
label: definition.label,
kind: definition.kind,
confirmed: Boolean(ELEMENT_CODES[definition.key]),
confirmed: false,
},
});
seededMappings += 1;
}
const missingElements = PAYROLL_ELEMENT_DEFINITIONS.filter(
(definition) =>
!ELEMENT_CODES[definition.key] && !definition.suggestedCode,
console.log(
` ${PAYROLL_ELEMENT_DEFINITIONS.filter((d) => d.suggestedCode).length} proposées, aucune confirmée`,
);
console.log(` ${seededMappings} correspondances`);
if (missingElements.length > 0) {
console.log(
` ⚠ sans code au dossier : ${missingElements.map((d) => d.label).join(', ')}`,
);
}
if (INCOMPLETE_IN_DOSSIER.length > 0) {
console.log(
` ⚠ ${INCOMPLETE_IN_DOSSIER.length} rubriques incomplètes côté Silae (préfixe « AB- » sans numéro) : ${INCOMPLETE_IN_DOSSIER.map((row) => row.label).join(', ')}`,
);
}
console.log('→ Matricules Silae');
// Fictifs, à la forme observée dans le dossier : cinq chiffres cadrés à zéro.
-36
View File
@@ -1,36 +0,0 @@
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { getAbsenceQueue } from '@/server/absences/queries';
export const metadata = { title: 'Demandes à traiter · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ATraiterPage() {
const result = await getAbsenceQueue('pending');
return (
<PageBody>
<PageHeader
title="Demandes à traiter"
subtitle={
result.rows.length === 0
? 'Aucune demande en attente'
: `${result.rows.length} demande${result.rows.length > 1 ? 's' : ''} en attente, la plus ancienne en premier`
}
/>
<AbsenceQueueView
result={result}
title="File d’attente"
description="Aucune demande en attente de décision."
/>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Les demandes sont classées par ancienneté : celle qui attend depuis le
plus longtemps arrive en tête. Accepter écrit au registre des compteurs ;
annuler ensuite contre-passe l’écriture plutôt que de l’effacer, et le
solde revient au même chiffre.
</p>
</PageBody>
);
}
-332
View File
@@ -1,332 +0,0 @@
import Link from 'next/link';
import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons';
import { RequestForm } from '@/components/absences/RequestForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge, type Tone } from '@/components/ui/Badge';
import { datesBetween, isoWeekday } from '@/domain/absences/count';
import { monthOf, parseMonthParam } from '@/domain/planning/month';
import { cx } from '@/lib/cx';
import { getAbsenceBoard, statusLabel } from '@/server/absences/queries';
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
interface PageProps {
searchParams: Promise<{ mois?: string }>;
}
const COUNTER_LABELS: Record<string, string> = {
PAID_LEAVE: 'Congés payés',
RTT: 'RTT',
COMPENSATORY_REST: 'Repos compensateur',
MODULATION: 'Modulation',
OVERTIME: 'Heures supplémentaires',
};
const STATUS_TONES: Record<string, Tone> = {
PENDING: 'warn',
ACCEPTED: 'ok',
DECLINED: 'danger',
CANCELLED: 'neutral',
EXPIRED: 'neutral',
};
export default async function CongesPage({ searchParams }: PageProps) {
const params = await searchParams;
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
const board = await getAbsenceBoard(month);
const href = (mois: string) => `/absences/calendrier?mois=${mois}`;
return (
<PageBody>
<PageHeader
title="Calendrier des absences"
subtitle={`${board.label} · ${board.inMonth.length} absence${board.inMonth.length > 1 ? 's' : ''} sur le mois`}
actions={
<>
<Link
href={href(board.previousParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Mois précédent
</Link>
<Link
href={href(board.nextParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Mois suivant →
</Link>
</>
}
/>
<RequestForm
absenceTypes={board.absenceTypes}
people={board.people}
ownMembershipId={board.ownMembershipId}
/>
<section id="attente" className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Demandes en attente
{board.pending.length > 0 ? ` · ${board.pending.length}` : ''}
</h2>
{board.pending.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucune demande à traiter.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.pending.map((request) => (
<li
key={request.id}
className="flex flex-wrap items-center gap-3 px-4 py-3"
>
<div className="min-w-56 flex-1">
<p className="text-sm font-medium text-ink-1">
{request.name} · {request.typeLabel}
</p>
<p className="text-micro text-ink-3">
du {frenchDate(request.startDate)} au{' '}
{frenchDate(request.endDate)} ·{' '}
<span className="tnum">{request.days}</span> jour
{request.days > 1 ? 's' : ''} décompté
{request.days > 1 ? 's' : ''}
</p>
{request.comment ? (
<p className="mt-0.5 text-micro text-ink-2">
« {request.comment} »
</p>
) : null}
</div>
{board.canDecide ? (
<DecisionButtons timeOffId={request.id} />
) : (
<CancelButton timeOffId={request.id} />
)}
</li>
))}
</ul>
)}
</section>
<MonthCalendar board={board} />
<section className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Absences du mois
</h2>
{board.inMonth.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucune absence sur cette période.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.inMonth.map((request) => (
<li
key={request.id}
className="flex flex-wrap items-center gap-3 px-4 py-2.5"
>
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
{statusLabel(request.status)}
</Badge>
<span className="text-sm text-ink-1">{request.name}</span>
<span className="text-sm text-ink-2">{request.typeLabel}</span>
<span className="text-micro text-ink-3">
{frenchDate(request.startDate)} → {frenchDate(request.endDate)}
</span>
<span className="tnum ml-auto text-xs text-ink-2">
{request.days} j
</span>
{request.status === 'ACCEPTED' ? (
<CancelButton timeOffId={request.id} />
) : null}
</li>
))}
</ul>
)}
</section>
<section
id="compteurs"
className="rounded-3 border border-line-1 bg-surface"
>
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Compteurs
</h2>
{board.counters.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucun compteur ouvert. Un compteur se crée à la première écriture —
acceptation d’un congé ou ajustement manuel.
</p>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
Salarié
</th>
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
Compteur
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Acquis
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Pris
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Solde
</th>
</tr>
</thead>
<tbody>
{board.counters.map((counter) => (
<tr
key={`${counter.membershipId}-${counter.counterType}`}
className="border-b border-line-1 last:border-b-0"
>
<td className="px-4 py-1.5 text-ink-1">{counter.name}</td>
<td className="px-4 py-1.5 text-ink-2">
{COUNTER_LABELS[counter.counterType] ?? counter.counterType}
</td>
<td className="tnum px-4 py-1.5 text-right text-ink-2">
{counter.accrued}
</td>
<td className="tnum px-4 py-1.5 text-right text-ink-2">
{counter.taken}
</td>
<td className="tnum px-4 py-1.5 text-right font-semibold text-ink-1">
{counter.balance}
</td>
</tr>
))}
</tbody>
</table>
)}
<p className="border-t border-line-1 px-4 py-2 text-micro text-ink-3">
Le solde est la somme des écritures du registre, jamais une valeur
stockée : un solde stocké se désynchronise, et la désynchronisation ne
se voit qu’au moment où un salarié conteste.
</p>
</section>
</PageBody>
);
}
/** Calendrier du mois, une colonne par jour. */
function MonthCalendar({
board,
}: {
board: Awaited<ReturnType<typeof getAbsenceBoard>>;
}) {
const accepted = board.inMonth.filter(
(request) => request.status === 'ACCEPTED' || request.status === 'PENDING',
);
const people = [...new Set(accepted.map((request) => request.membershipId))];
if (people.length === 0) return null;
return (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<table className="w-full border-collapse text-xs">
<caption className="sr-only">
Calendrier des absences, {board.label}
</caption>
<thead>
<tr className="border-b border-line-2 bg-surface-2">
<th
scope="col"
className="sticky left-0 z-10 min-w-40 bg-surface-2 px-3 py-2 text-left text-micro font-semibold text-ink-3 uppercase"
>
Salarié
</th>
{board.days.map((date) => (
<th
key={date}
scope="col"
className={cx(
'tnum w-7 px-0.5 py-2 text-center font-semibold',
isoWeekday(date) >= 6
? 'bg-surface-3 text-ink-2'
: 'text-ink-1',
)}
>
{Number(date.slice(8, 10))}
</th>
))}
</tr>
</thead>
<tbody>
{people.map((membershipId) => {
const own = accepted.filter(
(request) => request.membershipId === membershipId,
);
const covered = new Map<string, (typeof own)[number]>();
for (const request of own) {
for (const date of datesBetween(
request.startDate,
request.endDate,
)) {
covered.set(date, request);
}
}
return (
<tr
key={membershipId}
className="border-b border-line-1 last:border-b-0"
>
<th
scope="row"
className="sticky left-0 z-10 bg-surface px-3 py-1.5 text-left text-sm font-medium text-ink-1"
>
{own[0]?.name ?? ''}
</th>
{board.days.map((date) => {
const request = covered.get(date);
return (
<td
key={date}
className={cx(
'border-l border-line-1 px-0.5 py-1.5 text-center',
isoWeekday(date) >= 6 && 'bg-surface-2',
)}
title={
request
? `${request.typeLabel} — ${statusLabel(request.status)}`
: undefined
}
>
{request ? (
<span
className={cx(
'block h-4 rounded-1',
request.status === 'PENDING'
? 'border border-dashed border-warn bg-warn-soft'
: 'bg-info-soft',
)}
>
<span className="sr-only">
{request.typeLabel} le {date}
</span>
</span>
) : null}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function frenchDate(isoDate: string): string {
return isoDate.split('-').reverse().join('/');
}
-33
View File
@@ -1,33 +0,0 @@
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { getAbsenceQueue } from '@/server/absences/queries';
export const metadata = { title: 'Absences expirées · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ExpireesPage() {
const result = await getAbsenceQueue('expired');
return (
<PageBody>
<PageHeader
title="Absences expirées"
subtitle="Demandes dont la date est passée sans décision"
/>
<AbsenceQueueView
result={result}
title="Demandes expirées"
description="Aucune demande n’a expiré."
/>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Une demande expirée n’a jamais été décidée : elle n’a donc rien écrit au
registre des compteurs, et le solde du salarié n’en porte aucune trace.
Elle reste visible parce qu’une absence non traitée à temps est un fait
opposable — au salarié qui l’a demandée comme à l’employeur qui ne l’a
pas tranchée.
</p>
</PageBody>
);
}
-64
View File
@@ -1,64 +0,0 @@
import Link from 'next/link';
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { getAbsenceQueue } from '@/server/absences/queries';
export const metadata = { title: 'Absences traitées · PlanFlow' };
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{ nature?: string }>;
}
export default async function TraiteesPage({ searchParams }: PageProps) {
const { nature } = await searchParams;
// Filtre relevé à l'audit des menus : « Toutes les absences » ou
// « Uniquement les absences Sécurité sociale ». Deux valeurs, pas trois.
const socialSecurityOnly = nature === 'ss';
const result = await getAbsenceQueue('treated', { socialSecurityOnly });
const tab = (value: string | null, label: string) => {
const active = (value === 'ss') === socialSecurityOnly;
return (
<Link
href={value ? `/absences/traitees?nature=${value}` : '/absences/traitees'}
aria-current={active ? 'page' : undefined}
className={
active
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
}
>
{label}
</Link>
);
};
return (
<PageBody>
<PageHeader
title="Absences traitées"
subtitle="Décisions rendues : acceptées, refusées et annulées"
/>
<AbsenceQueueView
result={result}
title="Décisions rendues"
description="Aucune absence traitée pour ce filtre."
filterBar={
<div className="flex flex-wrap items-center gap-1">
{tab(null, 'Toutes les absences')}
{tab('ss', 'Uniquement Sécurité sociale')}
</div>
}
/>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Le motif d’une absence Sécurité sociale est une donnée de santé : il
n’apparaît qu’aux personnes habilitées à lire les pièces du dossier. Pour
les autres, la ligne dit « Absence » et rien de plus.
</p>
</PageBody>
);
}
+328 -18
View File
@@ -1,22 +1,332 @@
import { permanentRedirect } from 'next/navigation';
import Link from 'next/link';
/**
* Ancienne adresse du calendrier des absences.
*
* Les absences vivent désormais sous `/absences`, avec une route par état
* (§9). Le calendrier a déménagé plutôt que d'être dupliqué : deux écrans
* lisant le même modèle finissent toujours par diverger d'une correction.
*
* La redirection est **permanente** et conserve le mois demandé : un lien de
* planning partagé dans un message continue d'ouvrir la bonne page.
*/
export default async function CongesRedirect({
searchParams,
}: {
import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons';
import { RequestForm } from '@/components/absences/RequestForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge, type Tone } from '@/components/ui/Badge';
import { datesBetween, isoWeekday } from '@/domain/absences/count';
import { monthOf, parseMonthParam } from '@/domain/planning/month';
import { cx } from '@/lib/cx';
import { getAbsenceBoard, statusLabel } from '@/server/absences/queries';
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
interface PageProps {
searchParams: Promise<{ mois?: string }>;
}) {
const { mois } = await searchParams;
permanentRedirect(
mois ? `/absences/calendrier?mois=${mois}` : '/absences/calendrier',
}
const COUNTER_LABELS: Record<string, string> = {
PAID_LEAVE: 'Congés payés',
RTT: 'RTT',
COMPENSATORY_REST: 'Repos compensateur',
MODULATION: 'Modulation',
OVERTIME: 'Heures supplémentaires',
};
const STATUS_TONES: Record<string, Tone> = {
PENDING: 'warn',
ACCEPTED: 'ok',
DECLINED: 'danger',
CANCELLED: 'neutral',
EXPIRED: 'neutral',
};
export default async function CongesPage({ searchParams }: PageProps) {
const params = await searchParams;
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
const board = await getAbsenceBoard(month);
const href = (mois: string) => `/conges?mois=${mois}`;
return (
<PageBody>
<PageHeader
title="Calendrier des absences"
subtitle={`${board.label} · ${board.inMonth.length} absence${board.inMonth.length > 1 ? 's' : ''} sur le mois`}
actions={
<>
<Link
href={href(board.previousParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Mois précédent
</Link>
<Link
href={href(board.nextParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Mois suivant →
</Link>
</>
}
/>
<RequestForm
absenceTypes={board.absenceTypes}
people={board.people}
ownMembershipId={board.ownMembershipId}
/>
<section id="attente" className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Demandes en attente
{board.pending.length > 0 ? ` · ${board.pending.length}` : ''}
</h2>
{board.pending.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucune demande à traiter.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.pending.map((request) => (
<li
key={request.id}
className="flex flex-wrap items-center gap-3 px-4 py-3"
>
<div className="min-w-56 flex-1">
<p className="text-sm font-medium text-ink-1">
{request.name} · {request.typeLabel}
</p>
<p className="text-micro text-ink-3">
du {frenchDate(request.startDate)} au{' '}
{frenchDate(request.endDate)} ·{' '}
<span className="tnum">{request.days}</span> jour
{request.days > 1 ? 's' : ''} décompté
{request.days > 1 ? 's' : ''}
</p>
{request.comment ? (
<p className="mt-0.5 text-micro text-ink-2">
« {request.comment} »
</p>
) : null}
</div>
{board.canDecide ? (
<DecisionButtons timeOffId={request.id} />
) : (
<CancelButton timeOffId={request.id} />
)}
</li>
))}
</ul>
)}
</section>
<MonthCalendar board={board} />
<section className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Absences du mois
</h2>
{board.inMonth.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucune absence sur cette période.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.inMonth.map((request) => (
<li
key={request.id}
className="flex flex-wrap items-center gap-3 px-4 py-2.5"
>
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
{statusLabel(request.status)}
</Badge>
<span className="text-sm text-ink-1">{request.name}</span>
<span className="text-sm text-ink-2">{request.typeLabel}</span>
<span className="text-micro text-ink-3">
{frenchDate(request.startDate)} → {frenchDate(request.endDate)}
</span>
<span className="tnum ml-auto text-xs text-ink-2">
{request.days} j
</span>
{request.status === 'ACCEPTED' ? (
<CancelButton timeOffId={request.id} />
) : null}
</li>
))}
</ul>
)}
</section>
<section
id="compteurs"
className="rounded-3 border border-line-1 bg-surface"
>
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Compteurs
</h2>
{board.counters.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucun compteur ouvert. Un compteur se crée à la première écriture —
acceptation d’un congé ou ajustement manuel.
</p>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
Salarié
</th>
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
Compteur
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Acquis
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Pris
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Solde
</th>
</tr>
</thead>
<tbody>
{board.counters.map((counter) => (
<tr
key={`${counter.membershipId}-${counter.counterType}`}
className="border-b border-line-1 last:border-b-0"
>
<td className="px-4 py-1.5 text-ink-1">{counter.name}</td>
<td className="px-4 py-1.5 text-ink-2">
{COUNTER_LABELS[counter.counterType] ?? counter.counterType}
</td>
<td className="tnum px-4 py-1.5 text-right text-ink-2">
{counter.accrued}
</td>
<td className="tnum px-4 py-1.5 text-right text-ink-2">
{counter.taken}
</td>
<td className="tnum px-4 py-1.5 text-right font-semibold text-ink-1">
{counter.balance}
</td>
</tr>
))}
</tbody>
</table>
)}
<p className="border-t border-line-1 px-4 py-2 text-micro text-ink-3">
Le solde est la somme des écritures du registre, jamais une valeur
stockée : un solde stocké se désynchronise, et la désynchronisation ne
se voit qu’au moment où un salarié conteste.
</p>
</section>
</PageBody>
);
}
/** Calendrier du mois, une colonne par jour. */
function MonthCalendar({
board,
}: {
board: Awaited<ReturnType<typeof getAbsenceBoard>>;
}) {
const accepted = board.inMonth.filter(
(request) => request.status === 'ACCEPTED' || request.status === 'PENDING',
);
const people = [...new Set(accepted.map((request) => request.membershipId))];
if (people.length === 0) return null;
return (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<table className="w-full border-collapse text-xs">
<caption className="sr-only">
Calendrier des absences, {board.label}
</caption>
<thead>
<tr className="border-b border-line-2 bg-surface-2">
<th
scope="col"
className="sticky left-0 z-10 min-w-40 bg-surface-2 px-3 py-2 text-left text-micro font-semibold text-ink-3 uppercase"
>
Salarié
</th>
{board.days.map((date) => (
<th
key={date}
scope="col"
className={cx(
'tnum w-7 px-0.5 py-2 text-center font-semibold',
isoWeekday(date) >= 6
? 'bg-surface-3 text-ink-2'
: 'text-ink-1',
)}
>
{Number(date.slice(8, 10))}
</th>
))}
</tr>
</thead>
<tbody>
{people.map((membershipId) => {
const own = accepted.filter(
(request) => request.membershipId === membershipId,
);
const covered = new Map<string, (typeof own)[number]>();
for (const request of own) {
for (const date of datesBetween(
request.startDate,
request.endDate,
)) {
covered.set(date, request);
}
}
return (
<tr
key={membershipId}
className="border-b border-line-1 last:border-b-0"
>
<th
scope="row"
className="sticky left-0 z-10 bg-surface px-3 py-1.5 text-left text-sm font-medium text-ink-1"
>
{own[0]?.name ?? ''}
</th>
{board.days.map((date) => {
const request = covered.get(date);
return (
<td
key={date}
className={cx(
'border-l border-line-1 px-0.5 py-1.5 text-center',
isoWeekday(date) >= 6 && 'bg-surface-2',
)}
title={
request
? `${request.typeLabel} — ${statusLabel(request.status)}`
: undefined
}
>
{request ? (
<span
className={cx(
'block h-4 rounded-1',
request.status === 'PENDING'
? 'border border-dashed border-warn bg-warn-soft'
: 'bg-info-soft',
)}
>
<span className="sr-only">
{request.typeLabel} le {date}
</span>
</span>
) : null}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function frenchDate(isoDate: string): string {
return isoDate.split('-').reverse().join('/');
}
+28 -62
View File
@@ -3,6 +3,7 @@
import { useActionState, useState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PhoneField } from '@/components/ui/PhoneField';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createEmployeeAction,
@@ -25,35 +26,18 @@ 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 = [
['APPRENTISSAGE', 'Apprentissage'],
['CDD', 'CDD'],
['CDI', 'CDI'],
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
['CDD', 'CDD'],
['APPRENTISSAGE', 'Apprentissage'],
['STAGIAIRE', 'Stagiaire'],
['SAISONNIER', 'Saisonnier'],
['EXTRA', 'Extra'],
['INTERIM', 'Intérim'],
['SAISONNIER', 'Saisonnier'],
['STAGIAIRE', 'Stagiaire'],
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
] 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';
@@ -151,9 +135,23 @@ export function AddEmployeeForm({
type="email"
hint="Permet de se connecter à PlanFlow et de recevoir ses plannings. Sans elle, le salarié reste planifiable et déclarable."
/>
<Field label="Téléphone mobile" name="phone" type="tel" />
<PhoneField label="Téléphone mobile" name="phone" />
<Field label="Téléphone fixe" name="landline" 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>
<PhoneField label="Téléphone fixe" name="landline" />
</Section>
{locations.length > 0 ? (
@@ -189,22 +187,9 @@ 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 <Required />
</span>
<select
name="contractType"
required
defaultValue=""
className={selectClass}
>
<option value="" disabled>
Sélectionnez un type
</option>
<span className="text-sm font-medium">Type de contrat</span>
<select name="contractType" className={selectClass}>
{CONTRACT_TYPES.map(([value, label]) => (
<option key={value} value={value}>
{label}
@@ -295,35 +280,16 @@ 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 <Required />
</span>
<select
name="teamId"
required
defaultValue=""
className={selectClass}
>
<option value="" disabled>
Sélectionnez une équipe
</option>
<span className="text-sm font-medium">Équipe</span>
<select name="teamId" className={selectClass}>
<option value="">Aucune pour l’instant</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">
+215 -17
View File
@@ -5,6 +5,7 @@ import { useActionState, useState } from 'react';
import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
import { Button } from '@/components/ui/Button';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PhoneField } from '@/components/ui/PhoneField';
import {
GENDERS,
MARITAL_STATUSES,
@@ -12,12 +13,14 @@ import {
maritalStatusLabel,
} from '@/domain/hr/civil-status';
import {
COUNTRY_OPTIONS,
NATIONALITY_OPTIONS,
countryLabel,
nationalityLabel,
} from '@/domain/legal/countries';
import { DEPARTMENTS, departmentLabel } from '@/domain/legal/departments';
countryOptions,
departmentLabel,
departmentOptions,
} from '@/domain/hr/geo';
import {
updateWorkPermitAction,
} from '@/server/employees/profile-actions';
import {
updateProfileAction,
updateSensitiveAction,
@@ -48,6 +51,7 @@ export interface ProfileFields {
personalEmail: string;
phone: string;
landline: string;
smsSchedules: boolean;
addressLine1: string;
addressLine2: string;
postalCode: string;
@@ -78,11 +82,13 @@ function Select({
name,
defaultValue,
options,
hint,
}: {
label: string;
name: string;
defaultValue: string;
options: ReadonlyArray<readonly [string, string]>;
hint?: string;
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5">
@@ -99,6 +105,7 @@ function Select({
</option>
))}
</select>
{hint ? <span className="text-micro text-ink-3">{hint}</span> : null}
</label>
);
}
@@ -113,12 +120,14 @@ export function PersonalInfoPanel({
membershipId,
profile,
sensitive,
permit,
canEdit,
}: {
membershipId: string;
profile: ProfileFields;
/** Absent quand la capacité de lecture manque : la carte n'est pas rendue. */
sensitive: SensitiveFields | null;
permit: PermitFields;
canEdit: boolean;
}) {
const [editing, setEditing] = useState(false);
@@ -163,7 +172,7 @@ export function PersonalInfoPanel({
label="Nationalité"
name="nationality"
defaultValue={profile.nationality}
options={NATIONALITY_OPTIONS}
options={countryOptions()}
/>
<Field
label="Date de naissance"
@@ -175,15 +184,14 @@ export function PersonalInfoPanel({
label="Pays de naissance"
name="birthCountry"
defaultValue={profile.birthCountry}
options={COUNTRY_OPTIONS}
options={countryOptions()}
/>
{/* 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}
options={departmentOptions()}
hint="Pour une naissance à l’étranger, laissez vide."
/>
<Field
label="Commune de naissance"
@@ -215,8 +223,30 @@ export function PersonalInfoPanel({
type="email"
defaultValue={profile.personalEmail}
/>
<Field label="Téléphone mobile" name="phone" type="tel" defaultValue={profile.phone} />
<Field label="Téléphone fixe" name="landline" type="tel" defaultValue={profile.landline} />
<PhoneField
label="Téléphone mobile"
name="phone"
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>
<PhoneField
label="Téléphone fixe"
name="landline"
defaultValue={profile.landline}
/>
<Field label="Adresse" name="addressLine1" defaultValue={profile.addressLine1} />
<Field
label="Complément d’adresse"
@@ -231,7 +261,7 @@ export function PersonalInfoPanel({
label="Pays"
name="country"
defaultValue={profile.country}
options={COUNTRY_OPTIONS}
options={countryOptions()}
/>
</div>
</InfoCard>
@@ -243,10 +273,9 @@ export function PersonalInfoPanel({
name="emergencyContactName"
defaultValue={profile.emergencyContactName}
/>
<Field
<PhoneField
label="Téléphone"
name="emergencyContactPhone"
type="tel"
defaultValue={profile.emergencyContactPhone}
/>
</div>
@@ -273,9 +302,15 @@ 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={nationalityLabel(profile.nationality || null)} />
<InfoRow
label="Nationalité"
value={countryLabel(profile.nationality || null)}
/>
<InfoRow label="Date de naissance" value={readableDate(profile.birthDate)} tnum />
<InfoRow label="Pays de naissance" value={countryLabel(profile.birthCountry || null)} />
<InfoRow
label="Pays de naissance"
value={countryLabel(profile.birthCountry || null)}
/>
<InfoRow
label="Département de naissance"
value={departmentLabel(profile.birthDepartment || null)}
@@ -295,6 +330,10 @@ 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} />
@@ -319,6 +358,12 @@ export function PersonalInfoPanel({
</>
)}
<WorkPermitPanel
membershipId={membershipId}
permit={permit}
canEdit={canEdit}
/>
{sensitive ? (
<SensitivePanel
membershipId={membershipId}
@@ -399,3 +444,156 @@ function SensitivePanel({
</InfoCard>
);
}
export interface PermitFields {
foreignWorker: boolean;
permitType: string;
reference: string;
issuedAt: string;
expiresAt: string;
}
/**
* Autorisation de travail.
*
* Une seule case commande la section : la plupart des dossiers n'en relèvent
* pas, et poser quatre champs de titre de séjour devant tout le monde ferait
* d'une exception la règle de saisie.
*
* L'échéance est exigée dès qu'un titre est déclaré. Employer quelqu'un dont le
* titre a expiré est un délit ; sans date, rien ne peut le signaler à temps.
*/
function WorkPermitPanel({
membershipId,
permit,
canEdit,
}: {
membershipId: string;
permit: PermitFields;
canEdit: boolean;
}) {
const [state, save] = useActionState(updateWorkPermitAction, empty);
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
const [editing, setEditing] = useState(false);
const [foreign, setForeign] = useState(permit.foreignWorker);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
setEditing(false);
}
const expiry = permit.expiresAt
? new Date(`${permit.expiresAt}T00:00:00Z`)
: null;
const expired = expiry !== null && expiry < new Date();
return (
<InfoCard title="Autorisation de travail">
{editing ? (
<form action={save} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="membershipId" value={membershipId} />
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="foreignWorker"
checked={foreign}
onChange={(event) => setForeign(event.target.checked)}
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">
Travailleur étranger avec autorisation de travail
</span>
<span className="block text-micro text-ink-3">
Décocher retire le titre : il cesserait sinon d’alerter à son
échéance pour quelqu’un qui n’en relève plus.
</span>
</span>
</label>
{foreign ? (
<>
<Field
label="Nature du titre"
name="permitType"
required
defaultValue={permit.permitType}
hint="Carte de séjour, autorisation provisoire, récépissé…"
/>
<Field
label="Référence"
name="reference"
required
defaultValue={permit.reference}
/>
<div className="flex flex-wrap gap-3">
<Field
label="Délivré le"
name="issuedAt"
type="date"
defaultValue={permit.issuedAt}
/>
<Field
label="Expire le"
name="expiresAt"
type="date"
required
defaultValue={permit.expiresAt}
/>
</div>
</>
) : null}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer</SubmitButton>
<Button type="button" onClick={() => setEditing(false)}>
Annuler
</Button>
</div>
</form>
) : (
<>
<InfoRow
label="Travailleur étranger"
value={permit.foreignWorker ? 'Oui' : 'Non'}
/>
{permit.foreignWorker ? (
<>
<InfoRow label="Nature du titre" value={permit.permitType} />
<InfoRow label="Référence" value={permit.reference} />
<InfoRow
label="Délivré le"
value={readableDate(permit.issuedAt)}
tnum
/>
<InfoRow
label="Expire le"
value={readableDate(permit.expiresAt)}
tnum
/>
{expired ? (
<p
role="alert"
className="mt-3 rounded-2 border border-danger bg-danger-soft px-3 py-2 text-xs text-danger-soft-ink"
>
Ce titre a expiré. Employer une personne dont l’autorisation
n’est plus valide est un délit : régularisez avant de la
planifier.
</p>
) : null}
</>
) : null}
{canEdit ? (
<div className="pt-3">
<Button onClick={() => setEditing(true)}>Modifier</Button>
</div>
) : null}
</>
)}
</InfoCard>
);
}
+25 -3
View File
@@ -2,9 +2,11 @@ import { notFound } from 'next/navigation';
import {
PersonalInfoPanel,
type PermitFields,
type ProfileFields,
type SensitiveFields,
} from '@/app/(app)/equipe/[id]/PersonalInfoPanel';
import { toCountryCode } from '@/domain/hr/geo';
import { EmptyState } from '@/components/ui/Card';
import { getEmployee } from '@/server/employees/queries';
@@ -41,9 +43,9 @@ export default async function PersonalTab({
? employee.profile.birthDate.toISOString().slice(0, 10)
: '',
birthPlace: text(employee.profile.birthPlace),
birthCountry: text(employee.profile.birthCountry),
birthCountry: toCountryCode(employee.profile.birthCountry),
birthDepartment: text(employee.profile.birthDepartment),
nationality: text(employee.profile.nationality),
nationality: toCountryCode(employee.profile.nationality),
maritalStatus: text(employee.profile.maritalStatus),
dependents:
employee.profile.dependents === null
@@ -52,11 +54,12 @@ 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),
city: text(employee.profile.city),
country: text(employee.profile.country),
country: toCountryCode(employee.profile.country),
emergencyContactName: text(employee.profile.emergencyContactName),
emergencyContactPhone: text(employee.profile.emergencyContactPhone),
};
@@ -71,11 +74,30 @@ export default async function PersonalTab({
}
: null;
const permit: PermitFields = employee.workPermit
? {
foreignWorker: true,
permitType: employee.workPermit.permitType,
reference: employee.workPermit.reference,
issuedAt: employee.workPermit.issuedAt
? employee.workPermit.issuedAt.toISOString().slice(0, 10)
: '',
expiresAt: employee.workPermit.expiresAt.toISOString().slice(0, 10),
}
: {
foreignWorker: false,
permitType: '',
reference: '',
issuedAt: '',
expiresAt: '',
};
return (
<PersonalInfoPanel
membershipId={employee.id}
profile={profile}
sensitive={sensitive}
permit={permit}
canEdit={employee.canEdit}
/>
);
+1 -1
View File
@@ -276,7 +276,7 @@ function EmployeeHome() {
</li>
<li>
<Link
href="/absences/calendrier"
href="/conges"
className="flex h-full flex-col gap-1 rounded-3 border border-line-1 bg-surface p-4 hover:bg-surface-2"
>
<span className="text-sm font-medium text-ink-1">
-219
View File
@@ -1,219 +0,0 @@
import Link from 'next/link';
import { PrintButton } from '@/components/planning/PrintButton';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { can } from '@/domain/access/authorize';
import { formatMinutes } from '@/domain/counters/week';
import { displayName, type BoardRow } from '@/domain/planning/board';
import { absenceOnDay } from '@/domain/planning/presence';
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
import { isPosteCode, posteTokens } from '@/lib/design/postes';
import { requireSession } from '@/server/context';
import { getWeekBoard } from '@/server/planning/queries';
export const metadata = { title: 'Planning · impression · PlanFlow' };
/**
* Planning à afficher — PLAN.md §9, `/planning/impression`.
*
* Ce n'est pas la grille d'édition avec les boutons cachés. C'est une mise en
* page faite pour la feuille punaisée en salle de pause : toutes les équipes à
* la suite, pas de panneau d'édition, pas de compteurs — et un seul aplat par
* créneau, avec l'horaire et le code du poste écrits en toutes lettres.
*
* Rien n'est recalculé : le même `getWeekBoard` que les cinq vues. Une feuille
* imprimée qui contredit l'écran est pire qu'une absence de feuille, parce
* qu'elle survit à la correction.
*
* Le rendu PDF passe par la boîte d'impression du navigateur (voir
* `PrintButton`) : un second moteur de rendu serait un second endroit où la
* grille peut diverger.
*/
interface PageProps {
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
}
function ShiftLine({ shift }: { shift: BoardRow['days'][number][number] }) {
const tokens = isPosteCode(shift.poste) ? posteTokens(shift.poste) : null;
return (
<li
className="print-keep rounded-2 border px-1.5 py-1 text-micro leading-tight"
style={
tokens
? {
background: tokens.bg,
color: tokens.fg,
borderColor: tokens.edge,
}
: undefined
}
>
<span className="block font-semibold tnum">{shift.time}</span>
<span className="block">{shift.poste.toUpperCase()}</span>
{shift.breakMinutes > 0 ? (
<span className="block tnum">pause {shift.breakMinutes} min</span>
) : null}
</li>
);
}
export default async function ImpressionPage({ searchParams }: PageProps) {
const params = await searchParams;
const session = await requireSession();
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
const board = await getWeekBoard(week, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Impression"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
// Sans la capacité de voir le non publié, la feuille ne doit pas la
// contourner : imprimer un brouillon revient à le publier sur un mur.
const seeUnpublished = can(session.actor, 'planning.view_unpublished');
const sections = board.sections.filter(
(section) =>
!section.hidden && (seeUnpublished || section.status === 'PUBLISHED'),
);
const hiddenDrafts = board.sections.filter(
(section) => !section.hidden && section.status !== 'PUBLISHED',
).length;
return (
<PageBody>
<div data-print="hide">
<PageHeader
title={`Planning à afficher · semaine ${week.isoWeek}`}
subtitle={`${board.location.name} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
actions={
<>
<Link
href={`/planning/semaine?semaine=${board.weekParam}&etablissement=${board.location.id}`}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Retour à la grille
</Link>
<PrintButton label="Imprimer la semaine" />
</>
}
/>
</div>
<p className="text-sm text-ink-2">
{board.location.name} — semaine {week.isoWeek}, {board.label}
</p>
{hiddenDrafts > 0 && seeUnpublished ? (
<p
data-print="hide"
role="status"
className="rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs text-warn-soft-ink"
>
{hiddenDrafts} équipe{hiddenDrafts > 1 ? 's' : ''} non publiée
{hiddenDrafts > 1 ? 's' : ''} figure
{hiddenDrafts > 1 ? 'nt' : ''} sur cette feuille. Un planning affiché
en salle vaut information des salariés : publiez avant d’imprimer.
</p>
) : null}
{sections.length === 0 ? (
<Card>
<EmptyState
title="Rien à imprimer"
description="Aucune équipe publiée sur cette semaine."
/>
</Card>
) : null}
{sections.map((section) => (
<Card key={section.teamId} className="print-keep">
<CardHeader
title={section.teamName}
badge={
section.status === 'PUBLISHED' ? (
<Badge tone="ok">Publié</Badge>
) : (
<Badge tone="warn">Brouillon</Badge>
)
}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[1040px] text-sm">
<thead>
<tr className="border-b border-line-1">
<th className="w-48 px-3 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Salarié
</th>
{board.headings.map((heading) => (
<th
key={heading}
className="px-2 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{[
...section.rows,
...(section.unassignedRow ? [section.unassignedRow] : []),
].map((row) => (
<tr key={row.membershipId ?? 'non-assignes'} className="print-keep">
<th className="px-3 py-2 text-left align-top font-medium">
{displayName(row)}
<span className="block text-micro font-normal text-ink-3">
{row.unassigned ? 'À pourvoir' : row.job}
</span>
</th>
{board.dates.map((date, index) => {
const absence = absenceOnDay(row, index);
const shifts = row.days[index] ?? [];
return (
<td key={date} className="px-2 py-2 align-top">
{absence ? (
<span className="block rounded-2 border border-line-2 bg-surface-2 px-1.5 py-1 text-micro">
{absence.label}
</span>
) : shifts.length === 0 ? (
<span className="text-micro text-ink-3">—</span>
) : (
<ul className="flex flex-col gap-1">
{shifts.map((shift) => (
<ShiftLine key={shift.id} shift={shift} />
))}
</ul>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</Card>
))}
<p className="text-micro text-ink-3">
Édité depuis PlanFlow · {board.location.name} · semaine {week.isoWeek}.
Les horaires font foi tels qu’ils étaient à l’édition ; une modification
postérieure du planning n’apparaît pas sur cette feuille.
</p>
</PageBody>
);
}
-259
View File
@@ -1,259 +0,0 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge, type Tone } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { formatMinutes } from '@/domain/counters/week';
import { displayName } from '@/domain/planning/board';
import {
absenceOnDay,
dayState,
headcountByDay,
type DayState,
} from '@/domain/planning/presence';
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
import { cx } from '@/lib/cx';
import { getWeekBoard } from '@/server/planning/queries';
export const metadata = { title: 'Planning · présences · PlanFlow' };
/**
* Vue des présences et absences — cinquième vue de §9.
*
* Elle répond à une question que les quatre autres posent mal : **qui est là
* aujourd'hui ?** La grille hebdomadaire montre des créneaux, pas des
* personnes ; il faut la lire colonne par colonne et compter de tête.
*
* Elle lit le **même modèle** que les autres vues, sans requête propre : une
* modification faite dans la grille est correcte ici à l'instant même. Une vue
* qui recalcule ses présences de son côté finit par contredire le planning
* qu'elle prétend résumer.
*/
interface PageProps {
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
}
const STATE_TONE: Record<DayState, Tone> = {
present: 'ok',
absent: 'warn',
off: 'neutral',
};
const STATE_LABEL: Record<DayState, string> = {
present: 'Présent',
absent: 'Absent',
off: 'Repos',
};
/**
* État d'un salarié un jour donné.
*
* L'absence l'emporte sur le créneau : un salarié dont l'absence a été acceptée
* après la construction du planning garde des créneaux en base, et l'afficher
* « présent » enverrait un manager le chercher en rayon.
*/
export default async function PresencesPage({ searchParams }: PageProps) {
const params = await searchParams;
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
const board = await getWeekBoard(week, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Présences et absences"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
const href = (semaine: string, etablissement = board.location.id) =>
`/planning/presences?semaine=${semaine}&etablissement=${etablissement}`;
// Les besoins non couverts n'ont personne à compter : ils appartiennent à la
// grille, pas à un état de présence.
const sections = board.sections
.filter((section) => !section.hidden)
.map((section) => ({
...section,
rows: section.rows.filter((row) => !row.unassigned),
}));
const perDay = headcountByDay(
sections.flatMap((section) => section.rows),
board.dates.length,
).map((tally, index) => ({ date: board.dates[index] as string, ...tally }));
return (
<PageBody>
<PageHeader
title={`Présences · semaine ${week.isoWeek}`}
subtitle={`${board.location.name} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
actions={
<>
<Link
href={href(board.previousParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Semaine précédente
</Link>
<Link
href={href(board.nextParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Semaine suivante →
</Link>
</>
}
/>
<Card>
<CardHeader title="Effectif présent par jour" />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1">
<th className="px-4 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Jour
</th>
{board.headings.map((heading) => (
<th
key={heading}
className="px-3 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
<tr>
<th className="px-4 py-2 text-left font-medium">Présents</th>
{perDay.map((day) => (
<td key={day.date} className="px-3 py-2 text-center tnum">
{day.present}
</td>
))}
</tr>
<tr>
<th className="px-4 py-2 text-left font-medium">Absents</th>
{perDay.map((day) => (
<td
key={day.date}
className={cx(
'px-3 py-2 text-center tnum',
day.absent > 0 ? 'text-warn-soft-ink' : 'text-ink-3',
)}
>
{day.absent}
</td>
))}
</tr>
<tr>
<th className="px-4 py-2 text-left font-medium">Repos</th>
{perDay.map((day) => (
<td
key={day.date}
className="px-3 py-2 text-center tnum text-ink-3"
>
{day.off}
</td>
))}
</tr>
</tbody>
</table>
</div>
</Card>
{sections.length === 0 ? (
<Card>
<EmptyState
title="Aucune équipe"
description="Créez une équipe et rattachez-y des salariés pour suivre les présences."
/>
</Card>
) : null}
{sections.map((section) => (
<Card key={section.teamId}>
<CardHeader
title={section.teamName}
badge={
<Badge
tone={section.status === 'PUBLISHED' ? 'ok' : 'warn'}
>
{section.status === 'PUBLISHED' ? 'Publié' : 'Non publié'}
</Badge>
}
/>
{section.rows.length === 0 ? (
<EmptyState title="Aucun salarié rattaché" />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1">
<th className="px-4 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Salarié
</th>
{board.headings.map((heading) => (
<th
key={heading}
className="px-3 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{section.rows.map((row) => (
<tr key={row.membershipId ?? displayName(row)}>
<th className="px-4 py-2 text-left font-medium">
{row.membershipId ? (
<Link
href={`/equipe/${row.membershipId}`}
className="hover:underline"
>
{displayName(row)}
</Link>
) : (
displayName(row)
)}
<span className="block text-micro font-normal text-ink-3">
{row.job}
</span>
</th>
{board.dates.map((date, index) => {
const state = dayState(row, index);
const absence = absenceOnDay(row, index);
return (
<td key={date} className="px-3 py-2 text-center">
<Badge tone={STATE_TONE[state]}>
{absence ? absence.label : STATE_LABEL[state]}
</Badge>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
))}
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Une absence acceptée l’emporte sur les créneaux restés au planning : un
salarié dont le congé a été validé après la construction de la semaine
apparaît absent, même si ses créneaux n’ont pas encore été retirés.
L’inverse enverrait un responsable le chercher en rayon.
</p>
</PageBody>
);
}
+3 -21
View File
@@ -100,28 +100,10 @@ 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 ? (
<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>
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Cet établissement n’a pas encore d’équipe.
</p>
) : null}
{board.sections.map((section) => (
-197
View File
@@ -1,197 +0,0 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { getActivity } from '@/server/reports/activity';
export const metadata = { title: 'Journal d’activité · PlanFlow' };
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{
domaine?: string;
objet?: string;
du?: string;
au?: string;
}>;
}
const stamp = new Intl.DateTimeFormat('fr-FR', {
dateStyle: 'short',
timeStyle: 'short',
});
export default async function ActivitePage({ searchParams }: PageProps) {
const params = await searchParams;
// `exactOptionalPropertyTypes` : un filtre absent ne se transmet pas comme
// « undefined », il ne se transmet pas du tout.
const page = await getActivity({
...(params.domaine ? { domain: params.domaine } : {}),
...(params.objet ? { entityType: params.objet } : {}),
...(params.du ? { from: params.du } : {}),
...(params.au ? { to: params.au } : {}),
});
const withParam = (key: string, value: string | undefined) => {
const next = new URLSearchParams();
for (const [name, current] of Object.entries(params)) {
if (current) next.set(name, current);
}
if (value) next.set(key, value);
else next.delete(key);
const qs = next.toString();
return qs ? `/rapports/activite?${qs}` : '/rapports/activite';
};
const chip = (href: string, label: string, active: boolean) => (
<Link
key={`${href}-${label}`}
href={href}
aria-current={active ? 'page' : undefined}
className={
active
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
}
>
{label}
</Link>
);
return (
<PageBody>
<PageHeader
title="Journal d’activité"
subtitle={`${page.total} entrée${page.total > 1 ? 's' : ''} sur la période`}
/>
<Card>
<CardHeader
title="Filtres"
badge={
page.truncated ? (
<Badge tone="warn">200 dernières affichées</Badge>
) : null
}
/>
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex flex-wrap items-center gap-1">
{chip(withParam('domaine', undefined), 'Tous les domaines', !params.domaine)}
{page.domains.map((domain) =>
chip(
withParam('domaine', domain),
domain,
params.domaine === domain,
),
)}
</div>
<div className="flex flex-wrap items-center gap-1">
{chip(withParam('objet', undefined), 'Tous les objets', !params.objet)}
{page.entityTypes.map((type) =>
chip(withParam('objet', type), type, params.objet === type),
)}
</div>
<form className="flex flex-wrap items-end gap-3" method="get">
{params.domaine ? (
<input type="hidden" name="domaine" value={params.domaine} />
) : null}
{params.objet ? (
<input type="hidden" name="objet" value={params.objet} />
) : null}
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Du</span>
<input
type="date"
name="du"
defaultValue={params.du}
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Au</span>
<input
type="date"
name="au"
defaultValue={params.au}
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1"
/>
</label>
<button
type="submit"
className="h-9 rounded-2 border border-line-3 px-3 text-sm hover:bg-surface-2"
>
Appliquer
</button>
</form>
</div>
</Card>
<Card>
<CardHeader title="Entrées" />
{page.entries.length === 0 ? (
<EmptyState
title="Aucune entrée"
description="Aucune action enregistrée pour ces filtres."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{['Horodatage', 'Auteur', 'Action', 'Objet', 'Champs', 'Justification'].map(
(heading) => (
<th
key={heading}
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
),
)}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{page.entries.map((entry) => (
<tr key={entry.id}>
<td className="px-4 py-2 tnum whitespace-nowrap">
{stamp.format(entry.occurredAt)}
</td>
<td className="px-4 py-2">{entry.actorName}</td>
<td className="px-4 py-2 font-mono text-xs">{entry.action}</td>
<td className="px-4 py-2 text-ink-2">
{entry.entityType}
<span className="block font-mono text-micro text-ink-3">
{entry.entityId}
</span>
</td>
<td className="px-4 py-2 text-xs text-ink-2">
{entry.changedFields.length === 0
? '—'
: entry.changedFields.join(', ')}
</td>
<td className="px-4 py-2 text-xs text-ink-2">
{entry.reason ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Le journal enregistre l’avant et l’après de chaque mutation — c’est ce
qui le rend opposable. Cet écran n’affiche que le <em>nom</em> des champs
modifiés : la valeur se lit sur la fiche concernée, sous ses propres
autorisations. Un salaire ou un motif médical n’a pas à être visible de
quiconque ouvre la piste d’audit.
</p>
</PageBody>
);
}
-44
View File
@@ -1,44 +0,0 @@
import Link from 'next/link';
import { AccountIdentityForm } from '@/components/settings/AccountIdentityForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { getAccountIdentity } from '@/server/settings/account';
export const metadata = { title: 'Informations du compte · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ComptePage() {
const account = await getAccountIdentity();
return (
<PageBody>
<PageHeader
title="Informations du compte"
subtitle="Identité de l’entreprise et siège social"
/>
{account === null ? (
<Card>
<EmptyState title="Compte introuvable" />
</Card>
) : (
<Card>
<CardHeader title={account.name} />
<AccountIdentityForm account={account} />
</Card>
)}
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Le siège n’est pas un établissement : il porte l’identité juridique, là
où{' '}
<Link href="/reglages/etablissements" className="underline">
les établissements
</Link>{' '}
portent le SIRET, le fuseau effectif et les plannings. Une entreprise
dont le siège n’ouvre aucun magasin garde donc une adresse ici et aucune
équipe.
</p>
</PageBody>
);
}
@@ -1,187 +0,0 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import type { AgreementParameters } from '@/domain/compliance/parameters';
import {
publishAgreementVersionAction,
type ActionState,
} from '@/server/settings/agreement';
const empty: ActionState = {};
function Group({
title,
note,
children,
}: {
title: string;
note?: string;
children: React.ReactNode;
}) {
return (
<fieldset className="flex flex-col gap-2 border-t border-line-1 pt-3">
<legend className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
{title}
</legend>
{note ? <p className="text-micro text-ink-3">{note}</p> : null}
<div className="flex flex-wrap gap-3">{children}</div>
</fieldset>
);
}
function Area({
label,
name,
defaultValue,
hint,
}: {
label: string;
name: string;
defaultValue: string;
hint: string;
}) {
return (
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
<textarea
name={name}
rows={4}
defaultValue={defaultValue}
className="rounded-2 border border-line-2 bg-surface px-3 py-2 font-mono text-xs text-ink-1 outline-none focus-visible:border-focus"
/>
<span className="text-micro text-ink-3">{hint}</span>
</label>
);
}
export function AgreementForm({
idcc,
name,
parameters,
}: {
idcc: string;
name: string;
parameters: AgreementParameters;
}) {
const [state, action] = useActionState(publishAgreementVersionAction, empty);
const p = parameters;
const tiers = p.overtime.tiers
.map((tier) => `${tier.fromMinutes};${tier.toMinutes ?? ''};${tier.ratePercent}`)
.join('\n');
const derogations = p.partTime.derogations
.map((row) => `${row.code};${row.label};${row.minWeeklyMinutes}`)
.join('\n');
return (
<PersistentForm action={action} className="flex flex-col gap-4 p-4">
<Group title="Version" note="Les durées sont en minutes entières.">
<Field label="IDCC" name="idcc" defaultValue={idcc} required maxLength={10} />
<Field label="Intitulé" name="name" defaultValue={name} required maxLength={160} />
<Field
label="En vigueur à partir du"
name="effectiveFrom"
type="date"
required
hint="La version précédente reste applicable aux semaines antérieures."
/>
<Field
label="Source"
name="source"
required
maxLength={400}
placeholder="Legifrance, texte consolidé au…"
hint="Sans source, le paramètre n’est pas opposable."
/>
<Field label="Approuvé par" name="approvedBy" maxLength={160} />
</Group>
<Group title="Durées">
<Field label="Référence hebdomadaire" name="weeklyReferenceMinutes" type="number" defaultValue={p.weeklyReferenceMinutes} required />
<Field label="Maximum quotidien" name="maxDailyWorkMinutes" type="number" defaultValue={p.maxDailyWorkMinutes} required />
<Field
label="Amplitude quotidienne"
name="maxDailyAmplitudeMinutes"
type="number"
defaultValue={p.maxDailyAmplitudeMinutes ?? ''}
hint="Vide : la convention n’en fixe pas, la règle reste muette."
/>
<Field label="Repos quotidien minimal" name="minDailyRestMinutes" type="number" defaultValue={p.minDailyRestMinutes} required />
<Field label="Repos hebdomadaire minimal" name="minWeeklyRestMinutes" type="number" defaultValue={p.minWeeklyRestMinutes} required />
<Field label="Maximum hebdomadaire absolu" name="maxWeeklyWorkAbsoluteMinutes" type="number" defaultValue={p.maxWeeklyWorkAbsoluteMinutes} required />
<Field label="Fenêtre de moyenne (semaines)" name="averagedWindowWeeks" type="number" defaultValue={p.averagedWeeklyWork.windowWeeks} required />
<Field label="Moyenne maximale" name="averagedMaxMinutes" type="number" defaultValue={p.averagedWeeklyWork.maxAverageMinutes} required />
<Field label="Jours consécutifs maximum" name="maxConsecutiveWorkDays" type="number" defaultValue={p.maxConsecutiveWorkDays} required />
<Field label="Seuil de pause" name="breakThresholdMinutes" type="number" defaultValue={p.breakAfterThreshold.thresholdMinutes} required />
<Field label="Pause minimale" name="breakMinMinutes" type="number" defaultValue={p.breakAfterThreshold.minBreakMinutes} required />
<Field label="Tolérance d’écart au contrat" name="deviationToleranceMinutes" type="number" defaultValue={p.contractDeviationToleranceMinutes} required />
</Group>
<Group title="Temps partiel">
<Field label="Minimum hebdomadaire" name="partTimeMinWeeklyMinutes" type="number" defaultValue={p.partTime.minWeeklyMinutes} required />
<Area
label="Dérogations"
name="derogations"
defaultValue={derogations}
hint="Une par ligne : code;libellé;minutes."
/>
</Group>
<Group title="Heures supplémentaires">
<Area
label="Tranches"
name="overtimeTiers"
defaultValue={tiers}
hint="Une par ligne : début;fin;taux. Fin vide = sans plafond. Cumulatives depuis le début de la semaine."
/>
<Field label="Contingent annuel" name="overtimeQuotaMinutes" type="number" defaultValue={p.overtime.annualQuotaMinutes} required />
<Field label="Repos au-delà du contingent (%)" name="overtimeBeyondRestPercent" type="number" step="0.01" defaultValue={p.overtime.beyondQuotaRestPercent} required />
</Group>
<Group title="Heures complémentaires">
<Field label="Taux première tranche (%)" name="complementaryFirstRate" type="number" step="0.01" defaultValue={p.complementaryHours.firstTierRatePercent} required />
<Field label="Taux au-delà (%)" name="complementaryBeyondRate" type="number" step="0.01" defaultValue={p.complementaryHours.beyondRatePercent} required />
<Field label="Fraction première tranche" name="complementaryFirstFraction" type="number" step="0.01" defaultValue={p.complementaryHours.firstTierFraction} required />
<Field label="Plafond (fraction)" name="complementaryCapFraction" type="number" step="0.01" defaultValue={p.complementaryHours.capFraction} required />
</Group>
<Group
title="Dimanche"
note="La majoration et le repos compensateur vont ensemble : l’un sans l’autre est un manquement à L3132-27."
>
<Field label="Majoration (%)" name="sundayPremiumPercent" type="number" step="0.01" defaultValue={p.sunday.premiumPercent} required />
<Field label="Repos compensateur (%)" name="sundayRestPercent" type="number" step="0.01" defaultValue={p.sunday.compensatoryRestPercent} required />
<Field label="Dimanches du maire par an" name="sundayMayorQuota" type="number" defaultValue={p.sunday.mayorQuotaPerYear} required />
</Group>
<Group title="Jours fériés">
<Field label="Indemnité si travaillé (%)" name="holidayWorkedPremium" type="number" step="0.01" defaultValue={p.holiday.workedPremiumPercent} required />
<Field label="1er mai travaillé (%)" name="holidayLabourDayPremium" type="number" step="0.01" defaultValue={p.holiday.labourDayPremiumPercent} required />
<Field label="Fériés chômés garantis" name="holidayPaidOffDays" type="number" defaultValue={p.holiday.paidOffDaysPerYear} required />
<Field label="Repos de substitution (%)" name="holidaySubstitutionRest" type="number" step="0.01" defaultValue={p.holiday.substitutionRestPercent} required />
</Group>
<Group title="Nuit et forfait jours">
<Field label="Début de nuit (min. depuis minuit)" name="nightStartMinutes" type="number" defaultValue={p.night.startMinutes} required />
<Field label="Fin de nuit" name="nightEndMinutes" type="number" defaultValue={p.night.endMinutes} required />
<Field label="Non imposable à partir de" name="nightNotEnforceableFromAge" type="number" defaultValue={p.night.notEnforceableFromAge} required />
<Field label="Jours de forfait maximum" name="forfaitMaxDaysPerYear" type="number" defaultValue={p.forfaitJours.maxDaysPerYear} required />
<Field label="Entretien de charge (mois)" name="forfaitReviewMonths" type="number" defaultValue={p.forfaitJours.workloadReviewIntervalMonths} required />
</Group>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Publier une nouvelle version</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">
Version enregistrée. Les semaines antérieures gardent la précédente.
</span>
) : null}
</div>
</PersistentForm>
);
}
-145
View File
@@ -1,145 +0,0 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { AgreementForm } from '@/app/(app)/reglages/convention/AgreementForm';
import {
IDCC_1517_PROVENANCE,
type ParameterOrigin,
} from '@/domain/compliance/idcc1517';
import { listAgreementVersions } from '@/server/settings/agreement';
export const metadata = { title: 'Convention collective · PlanFlow' };
export const dynamic = 'force-dynamic';
const ORIGIN_TONE: Record<ParameterOrigin, 'danger' | 'info' | 'warn'> = {
OP: 'danger',
CCN: 'info',
ENT: 'warn',
};
const ORIGIN_LABEL: Record<ParameterOrigin, string> = {
OP: 'Ordre public',
CCN: 'Convention',
ENT: 'Accord d’entreprise',
};
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
export default async function ConventionPage() {
const versions = await listAgreementVersions();
const current = versions.find((version) => version.isCurrent) ?? versions[0];
return (
<PageBody>
<PageHeader
title="Convention collective"
subtitle={
current
? `IDCC ${current.idcc} · version ${current.version}`
: 'Aucune convention chargée'
}
/>
{!current ? (
<Card>
<EmptyState
title="Aucune convention"
description="Le moteur de règles lit ses seuils en base. Sans convention chargée, aucune règle horaire ne s’applique."
/>
</Card>
) : null}
<Card>
<CardHeader
title="Origine des valeurs"
badge={<Badge tone="neutral">{IDCC_1517_PROVENANCE.length}</Badge>}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Paramètre
</th>
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Valeur
</th>
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Origine
</th>
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Source
</th>
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{IDCC_1517_PROVENANCE.map((entry) => (
<tr key={entry.key}>
<td className="px-4 py-2">{entry.label}</td>
<td className="px-4 py-2 tnum">{entry.value}</td>
<td className="px-4 py-2">
<Badge tone={ORIGIN_TONE[entry.origin]}>
{ORIGIN_LABEL[entry.origin]}
</Badge>
</td>
<td className="px-4 py-2 text-ink-2">{entry.source}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="border-t border-line-1 px-4 py-3 text-xs leading-[var(--lh-prose)] text-ink-3">
Une valeur d’ordre public ne se négocie pas ; une valeur
conventionnelle change si la convention change ; une valeur d’accord
d’entreprise suppose un accord signé. Confondre les trois fait
apparaître comme discutable ce qui s’impose, et l’inverse.
</p>
</Card>
<Card>
<CardHeader
title="Versions"
badge={<Badge tone="neutral">{versions.length}</Badge>}
/>
{versions.length === 0 ? (
<EmptyState title="Aucune version enregistrée" />
) : (
<ul className="divide-y divide-line-1">
{versions.map((version) => (
<li key={version.id} className="flex flex-wrap items-center gap-2 px-4 py-3">
<Badge tone={version.isCurrent ? 'ok' : 'neutral'}>
v{version.version}
</Badge>
<span className="text-sm font-medium">{version.name}</span>
<span className="text-sm text-ink-2">
en vigueur au {dateFormat.format(version.effectiveFrom)}
</span>
{version.isCurrent ? <Badge tone="accent">Applicable</Badge> : null}
<span className="flex-1" />
<span className="text-xs text-ink-3">
{version.approvedBy
? `Approuvée par ${version.approvedBy}`
: 'Non approuvée'}
</span>
</li>
))}
</ul>
)}
</Card>
{current ? (
<Card>
<CardHeader
title="Éditer les paramètres"
badge={<Badge tone="warn">crée une version</Badge>}
/>
<AgreementForm
idcc={current.idcc}
name={current.name}
parameters={current.parameters}
/>
</Card>
) : null}
</PageBody>
);
}
@@ -1,67 +0,0 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createJobTitleAction,
renameJobTitleAction,
type ActionState,
} from '@/server/settings/job-titles';
const empty: ActionState = {};
export function AddJobTitleForm() {
const [state, action] = useActionState(createJobTitleAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<div className="flex flex-wrap items-end gap-3">
<Field
label="Intitulé"
name="name"
required
maxLength={120}
placeholder="Hôte de caisse"
hint="L’intitulé figure au registre unique du personnel et sur le contrat."
/>
<SubmitButton>Ajouter l’emploi</SubmitButton>
</div>
<FormError>{state.error}</FormError>
{state.ok ? (
<p className="text-xs text-ok-soft-ink">Emploi enregistré.</p>
) : null}
</PersistentForm>
);
}
export function RenameJobTitleForm({
id,
name,
}: {
id: string;
name: string;
}) {
const [state, action] = useActionState(renameJobTitleAction, empty);
return (
<PersistentForm action={action} className="flex flex-1 items-end gap-2">
<input type="hidden" name="id" value={id} />
<Field
label="Intitulé"
name="name"
defaultValue={name}
required
maxLength={120}
className="h-8"
/>
<SubmitButton size="sm">Renommer</SubmitButton>
<FormError>{state.error}</FormError>
</PersistentForm>
);
}
-103
View File
@@ -1,103 +0,0 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddJobTitleForm,
RenameJobTitleForm,
} from '@/app/(app)/reglages/emplois/JobTitleForms';
import {
archiveJobTitleAction,
listJobTitles,
} from '@/server/settings/job-titles';
export const metadata = { title: 'Emplois · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function EmploisPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const titles = await listJobTitles(showArchived);
const active = titles.filter((title) => title.archivedAt === null);
return (
<PageBody>
<PageHeader
title="Emplois"
subtitle={`${active.length} emploi${active.length > 1 ? 's' : ''} en vigueur`}
/>
<Card>
<CardHeader
title="Référentiel"
badge={
<Badge tone="neutral">
{titles.length} affiché{titles.length > 1 ? 's' : ''}
</Badge>
}
action={
<a
href={showArchived ? '/reglages/emplois' : '/reglages/emplois?archives=1'}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
</a>
}
/>
{titles.length === 0 ? (
<EmptyState
title="Aucun emploi"
description="Un contrat porte une qualification ; créez d’abord les emplois de l’organisation."
/>
) : (
<ul className="divide-y divide-line-1">
{titles.map((title) => (
<li
key={title.id}
className="flex flex-wrap items-end gap-3 px-4 py-3"
>
<RenameJobTitleForm id={title.id} name={title.name} />
<div className="flex items-center gap-2">
<Badge tone="neutral">
{title.contractCount} contrat
{title.contractCount > 1 ? 's' : ''}
</Badge>
{title.archivedAt ? <Badge tone="warn">Archivé</Badge> : null}
</div>
<form action={archiveJobTitleAction}>
<input type="hidden" name="id" value={title.id} />
{title.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{title.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouvel emploi" />
<div className="p-4">
<AddJobTitleForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Un emploi porté par un contrat est archivé, jamais supprimé : le registre
unique du personnel doit rester lisible pendant toute la durée de
conservation, y compris pour un salarié parti.
</p>
</PageBody>
);
}
@@ -1,85 +0,0 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import { POSTE_CODES, POSTE_LABELS } from '@/lib/design/postes';
import {
createLabelAction,
updateLabelAction,
type ActionState,
} from '@/server/settings/labels';
const empty: ActionState = {};
function PaletteSelect({ defaultValue }: { defaultValue?: string }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Teinte</span>
<select
name="paletteKey"
defaultValue={defaultValue ?? POSTE_CODES[0]}
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
{POSTE_CODES.map((code) => (
<option key={code} value={code}>
{POSTE_LABELS[code]}
</option>
))}
</select>
</label>
);
}
export function AddLabelForm() {
const [state, action] = useActionState(createLabelAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<div className="flex flex-wrap items-end gap-3">
<Field
label="Code"
name="code"
required
maxLength={12}
placeholder="cai"
hint="Affiché sur le bloc de planning : la couleur ne porte jamais l’information seule."
/>
<Field label="Libellé" name="name" required maxLength={120} placeholder="Caisse" />
<PaletteSelect />
<SubmitButton>Ajouter l’étiquette</SubmitButton>
</div>
<FormError>{state.error}</FormError>
{state.ok ? (
<p className="text-xs text-ok-soft-ink">Étiquette enregistrée.</p>
) : null}
</PersistentForm>
);
}
export function EditLabelForm({
id,
name,
paletteKey,
}: {
id: string;
name: string;
paletteKey: string;
}) {
const [state, action] = useActionState(updateLabelAction, empty);
return (
<PersistentForm action={action} className="flex flex-1 flex-wrap items-end gap-2">
<input type="hidden" name="id" value={id} />
<Field label="Libellé" name="name" defaultValue={name} required maxLength={120} />
<PaletteSelect defaultValue={paletteKey} />
<SubmitButton size="sm">Enregistrer</SubmitButton>
<FormError>{state.error}</FormError>
</PersistentForm>
);
}
-125
View File
@@ -1,125 +0,0 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddLabelForm,
EditLabelForm,
} from '@/app/(app)/reglages/etiquettes/LabelForms';
import { isPosteCode, posteTokens } from '@/lib/design/postes';
import { archiveLabelAction, listLabels } from '@/server/settings/labels';
export const metadata = { title: 'Étiquettes · PlanFlow' };
export const dynamic = 'force-dynamic';
function Swatch({ paletteKey, code }: { paletteKey: string; code: string }) {
if (!isPosteCode(paletteKey)) {
return <Badge tone="warn">teinte inconnue</Badge>;
}
const tokens = posteTokens(paletteKey);
return (
<span
className="inline-flex h-6 shrink-0 items-center rounded-2 border px-2 text-micro font-semibold"
style={{
background: tokens.bg,
color: tokens.fg,
borderColor: tokens.edge,
}}
>
{code.toUpperCase()}
</span>
);
}
export default async function EtiquettesPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const labels = await listLabels(showArchived);
const active = labels.filter((label) => label.archivedAt === null);
return (
<PageBody>
<PageHeader
title="Étiquettes"
subtitle={`${active.length} étiquette${active.length > 1 ? 's' : ''} en vigueur`}
/>
<Card>
<CardHeader
title="Postes de la grille"
action={
<a
href={
showArchived
? '/reglages/etiquettes'
: '/reglages/etiquettes?archives=1'
}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivées' : 'Voir les archivées'}
</a>
}
/>
{labels.length === 0 ? (
<EmptyState
title="Aucune étiquette"
description="Une étiquette colore le créneau et nomme le poste tenu. Créez-en une pour commencer."
/>
) : (
<ul className="divide-y divide-line-1">
{labels.map((label) => (
<li
key={label.id}
className="flex flex-wrap items-end gap-3 px-4 py-3"
>
<Swatch paletteKey={label.paletteKey} code={label.code} />
<EditLabelForm
id={label.id}
name={label.name}
paletteKey={label.paletteKey}
/>
<div className="flex items-center gap-2">
<Badge tone="neutral">
{label.shiftCount} créneau{label.shiftCount > 1 ? 'x' : ''}
</Badge>
{label.archivedAt ? <Badge tone="warn">Archivée</Badge> : null}
</div>
<form action={archiveLabelAction}>
<input type="hidden" name="id" value={label.id} />
{label.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{label.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouvelle étiquette" />
<div className="p-4">
<AddLabelForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Les douze teintes viennent d’une palette calculée pour rester
distinguables en vision daltonienne et à l’impression en noir et blanc.
C’est pourquoi le code du poste s’affiche toujours avec la couleur, et
pourquoi la teinte se choisit dans la liste plutôt qu’à la pipette.
</p>
</PageBody>
);
}
@@ -1,102 +0,0 @@
'use client';
import { useActionState } from 'react';
import { FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import { PRINT_DENSITIES, togglesOf } from '@/domain/settings/preferences';
import {
savePreferencesAction,
type ActionState,
type Preferences,
} from '@/server/settings/account';
const empty: ActionState = {};
export function PrintForm({ preferences }: { preferences: Preferences }) {
const [state, action] = useActionState(savePreferencesAction, empty);
const toggles = togglesOf('print');
return (
<PersistentForm action={action} className="flex flex-col gap-4 px-4 py-4">
{toggles.map((toggle) => (
<input key={`scope-${toggle.key}`} type="hidden" name="scope" value={toggle.key} />
))}
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium">Orientation</legend>
<p className="text-micro text-ink-3">
Une semaine se lit en largeur : le paysage évite de couper le
dimanche sur une seconde page.
</p>
{[
{ value: 'landscape', label: 'Paysage' },
{ value: 'portrait', label: 'Portrait' },
].map((option) => (
<label key={option.value} className="flex items-center gap-2 text-sm">
<input
type="radio"
name="printOrientation"
value={option.value}
defaultChecked={
(option.value === 'landscape') === preferences.printLandscape
}
/>
{option.label}
</label>
))}
</fieldset>
<label className="flex max-w-sm flex-col gap-1.5">
<span className="text-sm font-medium">Densité</span>
<select
name="printDensity"
defaultValue={preferences.printDensity}
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
{PRINT_DENSITIES.map((density) => (
<option key={density.key} value={density.key}>
{density.label} — {density.hint}
</option>
))}
</select>
</label>
{toggles.map((toggle) => (
<div key={toggle.key} className="flex flex-col gap-1">
<label className="flex items-start gap-2.5 text-sm">
<input
type="checkbox"
name={toggle.key}
defaultChecked={Boolean(
preferences[toggle.key as keyof Preferences],
)}
className="mt-0.5"
/>
<span>
<span className="font-medium">{toggle.label}</span>
<span className="block text-micro leading-[var(--lh-prose)] text-ink-3">
{toggle.hint}
</span>
</span>
</label>
{toggle.warning ? (
<p className="ml-6 rounded-2 border border-warn bg-warn-soft px-2.5 py-1.5 text-micro leading-[var(--lh-prose)] text-warn-soft-ink">
{toggle.warning}
</p>
) : null}
</div>
))}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Réglages enregistrés.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -1,37 +0,0 @@
import Link from 'next/link';
import { PrintForm } from '@/app/(app)/reglages/impression/PrintForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import { getPreferences } from '@/server/settings/account';
export const metadata = { title: 'Impression · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ImpressionReglagesPage() {
const preferences = await getPreferences();
return (
<PageBody>
<PageHeader
title="Impression"
subtitle="Mise en page du planning affiché en salle"
/>
<Card>
<CardHeader title="Planning imprimé" />
<PrintForm preferences={preferences} />
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Ces réglages gouvernent{' '}
<Link href="/planning/impression" className="underline">
le planning à afficher
</Link>
. Le rendu passe par la boîte d’impression du navigateur, qui sait aussi
enregistrer en PDF : un second moteur de rendu serait un second endroit
où la feuille peut diverger de l’écran.
</p>
</PageBody>
);
}
@@ -1,89 +0,0 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createTemplateAction,
updateTemplateAction,
type ActionState,
} from '@/server/settings/templates';
const empty: ActionState = {};
function BodyField({ defaultValue }: { defaultValue?: string }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Corps du document</span>
<textarea
name="bodyHtml"
rows={10}
required
defaultValue={defaultValue}
placeholder="<p>Je soussigné…, atteste que {{salarie.prenom}} {{salarie.nom}} est employé…</p>"
className="rounded-2 border border-line-2 bg-surface px-3 py-2 font-mono text-xs text-ink-1 outline-none focus-visible:border-focus"
/>
<span className="text-micro text-ink-3">
Les variables s’écrivent entre doubles accolades. Une variable inconnue
fait refuser l’enregistrement — mieux vaut la corriger ici que de
découvrir un trou dans l’attestation au moment de la remettre.
</span>
</label>
);
}
export function AddTemplateForm() {
const [state, action] = useActionState(createTemplateAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<Field
label="Nom du modèle"
name="name"
required
maxLength={160}
placeholder="Attestation d’emploi"
/>
<BodyField />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Créer le modèle</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Modèle enregistré.</span>
) : null}
</div>
</PersistentForm>
);
}
export function EditTemplateForm({
id,
name,
bodyHtml,
}: {
id: string;
name: string;
bodyHtml: string;
}) {
const [state, action] = useActionState(updateTemplateAction, empty);
return (
<PersistentForm action={action} className="flex flex-col gap-3">
<input type="hidden" name="id" value={id} />
<Field label="Nom du modèle" name="name" defaultValue={name} required maxLength={160} />
<BodyField defaultValue={bodyHtml} />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton size="sm">Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Modifications prises.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -1,164 +0,0 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddTemplateForm,
EditTemplateForm,
} from '@/app/(app)/reglages/modeles-documents/TemplateForms';
import { TEMPLATE_FIELDS } from '@/domain/documents/template';
import {
archiveTemplateAction,
listTemplates,
} from '@/server/settings/templates';
export const metadata = { title: 'Modèles de documents · PlanFlow' };
export const dynamic = 'force-dynamic';
const SCOPE_LABEL = {
employee: 'Salarié',
contract: 'Contrat',
location: 'Établissement',
} as const;
export default async function ModelesDocumentsPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const templates = await listTemplates(showArchived);
const active = templates.filter((template) => template.archivedAt === null);
return (
<PageBody>
<PageHeader
title="Modèles de documents"
subtitle={`${active.length} modèle${active.length > 1 ? 's' : ''} disponible${active.length > 1 ? 's' : ''}`}
/>
<div className="grid gap-5 lg:grid-cols-[1fr_18rem]">
<div className="flex min-w-0 flex-col gap-5">
<Card>
<CardHeader
title="Modèles"
action={
<a
href={
showArchived
? '/reglages/modeles-documents'
: '/reglages/modeles-documents?archives=1'
}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
</a>
}
/>
{templates.length === 0 ? (
<EmptyState
title="Aucun modèle"
description="Un modèle produit une attestation ou un courrier type en résolvant les variables du dossier."
/>
) : (
<ul className="divide-y divide-line-1">
{templates.map((template) => (
<li key={template.id} className="px-4 py-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">
{template.name}
</span>
<Badge tone="neutral">
{template.documentCount} pièce
{template.documentCount > 1 ? 's' : ''}
</Badge>
{template.archivedAt ? (
<Badge tone="warn">Archivé</Badge>
) : null}
<span className="flex-1" />
<form action={archiveTemplateAction}>
<input type="hidden" name="id" value={template.id} />
{template.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{template.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</div>
{template.fields.length > 0 ? (
<ul className="mb-3 flex flex-wrap gap-1.5">
{template.fields.map((field) => (
<li key={field}>
<Badge tone="info">{field}</Badge>
</li>
))}
</ul>
) : (
<p className="mb-3 text-xs text-ink-3">
Aucune variable : ce modèle produit toujours le même
texte.
</p>
)}
<details className="text-sm">
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
Modifier
</summary>
<div className="pt-3">
<EditTemplateForm
id={template.id}
name={template.name}
bodyHtml={template.bodyHtml}
/>
</div>
</details>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouveau modèle" />
<div className="p-4">
<AddTemplateForm />
</div>
</Card>
</div>
<aside className="flex flex-col gap-3">
<Card>
<CardHeader title="Variables disponibles" />
<ul className="divide-y divide-line-1">
{TEMPLATE_FIELDS.map((field) => (
<li key={field.key} className="px-3 py-2">
<code className="text-xs text-ink-1">
{'{{'}
{field.key}
{'}}'}
</code>
<span className="mt-0.5 flex items-center gap-1.5">
<span className="text-micro text-ink-3">{field.label}</span>
<Badge tone="neutral" subtle>
{SCOPE_LABEL[field.scope]}
</Badge>
</span>
</li>
))}
</ul>
</Card>
<p className="text-xs leading-[var(--lh-prose)] text-ink-3">
Les variables d’établissement se résolvent dans le contexte de
l’établissement porté par le contrat du salarié : la même attestation
ne porte pas la même raison sociale d’un magasin à l’autre.
</p>
</aside>
</div>
</PageBody>
);
}
-38
View File
@@ -1,38 +0,0 @@
import Link from 'next/link';
import { PreferenceBlock } from '@/components/settings/PreferenceForms';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import { getPreferences } from '@/server/settings/account';
export const metadata = { title: 'Réglages de paie · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ReglagesPaiePage() {
const preferences = await getPreferences();
return (
<PageBody>
<PageHeader
title="Réglages de paie"
subtitle="Décompte des heures et matricules"
/>
<Card>
<CardHeader title="Décompte" />
<PreferenceBlock group="payroll" preferences={preferences} />
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Ces trois réglages changent des chiffres, pas un affichage. Une période
déjà verrouillée n’est pas recalculée : le changement vaut pour les
périodes ouvertes, et les{' '}
<Link href="/paie/periodes" className="underline">
instantanés de paie
</Link>{' '}
antérieurs restent tels qu’ils ont été figés. C’est ce qui garde une paie
de mars reproductible après une modification en juin.
</p>
</PageBody>
);
}
@@ -1,131 +0,0 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
assignRttPolicyAction,
createRttPolicyAction,
type ActionState,
} from '@/server/settings/rtt-policies';
const empty: ActionState = {};
export function AddPolicyForm() {
const [state, action] = useActionState(createRttPolicyAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<div className="flex flex-wrap gap-3">
<Field
label="Nom"
name="name"
required
maxLength={160}
placeholder="RTT cadres"
/>
<Field
label="Jours par an"
name="daysPerYear"
type="number"
step="0.5"
min="0"
max="365"
required
defaultValue="0"
/>
<Field
label="Début de période"
name="periodStart"
required
placeholder="01-01"
pattern="(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])"
hint="Format MM-JJ : la période se reconduit d’une année sur l’autre, sans millésime à réécrire."
/>
</div>
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" name="autoRenew" defaultChecked className="mt-0.5" />
<span>
Reconduction automatique
<span className="block text-micro text-ink-3">
La politique ouvre un nouveau droit à chaque début de période.
Archivée, elle cesse de le faire.
</span>
</span>
</label>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Créer la politique</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Politique enregistrée.</span>
) : null}
</div>
</PersistentForm>
);
}
export function AssignForm({
policyId,
candidates,
assigned,
}: {
policyId: string;
candidates: Array<{ membershipId: string; name: string }>;
assigned: string[];
}) {
const [state, action] = useActionState(assignRttPolicyAction, empty);
const available = candidates.filter(
(candidate) => !assigned.includes(candidate.membershipId),
);
if (available.length === 0) {
return (
<p className="text-xs text-ink-3">
Tous les salariés actifs sont déjà couverts par cette politique.
</p>
);
}
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-2"
>
<input type="hidden" name="policyId" value={policyId} />
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Salariés à assigner</span>
<select
name="membershipIds"
multiple
size={Math.min(8, available.length)}
className="rounded-2 border border-line-2 bg-surface px-2 py-1 text-sm text-ink-1"
>
{available.map((candidate) => (
<option key={candidate.membershipId} value={candidate.membershipId}>
{candidate.name}
</option>
))}
</select>
</label>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton size="sm">Assigner</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Affectation enregistrée.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -1,185 +0,0 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddPolicyForm,
AssignForm,
} from '@/app/(app)/reglages/politiques-rtt/PolicyForms';
import {
archiveRttPolicyAction,
getPolicyBoard,
unassignRttPolicyAction,
} from '@/server/settings/rtt-policies';
export const metadata = { title: 'Politiques de RTT · PlanFlow' };
export const dynamic = 'force-dynamic';
/** « 01-01 » → « 1er janvier ». */
function periodLabel(periodStart: string): string {
const [month, day] = periodStart.split('-');
if (!month || !day) return periodStart;
const date = new Date(Date.UTC(2001, Number(month) - 1, Number(day)));
const formatted = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
timeZone: 'UTC',
}).format(date);
return day === '01' ? formatted.replace(/^1 /, '1er ') : formatted;
}
export default async function PolitiquesRttPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const board = await getPolicyBoard(showArchived);
const active = board.policies.filter((policy) => policy.status === 'ACTIVE');
return (
<PageBody>
<PageHeader
title="Politiques de RTT"
subtitle={`${active.length} politique${active.length > 1 ? 's' : ''} active${active.length > 1 ? 's' : ''}`}
/>
<Card>
<CardHeader
title="Politiques"
action={
<a
href={
showArchived
? '/reglages/politiques-rtt'
: '/reglages/politiques-rtt?archives=1'
}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivées' : 'Voir les archivées'}
</a>
}
/>
{board.policies.length === 0 ? (
<EmptyState
title="Aucune politique"
description="Une politique ouvre un droit à RTT et l’inscrit au registre des compteurs à chaque début de période."
/>
) : (
<ul className="divide-y divide-line-1">
{board.policies.map((policy) => (
<li key={policy.id} className="px-4 py-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{policy.name}</span>
<Badge tone="accent" className="tnum">
{policy.daysPerYear} j/an
</Badge>
<Badge tone="neutral">
à partir du {periodLabel(policy.periodStart)}
</Badge>
{policy.autoRenew ? (
<Badge tone="ok">Reconduite</Badge>
) : (
<Badge tone="neutral">Période unique</Badge>
)}
{policy.status === 'ARCHIVED' ? (
<Badge tone="warn">Archivée</Badge>
) : null}
<span className="flex-1" />
<form action={archiveRttPolicyAction}>
<input type="hidden" name="id" value={policy.id} />
{policy.status === 'ARCHIVED' ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{policy.status === 'ARCHIVED' ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</div>
<p className="mb-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
{policy.assignedCount} salarié
{policy.assignedCount > 1 ? 's' : ''} couvert
{policy.assignedCount > 1 ? 's' : ''}
</p>
{policy.assignees.length > 0 ? (
<ul className="mb-3 flex flex-wrap gap-1.5">
{policy.assignees.map((assignee) => (
<li key={assignee.membershipId}>
<form
action={unassignRttPolicyAction}
className="inline-flex items-center gap-1 rounded-full border border-line-2 bg-surface-3 py-px pr-1 pl-2"
>
<input
type="hidden"
name="policyId"
value={policy.id}
/>
<input
type="hidden"
name="membershipId"
value={assignee.membershipId}
/>
<span className="text-micro font-semibold text-ink-2">
{assignee.name}
</span>
<button
type="submit"
aria-label={`Retirer ${assignee.name} de ${policy.name}`}
className="cursor-pointer rounded-full px-1 text-micro text-ink-3 hover:text-danger"
>
×
</button>
</form>
</li>
))}
</ul>
) : (
<p className="mb-3 text-xs text-ink-3">
Aucun salarié assigné : cette politique n’ouvre aucun droit.
</p>
)}
{policy.status === 'ACTIVE' ? (
<details className="text-sm">
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
Assigner des employés
</summary>
<div className="pt-3">
<AssignForm
policyId={policy.id}
candidates={board.candidates}
assigned={policy.assignees.map(
(assignee) => assignee.membershipId,
)}
/>
</div>
</details>
) : null}
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouvelle politique" />
<div className="p-4">
<AddPolicyForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Archiver arrête la reconduction ; les jours déjà acquis restent au
registre des compteurs et les affectations en place. Une politique
n’efface pas ce qu’elle a produit — un solde de RTT est un droit ouvert,
pas un affichage.
</p>
</PageBody>
);
}
@@ -1,40 +0,0 @@
import { PreferenceBlock } from '@/components/settings/PreferenceForms';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import { getPreferences } from '@/server/settings/account';
export const metadata = { title: 'Préférences · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function PreferencesPage() {
const preferences = await getPreferences();
return (
<PageBody>
<PageHeader
title="Préférences"
subtitle="Comportement des plannings et droits ouverts aux salariés"
/>
<Card>
<CardHeader title="Plannings" />
<PreferenceBlock
group="planning"
preferences={preferences}
withEveningTime
/>
</Card>
<Card>
<CardHeader title="Droits" />
<PreferenceBlock group="rights" preferences={preferences} />
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Ces réglages valent pour tout le compte. Ils s’ajoutent aux capacités des
rôles sans les remplacer : ouvrir un droit ici ne donne rien à qui n’a
pas la capacité correspondante, et le fermer la retire à tout le monde.
</p>
</PageBody>
);
}
@@ -1,42 +0,0 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
saveProductivityTargetAction,
type ActionState,
} from '@/server/settings/account';
const empty: ActionState = {};
export function ProductivityForm({ target }: { target: string | null }) {
const [state, action] = useActionState(saveProductivityTargetAction, empty);
return (
<PersistentForm action={action} className="flex flex-col gap-3 p-4">
<div className="flex max-w-sm items-end gap-2">
<Field
label="Objectif de productivité"
name="target"
type="number"
step="0.01"
min="0"
defaultValue={target ?? ''}
hint="Chiffre d’affaires attendu par heure travaillée, en euros. Laisser vide pour n’en fixer aucun."
/>
<span className="pb-2.5 text-sm text-ink-2">€/h</span>
</div>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Objectif enregistré.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -1,48 +0,0 @@
import { ProductivityForm } from '@/app/(app)/reglages/productivite/ProductivityForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader } from '@/components/ui/Card';
import { getPreferences } from '@/server/settings/account';
export const metadata = { title: 'Productivité · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ProductivitePage() {
const preferences = await getPreferences();
const target = preferences.productivityTargetPerHour;
return (
<PageBody>
<PageHeader
title="Productivité"
subtitle={
target
? `${target} € de chiffre d’affaires par heure travaillée`
: 'Aucun objectif fixé'
}
/>
<Card>
<CardHeader
title="Objectif global"
badge={
target ? null : <Badge tone="neutral">non fixé</Badge>
}
/>
<ProductivityForm target={target} />
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
L’objectif vaut pour l’ensemble des établissements. Vider le champ
l’efface plutôt que de le ramener à zéro : zéro serait un objectif
inatteignable, l’absence de valeur dit qu’aucun objectif n’a été fixé.
</p>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Un objectif de productivité rapporte un chiffre d’affaires à des heures
planifiées. Il éclaire une décision d’organisation ; il ne mesure pas le
travail d’une personne, et n’a pas à être opposé à un salarié.
</p>
</PageBody>
);
}
-184
View File
@@ -1,184 +0,0 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader } from '@/components/ui/Card';
import {
DATA_DICTIONARY,
LEGAL_BASIS_LABELS,
} from '@/domain/legal/data-dictionary';
import { query } from '@/server/context';
export const metadata = { title: 'RGPD · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function RgpdPage() {
// Les durées ne sont pas écrites dans le dictionnaire : elles viennent des
// politiques de conservation, qui se paramètrent et s'auditent. Un artefact
// qui afficherait sa propre durée mentirait le jour où la politique change.
const policies = await query('settings.access', async (db) =>
db.retentionPolicy.findMany({
orderBy: [{ objectType: 'asc' }, { effectiveFrom: 'desc' }],
select: {
objectType: true,
durationMonths: true,
effectiveFrom: true,
},
}),
);
const durationFor = (objectTypes: string[]): string => {
const matches = objectTypes
.map((objectType) => {
// Une politique précise l'emporte sur une politique générale :
// « Document:SICK_NOTE » avant « Document ».
const exact = policies.find((policy) => policy.objectType === objectType);
if (exact) return exact;
const base = objectType.split(':')[0] as string;
return policies.find((policy) => policy.objectType === base);
})
.filter((policy) => policy !== undefined);
if (matches.length === 0) return 'Non déclarée';
const months = [...new Set(matches.map((policy) => policy.durationMonths))];
return months
.sort((a, b) => a - b)
.map((count) =>
count % 12 === 0 ? `${count / 12} an${count > 12 ? 's' : ''}` : `${count} mois`,
)
.join(' · ');
};
const undeclared = DATA_DICTIONARY.filter(
(entry) => durationFor(entry.objectTypes) === 'Non déclarée',
).length;
return (
<PageBody>
<PageHeader
title="RGPD"
subtitle="Dictionnaire des données : finalité, base légale, destinataires, durée"
/>
<p
role="note"
className="max-w-[70ch] rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs leading-[var(--lh-prose)] text-warn-soft-ink"
>
L’affectation d’une base légale est une qualification juridique. Ce
tableau porte la lecture usuelle d’un traitement de paie et de planning ;
il doit être confirmé par le responsable de traitement avant d’être
opposé à qui que ce soit.
</p>
{undeclared > 0 ? (
<p
role="status"
className="max-w-[70ch] rounded-2 border border-danger bg-danger-soft px-3 py-2 text-xs leading-[var(--lh-prose)] text-danger-soft-ink"
>
{undeclared} catégorie{undeclared > 1 ? 's' : ''} sans durée de
conservation déclarée. Le plan interdit d’appliquer une durée par
défaut : déclarez-la dans{' '}
<Link href="/reglages/conservation" className="underline">
Durées de conservation
</Link>
.
</p>
) : null}
<Card>
<CardHeader
title="Données traitées"
badge={<Badge tone="neutral">{DATA_DICTIONARY.length}</Badge>}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{[
'Catégorie',
'Finalité',
'Base légale',
'Destinataires',
'Durée',
].map((heading) => (
<th
key={heading}
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{DATA_DICTIONARY.map((entry) => {
const duration = durationFor(entry.objectTypes);
return (
<tr key={entry.category}>
<td className="px-4 py-3 align-top">
<span className="font-medium">{entry.category}</span>
{entry.sensitive ? (
<Badge tone="danger" className="ml-1.5">
Catégorie particulière
</Badge>
) : null}
<span className="mt-1 block font-mono text-micro text-ink-3">
{entry.objectTypes.join(', ')}
</span>
</td>
<td className="max-w-[28ch] px-4 py-3 align-top text-ink-2">
{entry.purpose}
{entry.notes ? (
<span className="mt-1 block text-micro text-ink-3">
{entry.notes}
</span>
) : null}
</td>
<td className="px-4 py-3 align-top">
{LEGAL_BASIS_LABELS[entry.basis]}
</td>
<td className="px-4 py-3 align-top text-ink-2">
{entry.recipients.join(', ')}
</td>
<td className="px-4 py-3 align-top">
{duration === 'Non déclarée' ? (
<Badge tone="danger">Non déclarée</Badge>
) : (
<span className="tnum">{duration}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
<Card>
<CardHeader title="Droits des personnes" />
<div className="flex flex-col gap-2 px-4 py-3 text-sm leading-[var(--lh-prose)] text-ink-2">
<p>
L’accès, la rectification et l’effacement s’exercent sur le dossier
du salarié. L’effacement ne franchit pas les durées de conservation
légales : une pièce sous obligation de conservation ne s’efface pas
à la demande, et le refus doit être motivé.
</p>
<p>
La portabilité passe par le paquet de départ : export filtré des
données du salarié, accompagné de son manifeste et de son empreinte.
</p>
<p>
Toute lecture d’une pièce de santé est journalisée et consultable
dans le{' '}
<Link href="/rapports/activite" className="underline">
journal d’activité
</Link>
.
</p>
</div>
</Card>
</PageBody>
);
}
@@ -1,168 +0,0 @@
'use client';
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,
updateAbsenceTypeAction,
type AbsenceTypeRow,
type ActionState,
} from '@/server/settings/absence-types';
const empty: ActionState = {};
/** Drapeaux du type, avec ce que chacun décide réellement. */
const FLAGS = [
{
name: 'isPaid',
label: 'Rémunérée',
hint: 'L’absence est payée par l’employeur.',
},
{
name: 'countsAsWorkTime',
label: 'Compte comme temps de travail',
hint: 'Entre dans le décompte horaire, donc dans les seuils de convention.',
},
{
name: 'affectsPaidLeaveAccrual',
label: 'Acquiert des congés payés',
hint: 'La période continue d’ouvrir des droits à congés.',
},
{
name: 'isSocialSecurity',
label: 'Absence Sécurité sociale',
hint: 'Maladie, maternité, accident du travail. Donnée de santé : le motif est masqué en vue manager.',
},
{
name: 'requiresJustification',
label: 'Justificatif exigé',
hint: 'La demande est refusée sans pièce jointe.',
},
] as const;
function Flags({ row }: { row?: AbsenceTypeRow }) {
return (
<fieldset className="flex flex-col gap-2">
<legend className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Effets
</legend>
{FLAGS.map((flag) => (
<label key={flag.name} className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name={flag.name}
defaultChecked={row ? Boolean(row[flag.name]) : flag.name === 'isPaid'}
className="mt-0.5"
/>
<span>
{flag.label}
<span className="block text-micro text-ink-3">{flag.hint}</span>
</span>
</label>
))}
</fieldset>
);
}
function SharedFields({ row }: { row?: AbsenceTypeRow }) {
return (
<div className="flex flex-wrap gap-3">
<Field
label="Libellé"
name="name"
required
maxLength={120}
defaultValue={row?.name}
placeholder="Congé payé"
/>
<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"
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"
type="number"
min={0}
max={365}
defaultValue={row?.minNoticeDays ?? ''}
hint="En jours. Vide : aucun délai."
/>
<Field
label="Code Silae"
name="silaeCode"
maxLength={20}
defaultValue={row?.silaeCode ?? ''}
hint="Partie « code » de AB-<code>. Sans lui, l’export de la période échoue."
/>
</div>
);
}
export function AddAbsenceTypeForm() {
const [state, action] = useActionState(createAbsenceTypeAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-4"
>
<div className="flex flex-wrap gap-3">
<Field
label="Code"
name="code"
required
maxLength={20}
placeholder="CP"
hint="Identifiant stable du type. Il ne se renomme pas."
/>
</div>
<SharedFields />
<Flags />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Ajouter le type</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Type enregistré.</span>
) : null}
</div>
</PersistentForm>
);
}
export function EditAbsenceTypeForm({ row }: { row: AbsenceTypeRow }) {
const [state, action] = useActionState(updateAbsenceTypeAction, empty);
return (
<PersistentForm action={action} className="flex flex-col gap-4">
<input type="hidden" name="id" value={row.id} />
<SharedFields row={row} />
<Flags row={row} />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton size="sm">Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Modifications prises.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -1,149 +0,0 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddAbsenceTypeForm,
EditAbsenceTypeForm,
} from '@/app/(app)/reglages/types-absence/AbsenceTypeForms';
import {
archiveAbsenceTypeAction,
listAbsenceTypes,
type AbsenceTypeRow,
} from '@/server/settings/absence-types';
export const metadata = { title: 'Types d’absence · PlanFlow' };
export const dynamic = 'force-dynamic';
function Summary({ row }: { row: AbsenceTypeRow }) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<Badge tone={row.isPaid ? 'ok' : 'neutral'}>
{row.isPaid ? 'Rémunérée' : 'Non rémunérée'}
</Badge>
{row.isSocialSecurity ? <Badge tone="info">Sécurité sociale</Badge> : null}
{row.countsAsWorkTime ? <Badge tone="neutral">Temps de travail</Badge> : null}
{row.affectsPaidLeaveAccrual ? (
<Badge tone="neutral">Acquisition CP</Badge>
) : null}
{row.requiresJustification ? (
<Badge tone="warn">Justificatif</Badge>
) : null}
{row.silaeCode ? (
<Badge tone="neutral">AB-{row.silaeCode}</Badge>
) : (
<Badge tone="danger">Code Silae manquant</Badge>
)}
</div>
);
}
export default async function TypesAbsencePage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const types = await listAbsenceTypes(showArchived);
const active = types.filter((type) => type.archivedAt === null);
const missingSilae = active.filter((type) => !type.silaeCode).length;
return (
<PageBody>
<PageHeader
title="Types d’absence"
subtitle={`${active.length} type${active.length > 1 ? 's' : ''} en vigueur`}
/>
{missingSilae > 0 ? (
<p
role="status"
className="rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs text-warn-soft-ink"
>
{missingSilae} type{missingSilae > 1 ? 's' : ''} sans code Silae.
L’export d’une période contenant ces absences échouera, avec la liste
des manques — aucun fichier partiel n’est produit.
</p>
) : null}
<Card>
<CardHeader
title="Référentiel"
action={
<a
href={
showArchived
? '/reglages/types-absence'
: '/reglages/types-absence?archives=1'
}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
</a>
}
/>
{types.length === 0 ? (
<EmptyState
title="Aucun type d’absence"
description="Sans type, aucune demande n’est saisissable. Commencez par le congé payé."
/>
) : (
<ul className="divide-y divide-line-1">
{types.map((type) => (
<li key={type.id} className="px-4 py-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<Badge tone="accent">{type.code}</Badge>
<span className="text-sm font-semibold">{type.name}</span>
<Badge tone="neutral">
{type.timeOffCount} absence
{type.timeOffCount > 1 ? 's' : ''}
</Badge>
{type.archivedAt ? <Badge tone="warn">Archivé</Badge> : null}
<span className="flex-1" />
<form action={archiveAbsenceTypeAction}>
<input type="hidden" name="id" value={type.id} />
{type.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{type.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</div>
<div className="mb-3">
<Summary row={type} />
</div>
<details className="text-sm">
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
Modifier
</summary>
<div className="pt-3">
<EditAbsenceTypeForm row={type} />
</div>
</details>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouveau type" />
<div className="p-4">
<AddAbsenceTypeForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Le code d’un type ne se renomme pas : il est référencé par les absences
déjà saisies et par la correspondance de paie. Un type devenu inutile
s’archive — les absences passées gardent alors un libellé lisible et les
écritures de compteur restent justifiables.
</p>
</PageBody>
);
}
-105
View File
@@ -1,105 +0,0 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import type { AnalyticsSeries } from '@/server/hr/analytics';
/**
* Cadre commun aux trois analyses.
*
* Même en-tête, même sélecteur d'établissement, mêmes onglets : trois écrans
* qui répondent à trois questions sur le même jeu de données n'ont pas de
* raison de se présenter différemment.
*/
export const ANALYSES = [
{ key: 'effectifs', href: '/rh/analyses/effectifs', label: 'Effectifs' },
{ key: 'heures', href: '/rh/analyses/heures', label: 'Heures' },
{ key: 'absences', href: '/rh/analyses/absences', label: 'Absences' },
] as const;
export type AnalysisKey = (typeof ANALYSES)[number]['key'];
export function AnalysisShell({
current,
title,
subtitle,
series,
children,
footnote,
}: {
current: AnalysisKey;
title: string;
subtitle: string;
series: AnalyticsSeries;
children: React.ReactNode;
footnote: string;
}) {
const withLocation = (href: string) =>
series.location ? `${href}?etablissement=${series.location.id}` : href;
return (
<PageBody>
<PageHeader
title={title}
subtitle={`${series.location?.name ?? 'Tous établissements'} · ${subtitle}`}
actions={
<nav aria-label="Analyses" className="flex gap-1">
{ANALYSES.map((analysis) => (
<Link
key={analysis.key}
href={withLocation(analysis.href)}
aria-current={analysis.key === current ? 'page' : undefined}
className={
analysis.key === current
? 'flex h-8 items-center rounded-2 border border-line-3 bg-surface-2 px-3 text-sm font-medium text-ink-1'
: 'flex h-8 items-center rounded-2 px-3 text-sm text-ink-2 hover:bg-surface-2'
}
>
{analysis.label}
</Link>
))}
</nav>
}
/>
{series.locations.length > 1 ? (
<Card>
<CardHeader title="Établissement" />
<div className="flex flex-wrap gap-1 px-4 py-3">
{series.locations.map((location) => (
<Link
key={location.id}
href={`${ANALYSES.find((a) => a.key === current)?.href}?etablissement=${location.id}`}
aria-current={
location.id === series.location?.id ? 'page' : undefined
}
className={
location.id === series.location?.id
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
}
>
{location.name}
</Link>
))}
</div>
</Card>
) : null}
{children}
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
{footnote}
</p>
</PageBody>
);
}
/** Mois abrégé, pour l'axe du graphique. « septembre 2026 » → « sept. 26 ». */
export function shortMonth(label: string): string {
const [name, year] = label.split(' ');
if (!name || !year) return label;
const abbreviated = name.length > 4 ? `${name.slice(0, 4)}.` : name;
return `${abbreviated} ${year.slice(2)}`;
}
-100
View File
@@ -1,100 +0,0 @@
import Link from 'next/link';
import {
AnalysisShell,
shortMonth,
} from '@/app/(app)/rh/analyses/AnalysisShell';
import { SeriesChart } from '@/components/hr/SeriesChart';
import { Card, CardHeader } from '@/components/ui/Card';
import { monthOf } from '@/domain/planning/month';
import { getAnalyticsSeries } from '@/server/hr/analytics';
export const metadata = { title: 'Analyse des absences · PlanFlow' };
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{ etablissement?: string }>;
}
export default async function AbsencesAnalysePage({ searchParams }: PageProps) {
const { etablissement } = await searchParams;
const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
const last = series.points[series.points.length - 1];
return (
<AnalysisShell
current="absences"
title="Absences"
subtitle={
last?.absenteeismRate === null || last === undefined
? 'Taux non calculable'
: `${last.absenteeismRate} % d’absentéisme ce mois`
}
series={series}
footnote="Le taux rapporte les jours d’absence aux jours théoriquement travaillés, pas aux jours calendaires : rapporter à trente donnerait un taux artificiellement bas et ferait passer un problème réel pour du bruit. La colonne « dont arrêts » agrège les absences relevant de la Sécurité sociale sans jamais nommer de salarié — un taux ne doit pas devenir un moyen d’identifier qui est malade."
>
<Card>
<CardHeader title="Taux d’absentéisme mensuel" />
<SeriesChart
tone="warn"
points={series.points.map((point) => ({
label: point.label,
short: shortMonth(point.label),
value: point.absenteeismRate,
}))}
format={(value) => `${value} %`}
/>
</Card>
<Card>
<CardHeader title="Détail mensuel" />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{[
'Mois',
'Jours d’absence',
'dont arrêts',
'Jours théoriques',
'Taux',
].map((heading) => (
<th
key={heading}
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{[...series.points].reverse().map((point) => (
<tr key={point.param}>
<td className="px-4 py-2">
<Link
href={`/?mois=${point.param}&liste=absences${series.location ? `&etablissement=${series.location.id}` : ''}`}
className="hover:underline"
>
{point.label}
</Link>
</td>
<td className="px-4 py-2 tnum">{point.absenceDays}</td>
<td className="px-4 py-2 tnum">{point.sickDays}</td>
<td className="px-4 py-2 tnum">
{point.theoreticalWorkedDays}
</td>
<td className="px-4 py-2 tnum">
{point.absenteeismRate === null
? '—'
: `${point.absenteeismRate} %`}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</AnalysisShell>
);
}
@@ -1,93 +0,0 @@
import Link from 'next/link';
import {
AnalysisShell,
shortMonth,
} from '@/app/(app)/rh/analyses/AnalysisShell';
import { SeriesChart } from '@/components/hr/SeriesChart';
import { Card, CardHeader } from '@/components/ui/Card';
import { monthOf } from '@/domain/planning/month';
import { getAnalyticsSeries } from '@/server/hr/analytics';
export const metadata = { title: 'Analyse des effectifs · PlanFlow' };
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{ etablissement?: string }>;
}
export default async function EffectifsPage({ searchParams }: PageProps) {
const { etablissement } = await searchParams;
const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
const last = series.points[series.points.length - 1];
return (
<AnalysisShell
current="effectifs"
title="Effectifs"
subtitle={`${last?.closingHeadcount ?? 0} salariés en fin de mois`}
series={series}
footnote="Le taux de rotation rapporte la moyenne des entrées et des sorties à l’effectif moyen : ne compter que les départs sous-estimerait une équipe qui recrute autant qu’elle perd. Sur un effectif nul il n’est pas calculé — « 0 % » y serait une affirmation fausse, pas une absence de mouvement."
>
<Card>
<CardHeader title="Effectif en fin de mois" />
<SeriesChart
points={series.points.map((point) => ({
label: point.label,
short: shortMonth(point.label),
value: point.closingHeadcount,
}))}
format={(value) => String(value)}
/>
</Card>
<Card>
<CardHeader title="Détail mensuel" />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{['Mois', 'Fin de mois', 'Moyen', 'Entrées', 'Sorties', 'Rotation'].map(
(heading) => (
<th
key={heading}
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
),
)}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{[...series.points].reverse().map((point) => (
<tr key={point.param}>
<td className="px-4 py-2">
{/* Chaque chiffre mène à ses lignes sources : un indicateur
qu'on ne peut pas ouvrir ne se corrige pas, il se
conteste. */}
<Link
href={`/?mois=${point.param}&liste=entrees${series.location ? `&etablissement=${series.location.id}` : ''}`}
className="hover:underline"
>
{point.label}
</Link>
</td>
<td className="px-4 py-2 tnum">{point.closingHeadcount}</td>
<td className="px-4 py-2 tnum">{point.averageHeadcount}</td>
<td className="px-4 py-2 tnum">{point.entries}</td>
<td className="px-4 py-2 tnum">{point.exits}</td>
<td className="px-4 py-2 tnum">
{point.turnoverRate === null
? '—'
: `${point.turnoverRate} %`}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</AnalysisShell>
);
}
-99
View File
@@ -1,99 +0,0 @@
import {
AnalysisShell,
shortMonth,
} from '@/app/(app)/rh/analyses/AnalysisShell';
import { SeriesChart } from '@/components/hr/SeriesChart';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader } from '@/components/ui/Card';
import { formatMinutes } from '@/domain/counters/week';
import { monthOf } from '@/domain/planning/month';
import { getAnalyticsSeries } from '@/server/hr/analytics';
export const metadata = { title: 'Analyse des heures · PlanFlow' };
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{ etablissement?: string }>;
}
export default async function HeuresPage({ searchParams }: PageProps) {
const { etablissement } = await searchParams;
const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
const last = series.points[series.points.length - 1];
return (
<AnalysisShell
current="heures"
title="Heures"
subtitle={
last ? `${formatMinutes(last.plannedMinutes)} planifiées ce mois` : '—'
}
series={series}
footnote="Le prévu et le réalisé sont deux états distincts du temps, et l’écart entre les deux est ce qu’un contrôle demande à justifier. Quand aucune heure réelle n’a été saisie sur un créneau, le prévu fait foi : laisser un zéro creuserait un écart qui n’existe pas."
>
<Card>
<CardHeader title="Heures planifiées par mois" />
<SeriesChart
points={series.points.map((point) => ({
label: point.label,
short: shortMonth(point.label),
value: Math.round(point.plannedMinutes / 60),
}))}
format={(value) => `${value} h`}
/>
</Card>
<Card>
<CardHeader
title="Prévu et réalisé"
badge={<Badge tone="neutral">12 mois</Badge>}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{['Mois', 'Planifié', 'Réalisé', 'Écart', 'Effectif moyen'].map(
(heading) => (
<th
key={heading}
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
),
)}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{[...series.points].reverse().map((point) => {
const delta = point.actualMinutes - point.plannedMinutes;
return (
<tr key={point.param}>
<td className="px-4 py-2">{point.label}</td>
<td className="px-4 py-2 tnum">
{formatMinutes(point.plannedMinutes)}
</td>
<td className="px-4 py-2 tnum">
{formatMinutes(point.actualMinutes)}
</td>
<td className="px-4 py-2 tnum">
{delta === 0 ? (
'—'
) : (
<Badge tone={delta > 0 ? 'warn' : 'info'}>
{delta > 0 ? '+' : '−'}
{formatMinutes(Math.abs(delta))}
</Badge>
)}
</td>
<td className="px-4 py-2 tnum">{point.averageHeadcount}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</AnalysisShell>
);
}
+58 -177
View File
@@ -14,98 +14,74 @@
========================================================================== */
@theme {
/* 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;
--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;
/* É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;
--text-micro: 11px;
--text-xs: 12px;
--text-sm: 13px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 20px;
--text-2xl: 26px;
--text-3xl: 32px;
/* 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-1: 3px;
--radius-2: 6px;
--radius-3: 10px;
--radius-4: 14px;
--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 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);
/* 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);
/* 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-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);
/* 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-focus: oklch(0.52 0.16 252);
/* 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-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);
--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-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-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-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-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);
--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-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-e1: 0 1px 2px oklch(0.3 0.02 85 / 0.08);
--shadow-e2:
0 1px 2px oklch(0.35 0.03 258 / 0.07), 0 4px 10px oklch(0.35 0.03 258 / 0.06);
0 1px 2px oklch(0.3 0.02 85 / 0.06), 0 6px 14px oklch(0.3 0.02 85 / 0.07);
--shadow-e3:
0 2px 4px oklch(0.35 0.03 258 / 0.08), 0 14px 34px oklch(0.35 0.03 258 / 0.12);
0 2px 6px oklch(0.3 0.02 85 / 0.08), 0 16px 40px oklch(0.3 0.02 85 / 0.14);
}
:root {
@@ -135,16 +111,12 @@
toujours écrit dans le bloc (voir ShiftChip).
--------------------------------------------------------------------- */
--tier-dir: -1;
/* 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;
--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;
--post-cai-h: 250;
--post-cai-t: 0;
@@ -364,30 +336,6 @@ 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;
}
*,
@@ -409,66 +357,6 @@ samp {
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,
@@ -523,13 +411,6 @@ samp {
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 {
+1 -42
View File
@@ -1,46 +1,10 @@
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.',
@@ -55,12 +19,7 @@ export default async function RootLayout({
const nonce = (await headers()).get('x-nonce') ?? undefined;
return (
<html
lang="fr"
data-theme="light"
className={`${plexSans.variable} ${plexMono.variable}`}
suppressHydrationWarning
>
<html lang="fr" data-theme="light" suppressHydrationWarning>
<head>
<script
nonce={nonce}
-155
View File
@@ -1,155 +0,0 @@
import Link from 'next/link';
import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons';
import { Badge, type Tone } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
statusLabel,
type AbsenceQueue as QueueKey,
type AbsenceQueueResult,
} from '@/server/absences/queries';
/**
* Files d'absences par état — PLAN.md §9.
*
* Les trois files partagent ce rendu. Une seule différence les sépare : la file
* à traiter porte les boutons de décision, les autres n'en portent pas — une
* absence déjà décidée ne se re-décide pas depuis une liste, elle s'annule
* depuis la fiche, avec sa contre-passe au registre.
*/
const STATUS_TONES: Record<string, Tone> = {
PENDING: 'warn',
ACCEPTED: 'ok',
DECLINED: 'danger',
CANCELLED: 'neutral',
EXPIRED: 'neutral',
};
export const QUEUE_TABS: Array<{ key: QueueKey; href: string; label: string }> = [
{ key: 'pending', href: '/absences/a-traiter', label: 'À traiter' },
{ key: 'treated', href: '/absences/traitees', label: 'Traitées' },
{ key: 'expired', href: '/absences/expirees', label: 'Expirées' },
];
const dateFormat = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
function formatDay(iso: string): string {
return dateFormat.format(new Date(`${iso}T00:00:00Z`));
}
/**
* Période affichée d'une absence.
*
* `endDate` porte le **dernier jour d'absence**, pas la date de reprise. La
* confusion est la plus fréquente du domaine et se paie en un jour de congé de
* trop ou de trop peu ; l'écran l'écrit donc explicitement.
*/
function period(row: AbsenceQueueResult['rows'][number]): string {
const start = formatDay(row.startDate);
if (row.startDate === row.endDate) {
return row.startHalfDay || row.endHalfDay ? `${start} (demi-journée)` : start;
}
return `du ${start} au ${formatDay(row.endDate)} inclus`;
}
export function AbsenceQueueView({
result,
title,
description,
filterBar,
}: {
result: AbsenceQueueResult;
title: string;
description: string;
filterBar?: React.ReactNode;
}) {
return (
<Card>
<CardHeader
title={title}
badge={
<Badge tone={result.rows.length > 0 ? 'accent' : 'neutral'}>
{result.rows.length} ligne{result.rows.length > 1 ? 's' : ''}
</Badge>
}
action={
<nav aria-label="Files d’absences" className="flex gap-1">
{QUEUE_TABS.map((tab) => (
<Link
key={tab.key}
href={tab.href}
aria-current={tab.key === result.queue ? 'page' : undefined}
className={
tab.key === result.queue
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
}
>
{tab.label}
<span className="ml-1.5 tnum text-ink-3">
{result.counts[tab.key]}
</span>
</Link>
))}
</nav>
}
/>
{filterBar ? (
<div className="border-b border-line-1 px-4 py-2.5">{filterBar}</div>
) : null}
{result.rows.length === 0 ? (
<EmptyState title="Aucune absence" description={description} />
) : (
<ul className="divide-y divide-line-1">
{result.rows.map((row) => (
<li key={row.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="min-w-[14rem] flex-1">
<p className="text-sm font-medium">{row.name}</p>
<p className="text-xs text-ink-2">
{row.typeLabel} · {period(row)}
</p>
{row.comment ? (
<p className="mt-1 text-xs text-ink-3">« {row.comment} »</p>
) : null}
{row.decisionComment ? (
<p className="mt-1 text-xs text-ink-3">
Décision : {row.decisionComment}
</p>
) : null}
</div>
<Badge tone="neutral" className="tnum">
{row.days} j
</Badge>
<Badge tone={STATUS_TONES[row.status] ?? 'neutral'}>
{statusLabel(row.status)}
</Badge>
{result.queue === 'pending' && result.canDecide ? (
<DecisionButtons timeOffId={row.id} />
) : null}
{result.queue === 'treated' && row.status === 'ACCEPTED' ? (
<CancelButton timeOffId={row.id} />
) : null}
<Link
href={`/equipe/${row.membershipId}/absences`}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
Fiche
</Link>
</li>
))}
</ul>
)}
</Card>
);
}
-16
View File
@@ -33,22 +33,6 @@ 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}
-76
View File
@@ -1,76 +0,0 @@
import { cx } from '@/lib/cx';
/**
* Histogramme mensuel, en HTML.
*
* Pas de bibliothèque de graphiques : douze barres et une échelle ne justifient
* pas 90 ko de dépendance, et la CSP interdit de toute façon toute origine
* tierce. Le tableau reste la source — le graphique n'est qu'une lecture rapide
* posée devant lui.
*
* **La hauteur ne porte jamais l'information seule** : chaque barre affiche sa
* valeur, et l'ensemble est doublé d'un tableau lisible au lecteur d'écran.
*/
export interface SeriesPoint {
label: string;
value: number | null;
/** Libellé affiché sous la barre — le mois abrégé. */
short: string;
}
export function SeriesChart({
points,
format,
tone = 'accent',
}: {
points: SeriesPoint[];
format: (value: number) => string;
tone?: 'accent' | 'warn' | 'info';
}) {
const values = points
.map((point) => point.value)
.filter((value): value is number => value !== null);
const max = values.length > 0 ? Math.max(...values) : 0;
const bar = {
accent: 'bg-accent',
warn: 'bg-warn',
info: 'bg-info',
}[tone];
return (
<div
className="flex items-end gap-1.5 overflow-x-auto px-4 py-4"
role="presentation"
>
{points.map((point) => {
// Une valeur nulle n'est pas zéro : un taux non calculable sur un
// effectif vide ne doit pas se dessiner comme une absence de mouvement.
const height =
point.value === null || max === 0
? 0
: Math.max(2, Math.round((point.value / max) * 120));
return (
<div
key={point.label}
className="flex min-w-[3.25rem] flex-1 flex-col items-center gap-1"
>
<span className="tnum text-micro text-ink-2">
{point.value === null ? '—' : format(point.value)}
</span>
<div
className={cx(
'w-full rounded-t-2',
point.value === null ? 'bg-surface-3' : bar,
)}
style={{ height: `${height}px` }}
/>
<span className="text-micro text-ink-3">{point.short}</span>
</div>
);
})}
</div>
);
}
+1 -32
View File
@@ -1,15 +1,6 @@
import type { AbsenceColorKey } from '@/domain/absences/colors';
import { cx } from '@/lib/cx';
/**
* 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;
export type AbsenceKind = 'cp' | 'rtt' | 'maladie' | 'sans-solde' | 'attente';
const KINDS: Record<AbsenceKind, { bg: string; ink: string; edge: string }> = {
cp: {
@@ -27,28 +18,6 @@ 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)',
+5 -15
View File
@@ -27,16 +27,9 @@ function Cell({ label, value }: { label: string; value: string }) {
}
/**
* Les cinq compteurs d'une ligne salarié — PLAN.md §7.4 : **heures
* contractuelles, planifié, absences, écart, repos compensateur**.
*
* Ce sont ces cinq-là et pas d'autres. Les dimanches travaillés et les jours de
* repos se lisent sur la grille ; le repos compensateur, lui, ne se lit nulle
* part ailleurs — c'est une contrepartie **due**, et l'omettre du bandeau
* masque la seule valeur qui dit qu'une dette existe.
*
* Rendus depuis `computeWeekCounters`, jamais recalculés localement : la même
* implémentation alimente la grille, le rapport d'heures et l'export de paie.
* Les cinq compteurs d'une ligne salarié : contrat, planifié, absences, écart,
* dimanches et repos. Rendus depuis `computeWeekCounters`, jamais recalculés
* localement.
*/
export function CounterStrip({
counters,
@@ -64,10 +57,7 @@ export function CounterStrip({
<span className="rounded-full bg-accent-soft px-1.5 py-px font-medium text-accent-soft-ink">
Forfait jours
</span>
{/* Un forfait jours reste soumis aux repos : le RC s'affiche, l'écart
horaire non. */}
<Cell label="Rep." value={String(counters.restDays)} />
<Cell label="RC" value={counters.compensatoryRestLabel} />
</div>
);
}
@@ -81,7 +71,6 @@ export function CounterStrip({
·
</span>
<Cell label="Planif." value={counters.plannedLabel} />
<Cell label="Abs." value={counters.absenceLabel} />
<span
className={cx(
'tnum rounded-full px-1.5 py-px font-semibold',
@@ -91,7 +80,8 @@ export function CounterStrip({
>
{counters.deltaLabel}
</span>
<Cell label="RC" value={counters.compensatoryRestLabel} />
<Cell label="Dim." value={String(counters.sundaysWorked)} />
<Cell label="Rep." value={String(counters.restDays)} />
</div>
);
}
+20 -255
View File
@@ -7,7 +7,6 @@ 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,
@@ -206,8 +205,6 @@ export function TeamSection({
rows={section.rows}
localDate={dates[target.dayIndex] ?? ''}
dayLabel={days[target.dayIndex] ?? ''}
dates={dates}
days={days}
target={target}
onClose={() => setTarget(null)}
/>
@@ -353,8 +350,6 @@ function ShiftComposer({
rows,
localDate,
dayLabel,
dates,
days,
target,
onClose,
}: {
@@ -364,8 +359,6 @@ function ShiftComposer({
rows: BoardRow[];
localDate: string;
dayLabel: string;
dates: string[];
days: string[];
target: Target;
onClose: () => void;
}) {
@@ -444,67 +437,33 @@ function ShiftComposer({
/>
</Field>
<BreakRows
initial={editing ? target.shift.breaks : []}
fallbackMinutes={editing ? target.shift.breakMinutes : 0}
/>
<Field label="Repas" htmlFor="composer-meals">
<Field label="Pause (min)" htmlFor="composer-break">
<input
id="composer-meals"
name="mealCount"
id="composer-break"
name="breakMinutes"
type="number"
min={0}
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>
{/* 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}
</option>
))}
</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"
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"
/>
</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>
</>
<Field label="Poste" htmlFor="composer-label">
<select
id="composer-label"
name="labelId"
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
{labels.map((label) => (
<option key={label.id} value={label.id}>
{label.name}
</option>
))}
</select>
</Field>
)}
<div className="flex items-center gap-2">
@@ -525,200 +484,6 @@ 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,
+4 -2
View File
@@ -1,7 +1,6 @@
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,
@@ -247,5 +246,8 @@ function Row({
* repli neutre : une teinte inconnue vaut mieux affichée en gris qu'absente.
*/
function absenceKind(colorKey: string): AbsenceKind {
return isAbsenceColorKey(colorKey) ? colorKey : 'sans-solde';
const known: AbsenceKind[] = ['cp', 'rtt', 'maladie', 'sans-solde', 'attente'];
return known.includes(colorKey as AbsenceKind)
? (colorKey as AbsenceKind)
: 'sans-solde';
}
@@ -1,90 +0,0 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
saveAccountIdentityAction,
type AccountIdentity,
type ActionState,
} from '@/server/settings/account';
const empty: ActionState = {};
export function AccountIdentityForm({
account,
}: {
account: AccountIdentity;
}) {
const [state, action] = useActionState(saveAccountIdentityAction, empty);
return (
<PersistentForm action={action} className="flex flex-col gap-3 p-4">
<Field
label="Raison sociale"
name="name"
defaultValue={account.name}
required
maxLength={200}
/>
<div className="flex flex-wrap gap-3">
<Field
label="SIREN"
name="siren"
defaultValue={account.siren ?? ''}
inputMode="numeric"
placeholder="9 chiffres"
hint="Identifie l’entreprise. Le SIRET, propre à chaque établissement, se saisit sur l’établissement."
/>
<Field
label="Code APE"
name="apeCode"
defaultValue={account.apeCode ?? ''}
placeholder="4759B"
/>
</div>
<Field
label="Adresse du siège"
name="addressLine"
defaultValue={account.addressLine ?? ''}
maxLength={200}
hint="Figure sur les documents édités et au registre unique du personnel."
/>
<div className="flex flex-wrap gap-3">
<Field
label="Code postal"
name="postalCode"
defaultValue={account.postalCode ?? ''}
inputMode="numeric"
/>
<Field
label="Ville"
name="city"
defaultValue={account.city ?? ''}
maxLength={120}
/>
</div>
<Field
label="Fuseau horaire"
name="timezone"
defaultValue={account.timezone}
required
hint="Défaut du compte. Un établissement dans un autre fuseau porte le sien."
/>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Compte enregistré.</span>
) : null}
</div>
</PersistentForm>
);
}
-101
View File
@@ -1,101 +0,0 @@
'use client';
import { useActionState } from 'react';
import { FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
minutesToTime,
togglesOf,
type PreferenceGroup,
} from '@/domain/settings/preferences';
import {
savePreferencesAction,
type ActionState,
type Preferences,
} from '@/server/settings/account';
const empty: ActionState = {};
/**
* Un bloc de préférences.
*
* Chaque formulaire déclare les clés qu'il gouverne dans des champs `scope`.
* Sans eux, enregistrer « Plannings » remettrait à faux tout « Droits » : une
* case décochée n'arrive pas dans le `FormData`, et le serveur ne saurait pas
* distinguer « décoché » de « pas dans ce formulaire ».
*/
export function PreferenceBlock({
group,
preferences,
withEveningTime = false,
}: {
group: PreferenceGroup;
preferences: Preferences;
withEveningTime?: boolean;
}) {
const [state, action] = useActionState(savePreferencesAction, empty);
const toggles = togglesOf(group);
return (
<PersistentForm action={action} className="flex flex-col gap-4 px-4 py-4">
{toggles.map((toggle) => (
<input key={`scope-${toggle.key}`} type="hidden" name="scope" value={toggle.key} />
))}
{toggles.map((toggle) => (
<div key={toggle.key} className="flex flex-col gap-1">
<label className="flex items-start gap-2.5 text-sm">
<input
type="checkbox"
name={toggle.key}
defaultChecked={Boolean(
preferences[toggle.key as keyof Preferences],
)}
className="mt-0.5"
/>
<span>
<span className="font-medium">{toggle.label}</span>
<span className="block text-micro leading-[var(--lh-prose)] text-ink-3">
{toggle.hint}
</span>
</span>
</label>
{toggle.warning ? (
<p className="ml-6 rounded-2 border border-warn bg-warn-soft px-2.5 py-1.5 text-micro leading-[var(--lh-prose)] text-warn-soft-ink">
{toggle.warning}
</p>
) : null}
</div>
))}
{withEveningTime ? (
<label className="flex max-w-xs flex-col gap-1.5">
<span className="text-sm font-medium">
Un créneau est « du soir » à partir de
</span>
<input
type="time"
name="eveningShiftStart"
defaultValue={minutesToTime(preferences.eveningShiftStartMinutes)}
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1"
/>
<span className="text-micro text-ink-3">
Sert au repérage à l’écran et aux majorations de soirée, pas au
travail de nuit, qui relève de la convention.
</span>
</label>
) : null}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Réglages enregistrés.</span>
) : null}
</div>
</PersistentForm>
);
}
+77 -92
View File
@@ -4,16 +4,18 @@ 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 {
@@ -33,36 +35,19 @@ 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">
{/*
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"
>
<header data-print="hide" className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
<Link
href="/"
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.02em] text-ink-1"
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.015em]"
>
{/* 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>
<span aria-hidden className="size-5 rounded-2 bg-accent" />
PlanFlow
</Link>
<nav aria-label="Sections" className="flex h-full">
<nav aria-label="Sections" className="flex h-full gap-0.5">
{NAVIGATION.map((entry) => {
const current = entry.id === section.id;
return (
@@ -71,15 +56,10 @@ export function AppShell({
href={sectionHref(entry)}
aria-current={current ? 'page' : undefined}
className={cx(
'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',
'flex h-full items-center border-b-2 px-3.5 text-sm font-medium whitespace-nowrap',
current
? 'font-semibold text-ink-1 after:bg-accent'
: 'font-medium text-ink-3 after:bg-transparent hover:text-ink-1',
? 'border-accent text-ink-1'
: 'border-transparent text-ink-3 hover:text-ink-1',
)}
>
{entry.label}
@@ -90,80 +70,85 @@ 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 items-center gap-2.5"
className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-ink"
title={`${fullName} · ${roleName}`}
>
<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 aria-hidden>{initials}</span>
<span className="sr-only">{`${fullName}, ${roleName}`}</span>
</span>
<form action={signOutAction}>
<Button size="sm" variant="ghost" type="submit">
<Button size="sm" type="submit">
Déconnexion
</Button>
</form>
</header>
{/*
Colonne centrale de **largeur fixe**, pas fluide.
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 className="px-6 pt-6">
<SectionTabs section={section} pathname={pathname} />
</div>
{children}
</>
)}
<p
data-print="hide"
className="mt-auto border-t border-line-1 px-6 py-4 text-micro leading-relaxed text-ink-3"
>
Instance auto-hébergée · {accountName}
<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>
</div>
</main>
{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>
<main className="min-w-0 flex-1 overflow-auto">{children}</main>
</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}
</>
);
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}
>
{content}
</Link>
);
}
+5 -7
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 border-b border-line-1 pb-4">
<div className="min-w-0">
<h1 className="text-2xl font-semibold">{title}</h1>
<div className="flex flex-wrap items-end gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-[-0.018em]">{title}</h1>
{subtitle ? (
<p className="mt-1.5 text-sm text-ink-2">{subtitle}</p>
<p className="mt-1 text-sm text-ink-2">{subtitle}</p>
) : null}
</div>
<span className="flex-1" />
@@ -22,9 +22,7 @@ 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-6 px-6 pt-6 pb-20">{children}</div>
<div className="flex flex-col gap-5 px-6 pt-5 pb-16">{children}</div>
);
}
-75
View File
@@ -1,75 +0,0 @@
'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
@@ -1,106 +0,0 @@
'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 -74
View File
@@ -32,29 +32,9 @@ export const NAVIGATION: NavSection[] = [
{ id: 'entrees', label: 'Entrées et sorties', href: '/?liste=entrees' },
{ id: 'profils', label: 'Profils incomplets', href: '/?liste=profils' },
{ id: 'titres', label: 'Titres de séjour', href: '/?liste=titres' },
// Ancre plutôt que route : le calendrier appartient à la section Congés,
// et deux entrées allumées pour un même écran rendent la navigation
// muette.
{
id: 'soldes-cp',
label: 'Compteurs de congés payés',
href: '/absences/calendrier#compteurs',
},
{
id: 'analyse-effectifs',
label: 'Analyse des effectifs',
href: '/rh/analyses/effectifs',
},
{
id: 'analyse-heures',
label: 'Analyse des heures',
href: '/rh/analyses/heures',
},
{
id: 'analyse-absences',
label: 'Analyse des absences',
href: '/rh/analyses/absences',
},
// Ancre plutôt que route : `/conges` appartient à la section Congés, et
// deux entrées allumées pour un même écran rendent la navigation muette.
{ id: 'soldes-cp', label: 'Compteurs de congés payés', href: '/conges#compteurs' },
],
},
{
@@ -65,12 +45,6 @@ export const NAVIGATION: NavSection[] = [
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' },
{ id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' },
{ id: 'mois', label: 'Vue mois', href: '/planning/mois' },
{
id: 'presences',
label: 'Présences et absences',
href: '/planning/presences',
},
{ id: 'impression', label: 'Planning à afficher', href: '/planning/impression' },
{ id: 'besoins', label: 'Besoins non couverts' },
{ id: 'modeles', label: "Modèles d'ouverture" },
],
@@ -87,19 +61,9 @@ export const NAVIGATION: NavSection[] = [
id: 'conges',
label: 'Congés',
items: [
{
id: 'calendrier',
label: 'Calendrier des absences',
href: '/absences/calendrier',
},
{ id: 'attente', label: 'Demandes à traiter', href: '/absences/a-traiter' },
{ id: 'traitees', label: 'Traitées', href: '/absences/traitees' },
{ id: 'expirees', label: 'Expirées', href: '/absences/expirees' },
{
id: 'politiques',
label: 'Politiques de RTT',
href: '/reglages/politiques-rtt',
},
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' },
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente' },
{ id: 'politiques', label: 'Politiques de congés' },
],
},
{
@@ -110,27 +74,14 @@ export const NAVIGATION: NavSection[] = [
{ id: 'paie', label: 'Préparation de paie', href: '/paie' },
{ id: 'periodes', label: 'Périodes de paie', href: '/paie/periodes' },
{ id: 'silae', label: 'Codes Silae', href: '/paie/silae' },
{
id: 'activite',
label: "Journal d'activité",
href: '/rapports/activite',
},
{ id: 'activite', label: "Journal d'activité" },
],
},
{
id: 'reglages',
label: 'Réglages',
items: [
{ id: 'compte', label: 'Informations du compte', href: '/reglages/compte' },
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
{ id: 'preferences', label: 'Préférences', href: '/reglages/preferences' },
{ id: 'paie-reglages', label: 'Réglages de paie', href: '/reglages/paie' },
{ id: 'impression', label: 'Impression', href: '/reglages/impression' },
{
id: 'productivite',
label: 'Productivité',
href: '/reglages/productivite',
},
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
{ id: 'securite', label: 'Sécurité', href: '/reglages/securite' },
@@ -139,24 +90,8 @@ export const NAVIGATION: NavSection[] = [
label: 'Durées de conservation',
href: '/reglages/conservation',
},
{ id: 'rgpd', label: 'RGPD', href: '/reglages/rgpd' },
{
id: 'modeles',
label: 'Modèles de documents',
href: '/reglages/modeles-documents',
},
{
id: 'convention',
label: 'Convention collective',
href: '/reglages/convention',
},
{ id: 'emplois', label: 'Emplois', href: '/reglages/emplois' },
{ id: 'etiquettes', label: 'Étiquettes', href: '/reglages/etiquettes' },
{
id: 'types-absence',
label: 'Types d’absence',
href: '/reglages/types-absence',
},
{ id: 'convention', label: 'Convention collective' },
{ id: 'postes', label: 'Postes et étiquettes' },
{ id: 'roles', label: 'Rôles et permissions', href: '/reglages/roles' },
],
},
+5 -9
View File
@@ -6,23 +6,19 @@ 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 shadow-e1 hover:bg-accent-hover active:bg-accent-press active:shadow-none',
'border-accent bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-press',
secondary:
'border-line-2 bg-surface text-ink-1 hover:border-line-3 hover:bg-surface-2 active:bg-surface-3',
'border-line-3 bg-surface text-ink-1 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 shadow-e1 hover:bg-danger-hover active:bg-danger-hover active:shadow-none',
'border-danger bg-danger text-danger-ink hover:bg-danger-hover active:bg-danger-hover',
};
/* 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-8 px-3 text-xs',
md: 'h-9 px-4 text-sm',
sm: 'h-[26px] px-2.5 text-xs',
md: 'h-8 px-3.5 text-sm',
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
+1 -7
View File
@@ -12,7 +12,6 @@ export function Card({ children, className }: CardProps) {
<section
className={cx(
'overflow-hidden rounded-3 border border-line-1 bg-surface',
'shadow-e1',
className,
)}
>
@@ -30,13 +29,8 @@ 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">
<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>
<h2 className="text-md font-semibold">{title}</h2>
{badge}
<span className="flex-1" />
{action}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useId, useState } from 'react';
import {
DEFAULT_DIAL_COUNTRY,
DIAL_CODES,
countryLabel,
splitDial,
} from '@/domain/hr/geo';
/**
* Numéro de téléphone avec indicatif.
*
* Deux contrôles à l'écran, une seule valeur envoyée : l'indicatif et le reste
* sont recollés dans un champ caché. Enregistrer l'indicatif à part obligerait
* chaque lecture — annuaire, export, SMS — à le recomposer, et un numéro à
* moitié lu ne joint personne.
*
* La liste des indicatifs est courte à dessein. Le champ accepte un numéro
* international saisi en entier : c'est ce qui reste vrai pour les pays qui
* n'y figurent pas.
*/
export function PhoneField({
label,
name,
defaultValue = '',
hint,
}: {
label: string;
name: string;
defaultValue?: string;
hint?: string;
}) {
const generated = useId();
const initial = splitDial(defaultValue);
const [dial, setDial] = useState(initial.dial);
const [rest, setRest] = useState(initial.rest);
const options = Object.entries(DIAL_CODES)
.map(([code, value]) => ({
code,
value,
label: `${countryLabel(code) ?? code} ${value}`,
}))
.sort((a, b) => {
if (a.code === DEFAULT_DIAL_COUNTRY) return -1;
if (b.code === DEFAULT_DIAL_COUNTRY) return 1;
return a.label.localeCompare(b.label, 'fr');
});
// Un numéro vide reste vide : recoller l'indicatif seul enregistrerait
// « +33 » comme un numéro, et l'annuaire afficherait un contact fantôme.
const composed = rest.trim() ? `${dial} ${rest.trim()}` : '';
return (
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<label htmlFor={generated} className="text-sm font-medium">
{label}
</label>
<input type="hidden" name={name} value={composed} />
<div className="flex gap-2">
<select
aria-label={`Indicatif — ${label}`}
value={dial}
onChange={(event) => setDial(event.target.value)}
className="h-9 w-28 shrink-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus"
>
{options.map((option) => (
<option key={option.code} value={option.value}>
{option.label}
</option>
))}
</select>
<input
id={generated}
type="tel"
inputMode="tel"
autoComplete="tel-national"
value={rest}
onChange={(event) => setRest(event.target.value)}
{...(hint ? { 'aria-describedby': `${generated}-hint` } : {})}
className="h-9 min-w-0 flex-1 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none placeholder:text-ink-3 focus-visible:border-focus"
/>
</div>
{hint ? (
<span id={`${generated}-hint`} className="text-micro text-ink-3">
{hint}
</span>
) : null}
</div>
);
}
-33
View File
@@ -1,33 +0,0 @@
/**
* 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);
}
+3 -8
View File
@@ -363,21 +363,16 @@ export const minBreakAfterThreshold: Rule = (context) => {
(shift.endAt.getTime() - shift.startAt.getTime()) / MINUTE,
);
if (span <= thresholdMinutes) 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 [];
if (shift.breakMinutes >= minBreakMinutes) return [];
return [
violation(
'MIN_BREAK_AFTER_THRESHOLD',
`${restMinutes} min de pause pour ${hours(span)} de présence : minimum ${minBreakMinutes} min au-delà de ${hours(thresholdMinutes)}.`,
`${shift.breakMinutes} 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: restMinutes,
breakMinutes: shift.breakMinutes,
requiredMinutes: minBreakMinutes,
spanMinutes: span,
},
-9
View File
@@ -98,16 +98,7 @@ 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;
}
-11
View File
@@ -22,15 +22,6 @@ export interface WeekCounters {
sundaysWorked: number;
/** Jours de repos dans la semaine. */
restDays: number;
/**
* Repos compensateur posé sur la semaine, en minutes.
*
* Distinct de `restDays` : un jour sans créneau est un jour sans travail,
* tandis qu'un repos compensateur est une **contrepartie due** — au dimanche
* travaillé, au dépassement de contingent. Les confondre ferait disparaître
* du bandeau la seule valeur qui dit qu'une dette existe.
*/
compensatoryRestMinutes: number;
}
export interface WeekCountersView extends WeekCounters {
@@ -40,7 +31,6 @@ export interface WeekCountersView extends WeekCounters {
plannedLabel: string;
absenceLabel: string;
deltaLabel: string;
compensatoryRestLabel: string;
tone: DeltaTone;
}
@@ -84,7 +74,6 @@ export function computeWeekCounters(input: WeekCounters): WeekCountersView {
plannedLabel: formatMinutes(input.plannedMinutes),
absenceLabel: formatMinutes(input.absenceMinutes),
deltaLabel: formatDelta(deltaMinutes),
compensatoryRestLabel: formatMinutes(input.compensatoryRestMinutes),
tone: deltaTone(deltaMinutes),
};
}
-118
View File
@@ -1,118 +0,0 @@
/**
* Modèles de documents — PLAN.md §4.7 et §9.
*
* Un modèle est un corps de texte avec des variables `{{cle}}`. Le rendu se
* fait ici, dans le domaine, pour trois raisons :
*
* 1. **Une variable inconnue n'est jamais silencieuse.** Un `{{salire}}` mal
* orthographié doit faire échouer le rendu, pas produire une attestation
* trouée. Une pièce remise à un salarié avec un blanc à la place de son
* salaire est un document faux, pas un document incomplet.
* 2. **Les variables sont scopées par établissement.** La même attestation ne
* porte pas la même raison sociale ni le même SIRET d'un magasin à l'autre ;
* le contexte de résolution est celui de l'établissement du contrat.
* 3. Le corps est de l'HTML rédigé par un administrateur du compte. Il est
* **échappé à l'insertion des valeurs**, jamais à l'écriture : c'est la
* valeur venue du dossier qui pourrait porter un chevron, pas le gabarit.
*/
export interface TemplateField {
key: string;
label: string;
/** D'où vient la valeur : salarié, contrat ou établissement. */
scope: 'employee' | 'contract' | 'location';
}
/**
* Variables offertes à la rédaction.
*
* Liste fermée : un modèle ne peut référencer que ce que l'application sait
* résoudre. Ouvrir la syntaxe à des expressions transformerait un écran de
* réglage en langage de programmation, avec la surface d'attaque qui va avec.
*/
export const TEMPLATE_FIELDS: TemplateField[] = [
{ key: 'salarie.nom', label: 'Nom du salarié', scope: 'employee' },
{ key: 'salarie.prenom', label: 'Prénom du salarié', scope: 'employee' },
{ key: 'salarie.matricule', label: 'Matricule', scope: 'employee' },
{ key: 'contrat.type', label: 'Type de contrat', scope: 'contract' },
{ key: 'contrat.debut', label: 'Date de début', scope: 'contract' },
{ key: 'contrat.fin', label: 'Date de fin', scope: 'contract' },
{ key: 'contrat.emploi', label: 'Emploi', scope: 'contract' },
{
key: 'contrat.heures',
label: 'Durée hebdomadaire',
scope: 'contract',
},
{ key: 'etablissement.nom', label: 'Établissement', scope: 'location' },
{ key: 'etablissement.siret', label: 'SIRET', scope: 'location' },
{ key: 'date.jour', label: 'Date du jour', scope: 'location' },
];
export const TEMPLATE_FIELD_KEYS: string[] = TEMPLATE_FIELDS.map(
(field) => field.key,
);
const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9._-]+)\s*\}\}/g;
/** Variables citées par un corps, dans l'ordre, sans doublon. */
export function referencedFields(bodyHtml: string): string[] {
const found = new Set<string>();
for (const match of bodyHtml.matchAll(PLACEHOLDER)) {
const key = match[1];
if (key) found.add(key);
}
return [...found];
}
/** Variables citées mais que l'application ne sait pas résoudre. */
export function unknownFields(bodyHtml: string): string[] {
return referencedFields(bodyHtml).filter(
(key) => !TEMPLATE_FIELD_KEYS.includes(key),
);
}
/**
* Échappement HTML des valeurs insérées.
*
* Un nom de famille contenant `&` ou une apostrophe typographique doit sortir
* tel quel ; une valeur venue d'un import mal nettoyé ne doit pas pouvoir
* ouvrir une balise.
*/
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export class MissingTemplateValue extends Error {
constructor(public readonly keys: string[]) {
super(
`Le modèle cite des variables sans valeur : ${keys.join(', ')}. Complétez le dossier avant de générer la pièce.`,
);
this.name = 'MissingTemplateValue';
}
}
/**
* Rend un modèle avec les valeurs fournies.
*
* Lève dès qu'une variable citée n'a pas de valeur. Produire l'attestation avec
* un blanc serait pire que de refuser : le blanc se remarque à la relecture une
* fois sur deux, et la pièce part signée.
*/
export function renderTemplate(
bodyHtml: string,
values: Record<string, string | null | undefined>,
): string {
const missing = referencedFields(bodyHtml).filter(
(key) => values[key] === null || values[key] === undefined || values[key] === '',
);
if (missing.length > 0) throw new MissingTemplateValue(missing);
return bodyHtml.replace(PLACEHOLDER, (_match, key: string) =>
escapeHtml(String(values[key])),
);
}
-37
View File
@@ -161,43 +161,6 @@ 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.
*
+183
View File
@@ -0,0 +1,183 @@
/**
* Référentiels géographiques du dossier salarié.
*
* Les pays sont stockés par leur **code ISO 3166-1 alpha-2**, pas par leur nom.
* Un nom dépend de la langue de l'interface et se retouche au fil des versions
* d'ICU ; un code ne bouge pas. Les libellés sont produits à l'affichage par
* `Intl.DisplayNames`, ce qui évite d'entretenir à la main deux cent cinquante
* traductions et d'en rater les mises à jour.
*
* Les valeurs déjà en base d'avant ce référentiel — du texte libre, « France »
* le plus souvent — sont rendues telles quelles : elles restent lisibles, et
* rien ne se perd en attendant qu'un passage sur la fiche les normalise.
*/
/** ISO 3166-1 alpha-2, ordre alphabétique du code. */
export const COUNTRY_CODES = [
'AD','AE','AF','AG','AI','AL','AM','AO','AQ','AR','AS','AT','AU','AW','AX','AZ',
'BA','BB','BD','BE','BF','BG','BH','BI','BJ','BL','BM','BN','BO','BQ','BR','BS',
'BT','BV','BW','BY','BZ','CA','CC','CD','CF','CG','CH','CI','CK','CL','CM','CN',
'CO','CR','CU','CV','CW','CX','CY','CZ','DE','DJ','DK','DM','DO','DZ','EC','EE',
'EG','EH','ER','ES','ET','FI','FJ','FK','FM','FO','FR','GA','GB','GD','GE','GF',
'GG','GH','GI','GL','GM','GN','GP','GQ','GR','GS','GT','GU','GW','GY','HK','HM',
'HN','HR','HT','HU','ID','IE','IL','IM','IN','IO','IQ','IR','IS','IT','JE','JM',
'JO','JP','KE','KG','KH','KI','KM','KN','KP','KR','KW','KY','KZ','LA','LB','LC',
'LI','LK','LR','LS','LT','LU','LV','LY','MA','MC','MD','ME','MF','MG','MH','MK',
'ML','MM','MN','MO','MP','MQ','MR','MS','MT','MU','MV','MW','MX','MY','MZ','NA',
'NC','NE','NF','NG','NI','NL','NO','NP','NR','NU','NZ','OM','PA','PE','PF','PG',
'PH','PK','PL','PM','PN','PR','PS','PT','PW','PY','QA','RE','RO','RS','RU','RW',
'SA','SB','SC','SD','SE','SG','SH','SI','SJ','SK','SL','SM','SN','SO','SR','SS',
'ST','SV','SX','SY','SZ','TC','TD','TF','TG','TH','TJ','TK','TL','TM','TN','TO',
'TR','TT','TV','TW','TZ','UA','UG','UM','US','UY','UZ','VA','VC','VE','VG','VI',
'VN','VU','WF','WS','YE','YT','ZA','ZM','ZW',
] as const;
/**
* Indicatifs téléphoniques.
*
* Volontairement partiels : ceux des pays d'où l'on embauche couramment en
* France. Le champ accepte de toute façon un numéro international saisi en
* entier — proposer deux cent cinquante indicatifs allongerait la liste sans
* rendre la saisie plus sûre.
*/
export const DIAL_CODES: Record<string, string> = {
FR: '+33', BE: '+32', CH: '+41', LU: '+352', DE: '+49', ES: '+34', IT: '+39',
PT: '+351', GB: '+44', IE: '+353', NL: '+31', PL: '+48', RO: '+40', BG: '+359',
AT: '+43', DK: '+45', SE: '+46', NO: '+47', FI: '+358', GR: '+30', HU: '+36',
CZ: '+420', SK: '+421', HR: '+385', LT: '+370', LV: '+371', EE: '+372',
DZ: '+213', MA: '+212', TN: '+216', SN: '+221', CI: '+225', ML: '+223',
CM: '+237', CD: '+243', MG: '+261', HT: '+509', TR: '+90', UA: '+380',
US: '+1', CA: '+1', BR: '+55', CN: '+86', IN: '+91', JP: '+81',
GP: '+590', MQ: '+596', GF: '+594', RE: '+262', YT: '+262', NC: '+687',
PF: '+689',
};
/** Indicatif le plus courant ici : la saisie part de là. */
export const DEFAULT_DIAL_COUNTRY = 'FR';
const displayNames =
typeof Intl !== 'undefined' && 'DisplayNames' in Intl
? new Intl.DisplayNames(['fr'], { type: 'region' })
: null;
/**
* Libellé d'un pays.
*
* Rend la valeur telle quelle quand elle n'est pas un code connu : c'est le cas
* des dossiers saisis avant ce référentiel, et les blanchir serait pire que de
* les laisser en texte libre.
*/
export function countryLabel(value: string | null): string | null {
if (!value) return null;
if (!/^[A-Z]{2}$/.test(value)) return value;
return displayNames?.of(value) ?? value;
}
/** Liste triée pour les menus déroulants, dans l'ordre alphabétique français. */
export function countryOptions(): Array<readonly [string, string]> {
return COUNTRY_CODES.map(
(code) => [code, countryLabel(code) ?? code] as const,
).sort((a, b) => a[1].localeCompare(b[1], 'fr'));
}
/**
* Départements français, Corse et outre-mer compris.
*
* Stockés par leur code : « 2A » et « 2B » ne sont pas des nombres, et le
* département de naissance sert à la déclaration sociale, qui attend le code.
*/
export const DEPARTMENTS: Array<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'],
['975', 'Saint-Pierre-et-Miquelon'], ['977', 'Saint-Barthélemy'],
['978', 'Saint-Martin'], ['984', 'Terres australes et antarctiques'],
['986', 'Wallis-et-Futuna'], ['987', 'Polynésie française'],
['988', 'Nouvelle-Calédonie'], ['989', 'Clipperton'],
];
const DEPARTMENT_NAMES = new Map(DEPARTMENTS);
export function departmentLabel(value: string | null): string | null {
if (!value) return null;
const name = DEPARTMENT_NAMES.get(value);
return name ? `${value} - ${name}` : value;
}
/** Options du menu, au format « 54 - Meurthe-et-Moselle ». */
export function departmentOptions(): Array<readonly [string, string]> {
return DEPARTMENTS.map(([code, name]) => [code, `${code} - ${name}`] as const);
}
export function isKnownCountry(value: string): boolean {
return (COUNTRY_CODES as readonly string[]).includes(value);
}
/**
* Ramène une valeur héritée à son code.
*
* Les dossiers saisis avant ce référentiel portent « France » en toutes
* lettres. Sans cette reconnaissance, le menu déroulant ne trouverait pas la
* valeur, retomberait sur « Non renseigné », et le premier enregistrement
* effacerait une information exacte.
*/
export function toCountryCode(value: string | null): string {
if (!value) return '';
if (isKnownCountry(value)) return value;
const needle = value.trim().toLocaleLowerCase('fr');
const match = COUNTRY_CODES.find(
(code) => countryLabel(code)?.toLocaleLowerCase('fr') === needle,
);
return match ?? '';
}
export function isKnownDepartment(value: string): boolean {
return DEPARTMENT_NAMES.has(value);
}
/**
* Découpe un numéro enregistré en indicatif et reste.
*
* L'indicatif le plus long l'emporte : « +1 » préfixe « +1 » mais aussi
* « +1242 », et tester dans l'ordre de la table donnerait le mauvais pays.
*/
export function splitDial(value: string): { dial: string; rest: string } {
const trimmed = value.trim();
const dials = [...new Set(Object.values(DIAL_CODES))].sort(
(a, b) => b.length - a.length,
);
const found = dials.find((dial) => trimmed.startsWith(dial));
if (!found) return { dial: DIAL_CODES[DEFAULT_DIAL_COUNTRY] as string, rest: trimmed };
return { dial: found, rest: trimmed.slice(found.length).trim() };
}
-184
View File
@@ -1,184 +0,0 @@
/**
* 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
@@ -1,147 +0,0 @@
/**
* Dictionnaire des données — matrice de conformité n° 14.
*
* « Donnée → finalité → base légale → destinataire → durée », livré comme
* artefact. Il décrit ce que **cette** application collecte, catégorie par
* catégorie ; il ne décrit pas le traitement RH de l'entreprise en général.
*
* Il vit dans le code et non en base : le contenu suit le schéma, et un
* dictionnaire tenu à la main dans une table divergerait au premier champ
* ajouté sans que rien ne le signale. La **durée**, elle, vient de
* `RetentionPolicy` : elle se paramètre, se justifie et s'audite.
*
* `À VALIDER` — l'affectation d'une base légale est une qualification
* juridique. Les lignes ci-dessous portent la lecture usuelle du traitement de
* paie et de planning ; elles doivent être confirmées par le responsable de
* traitement avant d'être opposées à qui que ce soit.
*/
export type LegalBasis =
| 'contract'
| 'legal_obligation'
| 'legitimate_interest'
| 'consent';
export const LEGAL_BASIS_LABELS: Record<LegalBasis, string> = {
contract: 'Exécution du contrat de travail',
legal_obligation: 'Obligation légale',
legitimate_interest: 'Intérêt légitime',
consent: 'Consentement',
};
export interface DictionaryEntry {
/** Catégorie de données, telle qu'un salarié la reconnaîtrait. */
category: string;
/** Objets du schéma qui la portent — le lien avec la politique de purge. */
objectTypes: string[];
purpose: string;
basis: LegalBasis;
recipients: string[];
/** Vrai pour une catégorie particulière au sens de l'article 9. */
sensitive: boolean;
notes?: string;
}
export const DATA_DICTIONARY: DictionaryEntry[] = [
{
category: 'État civil et coordonnées',
objectTypes: ['EmployeeProfile'],
purpose:
'Tenue du registre unique du personnel, déclaration préalable à l’embauche, édition des contrats.',
basis: 'legal_obligation',
recipients: ['Service RH', 'Cabinet de paie'],
sensitive: false,
},
{
category: 'Numéro de sécurité sociale',
objectTypes: ['EmployeeProfile'],
purpose: 'Déclarations sociales et transmission au logiciel de paie.',
basis: 'legal_obligation',
recipients: ['Cabinet de paie'],
sensitive: false,
notes:
'Chiffré au repos. Sa lecture n’est ouverte qu’aux capacités de paie.',
},
{
category: 'Coordonnées bancaires',
objectTypes: ['EmployeeProfile'],
purpose: 'Versement du salaire.',
basis: 'contract',
recipients: ['Cabinet de paie'],
sensitive: false,
notes: 'Chiffrées au repos.',
},
{
category: 'Contrat, rémunération et avenants',
objectTypes: ['UserContract', 'Amendment'],
purpose:
'Exécution et suivi du contrat, calcul des seuils de convention, préparation de la paie.',
basis: 'contract',
recipients: ['Service RH', 'Encadrement', 'Cabinet de paie'],
sensitive: false,
notes:
'La rémunération n’est visible qu’avec la capacité `members.salary.view`.',
},
{
category: 'Planning, heures prévues et réalisées',
objectTypes: ['Shift', 'WeeklySchedule', 'Rest', 'ForfaitDayEntry'],
purpose:
'Organisation du travail, contrôle des durées maximales et des repos, variables de paie.',
basis: 'legal_obligation',
recipients: ['Encadrement', 'Service RH', 'Cabinet de paie'],
sensitive: false,
},
{
category: 'Absences et compteurs',
objectTypes: ['TimeOff', 'Counter', 'LedgerOperation'],
purpose: 'Gestion des congés, décompte des droits, variables de paie.',
basis: 'contract',
recipients: ['Encadrement', 'Service RH', 'Cabinet de paie'],
sensitive: false,
},
{
category: 'Arrêts de travail et pièces de santé',
objectTypes: ['Document:SICK_NOTE'],
purpose:
'Justification de l’absence et déclaration à la sécurité sociale. Le motif médical n’est pas exploité.',
basis: 'legal_obligation',
recipients: ['Service RH'],
sensitive: true,
notes:
'Catégorie particulière (art. 9). Lecture journalisée ; le motif est masqué en vue encadrement.',
},
{
category: 'Titre de séjour et autorisation de travail',
objectTypes: ['WorkPermit'],
purpose:
'Vérification de l’autorisation de travail et suivi des échéances.',
basis: 'legal_obligation',
recipients: ['Service RH'],
sensitive: false,
notes:
'Le statut migratoire n’est ni affiché ni exploité au-delà de l’échéance.',
},
{
category: 'Comptes, sessions et second facteur',
objectTypes: ['User', 'Session', 'MfaRecoveryCode'],
purpose: 'Authentification, révocation d’accès, sécurité du compte.',
basis: 'legitimate_interest',
recipients: ['Administrateurs de l’instance'],
sensitive: false,
},
{
category: 'Journal d’audit',
objectTypes: ['AuditLog'],
purpose:
'Traçabilité des décisions opposables : contrat, absence, publication, période de paie, permissions.',
basis: 'legal_obligation',
recipients: ['Administrateurs', 'Contrôleurs habilités'],
sensitive: false,
notes: 'Append-only, imposé par un trigger de base.',
},
];
/** Catégories particulières, à traiter avec une vigilance distincte. */
export const SENSITIVE_ENTRIES = DATA_DICTIONARY.filter(
(entry) => entry.sensitive,
);
-147
View File
@@ -1,147 +0,0 @@
/**
* 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);
}
-49
View File
@@ -12,26 +12,13 @@ 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;
}
@@ -42,13 +29,7 @@ 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;
}
@@ -95,19 +76,6 @@ export interface AbsenceInput {
colorKey: string;
}
/**
* Repos posé sur la semaine.
*
* Seul le repos **compensateur** alimente le compteur RC : le repos
* hebdomadaire est un droit déjà pris, tandis que le compensateur est une
* contrepartie due, et le bandeau doit dire laquelle des deux est en jeu.
*/
export interface RestInput {
membershipId: string;
restType: 'WEEKLY_REST' | 'COMPENSATORY_REST';
minutes: number | null;
}
/**
* Range les créneaux d'une semaine en lignes × sept colonnes.
*
@@ -123,19 +91,9 @@ export function buildRows(
timeZone: string,
isPublished: boolean,
absences: AbsenceInput[] = [],
rests: RestInput[] = [],
): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
const columnOf = new Map(weekDates.map((date, index) => [date, index]));
const compensatoryByMember = new Map<string, number>();
for (const rest of rests) {
if (rest.restType !== 'COMPENSATORY_REST') continue;
compensatoryByMember.set(
rest.membershipId,
(compensatoryByMember.get(rest.membershipId) ?? 0) + (rest.minutes ?? 0),
);
}
const emptyDays = (): BoardShift[][] =>
Array.from({ length: 7 }, () => [] as BoardShift[]);
@@ -158,10 +116,6 @@ 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,
};
@@ -245,8 +199,6 @@ export function buildRows(
),
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
restDays: 7 - worked,
compensatoryRestMinutes:
compensatoryByMember.get(person.membershipId) ?? 0,
},
} satisfies BoardRow;
});
@@ -269,7 +221,6 @@ export function buildRows(
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
compensatoryRestMinutes: 0,
},
}
: null;
-161
View File
@@ -1,161 +0,0 @@
/**
* 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;
}
-70
View File
@@ -1,70 +0,0 @@
import type { BoardAbsence, BoardRow } from '@/domain/planning/board';
/**
* Lecture « présent / absent / repos » d'une ligne de planning.
*
* Vit dans le domaine et non dans l'écran : la vue des présences, la feuille
* imprimée et, demain, un export en tirent la même conclusion. Deux
* implémentations de cette règle finiraient par se contredire sur le seul cas
* qui compte — celui du salarié dont le congé a été validé après coup.
*/
export type DayState = 'present' | 'absent' | 'off';
/**
* Absence recouvrant la colonne demandée.
*
* Les absences d'une ligne sont déjà découpées sur la semaine affichée :
* `startDay` est un index de colonne, `span` un nombre de jours. Repartir des
* dates ici referait, moins bien, un travail déjà fait — et rouvrirait la
* question du fuseau au passage.
*/
export function absenceOnDay(
row: BoardRow,
dayIndex: number,
): BoardAbsence | undefined {
return row.absences.find(
(absence) =>
dayIndex >= absence.startDay && dayIndex < absence.startDay + absence.span,
);
}
/**
* État d'un salarié un jour donné.
*
* **L'absence l'emporte sur le créneau.** Un congé accepté après la
* construction du planning laisse les créneaux en base : afficher « présent »
* enverrait un responsable chercher en rayon quelqu'un qui est chez lui. Le
* planning a tort, pas l'absence.
*/
export function dayState(row: BoardRow, dayIndex: number): DayState {
if (absenceOnDay(row, dayIndex)) return 'absent';
return (row.days[dayIndex]?.length ?? 0) > 0 ? 'present' : 'off';
}
export interface DayHeadcount {
present: number;
absent: number;
off: number;
}
/**
* Effectif par jour sur un ensemble de lignes.
*
* Les lignes de besoins non couverts sont écartées : elles ne portent personne,
* et les compter en « repos » gonflerait un effectif qui n'existe pas.
*/
export function headcountByDay(
rows: BoardRow[],
dayCount: number,
): DayHeadcount[] {
const counted = rows.filter((row) => !row.unassigned);
return Array.from({ length: dayCount }, (_, index) => {
const tally: DayHeadcount = { present: 0, absent: 0, off: 0 };
for (const row of counted) {
tally[dayState(row, index)] += 1;
}
return tally;
});
}
-210
View File
@@ -1,210 +0,0 @@
/**
* Préférences du compte — PLAN.md §9.
*
* Le catalogue vit dans le domaine plutôt que dans l'écran : chaque préférence
* y porte son libellé, sa conséquence et, le cas échéant, son avertissement.
* Un interrupteur qui change un calcul de paie ne doit pas se présenter comme
* un réglage d'affichage, et la nuance ne tiendra pas si elle n'existe que dans
* le JSX.
*/
export type PreferenceGroup = 'planning' | 'rights' | 'payroll' | 'print';
/** Densités d'impression. Liste fermée : elle décide d'une mise en page. */
export const PRINT_DENSITIES = [
{
key: 'large',
label: 'Grande',
hint: 'Moins de salariés par page, lisible à distance sur un mur.',
},
{
key: 'compact',
label: 'Compacte',
hint: 'Tout l’effectif sur une page, au prix de la lisibilité de loin.',
},
] as const;
export type PrintDensity = (typeof PRINT_DENSITIES)[number]['key'];
export function isPrintDensity(value: string): value is PrintDensity {
return PRINT_DENSITIES.some((density) => density.key === value);
}
export interface PreferenceToggle {
key: string;
group: PreferenceGroup;
label: string;
/** Ce que l'interrupteur change réellement. */
hint: string;
/** Avertissement affiché en évidence — risque juridique ou de calcul. */
warning?: string;
/** Décalé sous la préférence dont il dépend. */
parent?: string;
}
export const PREFERENCE_TOGGLES: PreferenceToggle[] = [
// --- Plannings ---------------------------------------------------------
{
key: 'defaultMealPerShift',
group: 'planning',
label: 'Compter un repas par créneau',
hint: 'Chaque créneau ouvre un repas sans saisie. À décocher si le repas dépend de l’amplitude.',
},
{
key: 'paidBreaks',
group: 'planning',
label: 'Les pauses sont rémunérées',
hint: 'Les pauses cessent d’être déduites du temps de travail effectif — et entrent donc dans les seuils de convention.',
warning:
'Modifie le décompte des heures : les semaines déjà exportées ne sont pas recalculées.',
},
{
key: 'lockEmployeeMeals',
group: 'planning',
label: 'Les salariés ne peuvent pas modifier leur repas',
hint: 'Le repas devient une donnée de gestion, saisie par l’encadrement.',
},
{
key: 'employeesSeeOwnTotals',
group: 'planning',
label: 'Le salarié voit son total d’heures',
hint: 'Heures planifiées et réelles de sa semaine, sur son propre planning.',
},
{
key: 'employeesSeeTeamPlanning',
group: 'planning',
label: 'Le salarié voit le planning de son équipe',
hint: 'Les créneaux des autres membres de ses équipes, sans leurs compteurs.',
},
{
key: 'hideForfaitCounters',
group: 'planning',
label: 'Masquer les compteurs au forfait jours',
hint: 'Les alertes horaires de ces lignes sont masquées avec eux. Un forfait jours ne se compte pas en heures : un écart hebdomadaire y est un faux positif.',
},
// --- Droits ------------------------------------------------------------
{
key: 'employeesEditOwnProfile',
group: 'rights',
label: 'Le salarié modifie ses informations personnelles',
hint: 'Coordonnées et état civil. Le contrat et la rémunération restent hors de portée.',
},
{
key: 'employeesSeeOwnTimesheets',
group: 'rights',
label: 'Le salarié consulte ses feuilles de présence',
hint: 'Depuis son profil, en lecture seule.',
},
{
key: 'employeesSeeTeamContacts',
group: 'rights',
label: 'Le salarié voit les coordonnées de l’équipe',
hint: 'Téléphone et adresse e-mail de tous les membres.',
warning:
'Ouvre des données personnelles à l’ensemble de l’équipe : à n’activer que si la finalité le justifie.',
},
{
key: 'managerCreatedArePlannable',
group: 'rights',
label: 'Un salarié créé par un manager est planifiable aussitôt',
hint: 'Décoché, un administrateur ou un directeur doit le rendre planifiable depuis son profil.',
},
{
key: 'directorsSharePeople',
group: 'rights',
label: 'Les directeurs partagent leurs salariés',
hint: 'Un salarié devient disponible sur les plannings d’un autre établissement.',
},
{
key: 'directorsAccessHrDashboard',
group: 'rights',
label: 'Les directeurs accèdent au tableau de bord RH',
hint: 'Les agrégats restent bornés à leur périmètre d’établissement.',
},
{
key: 'directorsSeePaidLeave',
group: 'rights',
label: 'Compteurs de congés payés visibles des directeurs',
hint: 'Lecture seule. L’ajustement manuel reste une capacité distincte.',
},
{
key: 'managersSeePaidLeave',
group: 'rights',
label: 'Compteurs de congés payés visibles des managers',
hint: 'Lecture seule, même règle que pour les directeurs.',
},
// --- Paie --------------------------------------------------------------
{
key: 'smoothOvertimeMonthly',
group: 'payroll',
label: 'Lisser les heures supplémentaires au mois',
hint: 'Le décompte se fait sur le mois au lieu de la semaine.',
warning:
'Le décompte hebdomadaire est le principe (art. L3121-28). Lisser au mois suppose un accord d’aménagement du temps de travail ; sans accord, l’aménagement est inopposable et les heures restent dues à la semaine.',
},
{
key: 'includeRestInNormalHours',
group: 'payroll',
label: 'Inclure repos et absences dans les heures normales',
hint: 'Les heures travaillées au-delà du contractuel du fait d’une absence sont payées au taux normal plutôt qu’en heures supplémentaires.',
warning:
'Modifie la répartition entre heures normales et heures majorées : à confirmer avec le gestionnaire de paie avant tout export.',
},
{
key: 'autoEmployeeNumber',
group: 'payroll',
label: 'Générer le matricule automatiquement',
hint: 'Décoché, le matricule est saisi à l’embauche — utile quand il doit correspondre à celui du logiciel de paie.',
},
// --- Impression --------------------------------------------------------
{
key: 'printContractTotals',
group: 'print',
label: 'Imprimer les totaux contractuels et planifiés',
hint: 'Ajoute une colonne de totaux par salarié.',
},
{
key: 'printOtherTeams',
group: 'print',
label: 'Imprimer les créneaux des autres équipes',
hint: 'Situe la journée d’ensemble sur la feuille d’une équipe.',
},
{
key: 'printSunday',
group: 'print',
label: 'Imprimer le dimanche',
hint: 'À décocher pour un établissement qui n’ouvre jamais ce jour-là : la colonne gagnée profite aux six autres.',
},
{
key: 'printSignatureColumn',
group: 'print',
label: 'Imprimer la colonne « émargements »',
hint: 'Colonne vierge pour signature manuscrite.',
warning:
'Un émargement papier ne vaut pas décompte du temps de travail : le décompte reste celui de PlanFlow, pas celui de la feuille signée.',
},
];
export const PREFERENCE_KEYS: string[] = PREFERENCE_TOGGLES.map(
(toggle) => toggle.key,
);
export function togglesOf(group: PreferenceGroup): PreferenceToggle[] {
return PREFERENCE_TOGGLES.filter((toggle) => toggle.group === group);
}
/** « 20:00 » ↔ minutes depuis minuit, pour l'heure de bascule du soir. */
export function minutesToTime(minutes: number): string {
const hours = Math.floor(minutes / 60) % 24;
const rest = minutes % 60;
return `${String(hours).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
}
export function timeToMinutes(value: string): number | null {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
+4 -4
View File
@@ -229,7 +229,7 @@ export async function requestTimeOffAction(
return toState(error, "Vous n'avez pas le droit de poser cette absence.");
}
revalidatePath('/absences/calendrier');
revalidatePath('/conges');
return warning ? { ok: true, warning } : { ok: true };
}
@@ -328,7 +328,7 @@ export async function decideTimeOffAction(
return toState(error, "Vous n'avez pas le droit de décider de cette demande.");
}
revalidatePath('/absences/calendrier');
revalidatePath('/conges');
return { ok: true };
}
@@ -384,7 +384,7 @@ export async function cancelTimeOffAction(
return toState(error, "Vous n'avez pas le droit d'annuler cette absence.");
}
revalidatePath('/absences/calendrier');
revalidatePath('/conges');
return { ok: true };
}
@@ -462,7 +462,7 @@ export async function adjustCounterAction(
return toState(error, "Vous n'avez pas le droit d'ajuster un compteur.");
}
revalidatePath('/absences/calendrier');
revalidatePath('/conges');
return { ok: true };
}
-136
View File
@@ -322,142 +322,6 @@ export async function getAbsenceBoard(month: Month): Promise<AbsenceBoard> {
});
}
/**
* File d'absences filtrée par état — PLAN.md §9, écrans `/absences/*`.
*
* Les trois files partagent une requête plutôt qu'un écran chacune : la règle
* de masquage du motif médical vaut pour les trois, et la dupliquer trois fois
* garantirait qu'une des copies finisse par diverger. Une donnée de santé qui
* ne fuit que sur l'écran « traitées » reste une fuite.
*/
export type AbsenceQueue = 'pending' | 'treated' | 'expired';
export interface QueueFilters {
/** Restreint à un type d'absence. */
typeId?: string;
/** `true` : uniquement les absences Sécurité sociale (filtre relevé à l'audit). */
socialSecurityOnly?: boolean;
}
export interface AbsenceQueueResult {
queue: AbsenceQueue;
rows: AbsenceRequest[];
absenceTypes: Array<{ id: string; code: string; name: string }>;
canDecide: boolean;
ownMembershipId: string;
/** Décomptes des trois files, pour les onglets. */
counts: Record<AbsenceQueue, number>;
}
const QUEUE_STATUSES: Record<
AbsenceQueue,
Array<AbsenceRequest['status']>
> = {
pending: ['PENDING'],
treated: ['ACCEPTED', 'DECLINED', 'CANCELLED'],
expired: ['EXPIRED'],
};
export async function getAbsenceQueue(
queue: AbsenceQueue,
filters: QueueFilters = {},
): Promise<AbsenceQueueResult> {
return query('timeoff.view_own', async (db, actor) => {
const canSeeOthers = can(actor, 'timeoff.view_others');
const canDecide = can(actor, 'timeoff.decide');
const canSeeMedical = can(actor, 'members.documents.view');
const scope = canSeeOthers ? {} : { membershipId: actor.membershipId };
const rows = await db.timeOff.findMany({
where: {
...scope,
status: { in: QUEUE_STATUSES[queue] },
...(filters.typeId ? { absenceTypeId: filters.typeId } : {}),
...(filters.socialSecurityOnly
? { absenceType: { isSocialSecurity: true } }
: {}),
},
include: { absenceType: true },
// La file à traiter se lit par ancienneté — la plus vieille demande est
// celle qui coûte le plus au salarié qui attend. Les files closes se
// lisent à l'envers : on y cherche ce qui vient d'être décidé.
orderBy:
queue === 'pending'
? { requestedAt: 'asc' }
: { startDate: 'desc' },
take: 500,
});
const memberIds = [...new Set(rows.map((entry) => entry.membershipId))];
const members = await db.membership.findMany({
where: { id: { in: memberIds } },
include: { profile: { select: { firstName: true, lastName: true } } },
});
const nameById = new Map(
members.map((member) => [
member.id,
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
]),
);
const counts = await db.timeOff.groupBy({
by: ['status'],
where: scope,
_count: { _all: true },
});
const byStatus = new Map(
counts.map((row) => [row.status, row._count._all] as const),
);
const total = (queueKey: AbsenceQueue) =>
QUEUE_STATUSES[queueKey].reduce(
(sum, status) => sum + (byStatus.get(status) ?? 0),
0,
);
const absenceTypes = await db.absenceType.findMany({
where: { archivedAt: null },
select: { id: true, code: true, name: true },
orderBy: { code: 'asc' },
});
return {
queue,
rows: rows.map((entry) => ({
id: entry.id,
membershipId: entry.membershipId,
name: nameById.get(entry.membershipId) ?? 'Salarié',
typeLabel:
entry.absenceType.isSocialSecurity && !canSeeMedical
? 'Absence'
: entry.absenceType.name,
colorKey: entry.absenceType.colorKey,
isSocialSecurity: entry.absenceType.isSocialSecurity,
startDate: entry.startDate.toISOString().slice(0, 10),
endDate: entry.endDate.toISOString().slice(0, 10),
startHalfDay: entry.startHalfDay,
endHalfDay: entry.endHalfDay,
days: Number(entry.countedDays?.toString() ?? '0'),
status: entry.status,
comment:
entry.absenceType.isSocialSecurity && !canSeeMedical
? null
: entry.comment,
decisionComment: entry.decisionComment,
requestedAt: entry.requestedAt,
})),
absenceTypes,
canDecide,
ownMembershipId: actor.membershipId,
counts: {
pending: total('pending'),
treated: total('treated'),
expired: total('expired'),
},
};
});
}
/** Absences acceptées d'une période, pour la grille de planning. */
export async function acceptedAbsencesBetween(
membershipIds: string[],
-4
View File
@@ -67,7 +67,6 @@ interface LoadedShift {
startAt: Date;
endAt: Date;
breakMinutes: number;
paidBreakMinutes: number;
}
/**
@@ -263,7 +262,6 @@ async function buildContexts(
startAt: true,
endAt: true,
breakMinutes: true,
paidBreakMinutes: true,
},
});
@@ -279,7 +277,6 @@ async function buildContexts(
startAt: true,
endAt: true,
breakMinutes: true,
paidBreakMinutes: true,
},
});
@@ -324,7 +321,6 @@ async function buildContexts(
startAt: shift.startAt,
endAt: shift.endAt,
breakMinutes: shift.breakMinutes,
paidBreakMinutes: shift.paidBreakMinutes,
assigned: shift.membershipId !== null,
}));
-1
View File
@@ -37,7 +37,6 @@ export type EmailKind =
| 'INVITATION'
| 'PASSWORD_RESET'
| 'PLANNING_PUBLISHED'
| 'SHIFT_ASSIGNED'
| 'TIMEOFF_DECISION'
| 'LEAVE_NOTICE';
+4 -4
View File
@@ -38,6 +38,7 @@ 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(),
});
/**
@@ -49,10 +50,7 @@ const employeeInput = z.object({
*/
const hiringContractInput = z.object({
locationId: z.string().min(1, 'Établissement requis'),
// 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.'),
teamId: z.string().trim(),
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. */
@@ -94,6 +92,7 @@ export async function createEmployeeAction(
email: formData.get('email') ?? '',
phone: formData.get('phone') ?? '',
landline: formData.get('landline') ?? '',
smsSchedules: formData.get('smsSchedules') === 'on',
});
if (!parsed.success) {
@@ -226,6 +225,7 @@ 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,
});
+148 -12
View File
@@ -4,8 +4,7 @@ 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 { isKnownCountry, isKnownDepartment } from '@/domain/hr/geo';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
import { encryptOptional } from '@/server/crypto';
@@ -46,7 +45,22 @@ const optionalEnum = <T extends readonly string[]>(values: T) =>
)
.transform((value) => (value === '' ? null : value));
const COUNTRY_CODES = COUNTRIES.map((country) => country.code);
const optionalCountry = () =>
z
.string()
.trim()
.refine((value) => value === '' || isKnownCountry(value), 'Pays inconnu')
.transform((value) => (value === '' ? null : value));
const optionalDepartment = () =>
z
.string()
.trim()
.refine(
(value) => value === '' || isKnownDepartment(value),
'Département inconnu',
)
.transform((value) => (value === '' ? null : value));
const profileInput = z.object({
membershipId: z.string().min(1),
@@ -56,14 +70,11 @@ const profileInput = z.object({
lastName: z.string().trim().min(1, 'Nom requis').max(80),
birthDate: optionalText(10),
birthPlace: optionalText(120),
// 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),
// Les pays et le département viennent de listes fermées : accepter du texte
// libre rendrait le référentiel inutile dès la première saisie à la main.
birthCountry: optionalCountry(),
birthDepartment: optionalDepartment(),
nationality: optionalCountry(),
maritalStatus: optionalEnum([
'SINGLE',
'MARRIED',
@@ -91,11 +102,12 @@ 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: optionalEnum(COUNTRY_CODES),
country: optionalCountry(),
emergencyContactName: optionalText(120),
emergencyContactPhone: optionalText(30),
});
@@ -131,6 +143,9 @@ 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) {
@@ -271,3 +286,124 @@ export async function updateSensitiveAction(
revalidatePath(`/equipe/${membershipId}`);
return { ok: true };
}
const permitInput = z.object({
membershipId: z.string().min(1),
/** Décoché = le salarié n'a pas besoin d'autorisation : les titres tombent. */
foreignWorker: z.boolean(),
permitType: z.string().trim().max(120),
reference: z.string().trim().max(120),
issuedAt: z.string().trim().max(10),
expiresAt: z.string().trim().max(10),
});
/**
* Titre de séjour valant autorisation de travail.
*
* L'échéance est **obligatoire** dès qu'un titre est déclaré : un titre sans
* date de fin ne se surveille pas, et employer quelqu'un dont le titre a expiré
* est un délit — c'est précisément ce que cette date permet d'éviter.
*
* Décocher retire le titre plutôt que de le laisser dormir : un titre conservé
* pour un salarié qui n'en relève plus continuerait d'alarmer à son échéance.
*/
export async function updateWorkPermitAction(
_previous: ProfileActionState,
formData: FormData,
): Promise<ProfileActionState> {
const parsed = permitInput.safeParse({
membershipId: formData.get('membershipId') ?? '',
foreignWorker: formData.get('foreignWorker') === 'on',
permitType: formData.get('permitType') ?? '',
reference: formData.get('reference') ?? '',
issuedAt: formData.get('issuedAt') ?? '',
expiresAt: formData.get('expiresAt') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const { membershipId, foreignWorker } = parsed.data;
if (foreignWorker) {
if (!parsed.data.permitType) {
return { error: 'Précisez la nature du titre.' };
}
if (!parsed.data.reference) {
return { error: 'La référence du titre est requise.' };
}
if (!parsed.data.expiresAt) {
return {
error:
'La date d’expiration est requise : un titre sans échéance ne peut pas être surveillé.',
};
}
}
const issuedAt = parsed.data.issuedAt
? new Date(`${parsed.data.issuedAt}T00:00:00Z`)
: null;
const expiresAt = parsed.data.expiresAt
? new Date(`${parsed.data.expiresAt}T00:00:00Z`)
: null;
if (foreignWorker && expiresAt && issuedAt && expiresAt < issuedAt) {
return { error: 'Le titre expire avant d’avoir été délivré.' };
}
try {
await mutate('members.edit', async (db, actor) => {
const before = await db.workPermit.findFirst({
where: { membershipId },
orderBy: { expiresAt: 'desc' },
select: { id: true, permitType: true, expiresAt: true },
});
// Remplacement et non accumulation : la fiche n'en porte qu'un, et
// empiler les renouvellements ferait surveiller une échéance périmée à
// côté de la bonne.
await db.workPermit.deleteMany({ where: { membershipId } });
if (foreignWorker && expiresAt) {
await db.workPermit.create({
data: {
membershipId,
permitType: parsed.data.permitType,
reference: parsed.data.reference,
issuedAt,
expiresAt,
} as never,
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.work_permit.update',
entityType: 'Membership',
entityId: membershipId,
before: before
? {
permitType: before.permitType,
expiresAt: before.expiresAt.toISOString(),
}
: null,
after: foreignWorker
? {
permitType: parsed.data.permitType,
expiresAt: expiresAt?.toISOString() ?? null,
}
: { removed: true },
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return { error: 'Vous n’avez pas le droit de modifier ce dossier.' };
}
throw error;
}
revalidatePath(`/equipe/${membershipId}`);
return { ok: true };
}
+20
View File
@@ -285,6 +285,7 @@ export interface EmployeeProfileDetail {
country: string | null;
phone: string | null;
landline: string | null;
smsSchedules: boolean;
personalEmail: string | null;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
@@ -304,6 +305,13 @@ export interface EmployeeDetail extends EmployeeListRow {
contractEnd: Date | null;
};
profile: EmployeeProfileDetail | null;
/** Titre de séjour en cours, `null` quand le salarié n'en relève pas. */
workPermit: {
permitType: string;
reference: string;
issuedAt: Date | null;
expiresAt: Date;
} | null;
contracts: Array<{
id: string;
type: string;
@@ -363,6 +371,9 @@ export const getEmployee = cache(async function getEmployee(
teams: {
select: { isPrimary: true, team: { select: { name: true } } },
},
// Le plus lointain : c'est celui qui couvre la période en cours quand
// un renouvellement a été enregistré avant l'expiration du précédent.
workPermits: { orderBy: { expiresAt: 'desc' }, take: 1 },
contracts: {
orderBy: { startDate: 'desc' },
include: {
@@ -466,6 +477,14 @@ export const getEmployee = cache(async function getEmployee(
: null,
contractEnd: active?.endDate ?? null,
},
workPermit: membership.workPermits[0]
? {
permitType: membership.workPermits[0].permitType,
reference: membership.workPermits[0].reference,
issuedAt: membership.workPermits[0].issuedAt,
expiresAt: membership.workPermits[0].expiresAt,
}
: null,
profile: membership.profile
? {
gender: membership.profile.gender,
@@ -486,6 +505,7 @@ 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,
+2 -8
View File
@@ -2,8 +2,6 @@ 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';
@@ -84,9 +82,7 @@ export const getRegisterData = cache(async function getRegisterData(
'',
firstName: contract.membership.profile?.firstName ?? '',
sex: genderShort(contract.membership.profile?.gender ?? null),
nationality: nationalityLabel(
contract.membership.profile?.nationality ?? null,
),
nationality: contract.membership.profile?.nationality ?? null,
birthDate: contract.membership.profile?.birthDate ?? null,
jobTitle: contract.jobTitleId
? (jobTitleNames.get(contract.jobTitleId) ?? null)
@@ -156,9 +152,7 @@ export const listRegisterLocations = cache(
'',
firstName: contract.membership.profile?.firstName ?? '',
sex: genderShort(contract.membership.profile?.gender ?? null),
nationality: nationalityLabel(
contract.membership.profile?.nationality ?? null,
),
nationality: contract.membership.profile?.nationality ?? null,
birthDate: contract.membership.profile?.birthDate ?? null,
jobTitle: contract.jobTitleId ? 'x' : null,
qualification: contract.classification,
-232
View File
@@ -1,232 +0,0 @@
import { inScope } from '@/domain/access/authorize';
import { shiftMinutes } from '@/domain/counters/week';
import {
absenteeism,
headcount,
turnoverRate,
type AbsenceInput,
type HeadcountInput,
} from '@/domain/hr/indicators';
import {
formatMonthParam,
monthDates,
monthLabel,
previousMonth,
type Month,
} from '@/domain/planning/month';
import { query } from '@/server/context';
/**
* Analyses RH — PLAN.md §9, `/rh/analyses/{effectifs,heures,absences}`.
*
* Une série mensuelle, pas trois écrans qui recalculent chacun leur version.
* Les trois analyses lisent le même tableau : c'est ce qui garantit qu'un
* effectif moyen affiché en « effectifs » soit le même que le dénominateur du
* taux d'absentéisme affiché en « absences ». Deux calculs séparés finiraient
* par diverger d'un arrondi, et l'écart se plaide mal devant un CSE.
*
* Les indicateurs eux-mêmes viennent du domaine, testés aux bornes. Ce module
* ne fait que rassembler les données et boucler sur les mois.
*/
export interface MonthPoint {
month: Month;
label: string;
param: string;
closingHeadcount: number;
averageHeadcount: number;
entries: number;
exits: number;
turnoverRate: number | null;
absenceDays: number;
sickDays: number;
absenteeismRate: number | null;
/** Minutes planifiées, pauses déduites. */
plannedMinutes: number;
/** Minutes réellement travaillées quand elles ont été saisies. */
actualMinutes: number;
theoreticalWorkedDays: number;
}
export interface AnalyticsSeries {
location: { id: string; name: string } | null;
locations: Array<{ id: string; name: string }>;
/** Du plus ancien au plus récent : un graphique se lit dans ce sens. */
points: MonthPoint[];
}
/** Jours ouvrables d'un mois — base théorique de l'absentéisme (dimanche exclu). */
function workingDaysIn(dates: string[]): number {
return dates.filter(
(date) => new Date(`${date}T00:00:00Z`).getUTCDay() !== 0,
).length;
}
function monthsEndingWith(last: Month, count: number): Month[] {
const months: Month[] = [last];
for (let index = 1; index < count; index += 1) {
months.unshift(previousMonth(months[0] as Month));
}
return months;
}
export async function getAnalyticsSeries(
lastMonth: Month,
monthCount = 12,
locationId?: string,
): Promise<AnalyticsSeries> {
return query('members.view', async (db, actor) => {
const allLocations = await db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
// Le périmètre s'applique avant l'agrégation : un total consolidé ne doit
// pas laisser deviner l'effectif d'un établissement qu'on n'a pas le droit
// de lire.
const visible = allLocations.filter((location) =>
inScope(actor, { locationId: location.id }),
);
const location =
visible.find((candidate) => candidate.id === locationId) ??
visible[0] ??
null;
const months = monthsEndingWith(lastMonth, monthCount);
const firstDate = monthDates(months[0] as Month)[0] as string;
const lastDates = monthDates(months[months.length - 1] as Month);
const lastDate = lastDates[lastDates.length - 1] as string;
const locationFilter = location ? { locationId: location.id } : {};
// Une seule lecture par nature, puis un découpage en mémoire. Douze
// requêtes par indicateur donneraient le même résultat en douze fois plus
// de temps, et rendraient l'écran inutilisable sur une année glissante.
const contracts = await db.userContract.findMany({
where: locationFilter,
select: {
membershipId: true,
startDate: true,
endDate: true,
},
});
const contractInputs: HeadcountInput[] = contracts.map((contract) => ({
membershipId: contract.membershipId,
startDate: contract.startDate.toISOString().slice(0, 10),
endDate: contract.endDate?.toISOString().slice(0, 10) ?? null,
}));
const timeOffs = await db.timeOff.findMany({
where: {
status: 'ACCEPTED',
startDate: { lte: new Date(`${lastDate}T00:00:00Z`) },
endDate: { gte: new Date(`${firstDate}T00:00:00Z`) },
},
select: {
id: true,
startDate: true,
endDate: true,
countedDays: true,
absenceType: { select: { isSocialSecurity: true } },
},
});
const shifts = await db.shift.findMany({
where: {
localDate: {
gte: new Date(`${firstDate}T00:00:00Z`),
lte: new Date(`${lastDate}T00:00:00Z`),
},
},
select: {
localDate: true,
startAt: true,
endAt: true,
breakMinutes: true,
actualStartAt: true,
actualEndAt: true,
actualBreakMinutes: true,
},
});
const points = months.map((month) => {
const dates = monthDates(month);
const from = dates[0] as string;
const to = dates[dates.length - 1] as string;
const counts = headcount(contractInputs, from, to);
// Une absence à cheval sur deux mois est rattachée au mois de son début :
// `countedDays` a été figé à la décision et ne se redécoupe pas sans
// refaire le décompte des fériés, ce qui produirait un autre chiffre que
// celui écrit au registre des compteurs.
const monthAbsences: AbsenceInput[] = timeOffs
.filter((entry) => {
const start = entry.startDate.toISOString().slice(0, 10);
return start >= from && start <= to;
})
.map((entry) => ({
membershipId: entry.id,
timeOffId: entry.id,
days: Number(entry.countedDays?.toString() ?? '0'),
isSocialSecurity: entry.absenceType.isSocialSecurity,
}));
const theoreticalWorkedDays =
workingDaysIn(dates) * Math.round(counts.average);
const absence = absenteeism(monthAbsences, theoreticalWorkedDays);
const monthShifts = shifts.filter((shift) => {
const day = shift.localDate.toISOString().slice(0, 10);
return day >= from && day <= to;
});
const plannedMinutes = monthShifts.reduce(
(sum, shift) =>
sum + shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes),
0,
);
const actualMinutes = monthShifts.reduce((sum, shift) => {
if (!shift.actualStartAt || !shift.actualEndAt) {
// Sans heures réelles saisies, le prévu fait foi (PLAN.md §7.3) :
// laisser un zéro creuserait un écart qui n'existe pas.
return sum + shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes);
}
return (
sum +
shiftMinutes(
shift.actualStartAt,
shift.actualEndAt,
shift.actualBreakMinutes ?? shift.breakMinutes,
)
);
}, 0);
return {
month,
label: monthLabel(month),
param: formatMonthParam(month),
closingHeadcount: counts.closing,
averageHeadcount: counts.average,
entries: counts.entries.length,
exits: counts.exits.length,
turnoverRate: turnoverRate(counts),
absenceDays: absence.totalDays,
sickDays: absence.sickDays,
absenteeismRate: absence.rate,
plannedMinutes,
actualMinutes,
theoreticalWorkedDays,
};
});
return {
location: location ? { id: location.id, name: location.name } : null,
locations: visible,
points,
};
}, locationId ? { locationId } : undefined);
}
+9 -24
View File
@@ -13,21 +13,19 @@ 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 : 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.
* 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.
*
* 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.
* 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.
*/
export interface InstalledInstance {
@@ -107,7 +105,7 @@ export async function installAccount(
});
}
const location = await tx.location.create({
await tx.location.create({
data: {
accountId: account.id,
name: data.locationName,
@@ -115,19 +113,6 @@ 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
@@ -1,219 +0,0 @@
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.
}
+63 -272
View File
@@ -17,15 +17,7 @@ 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';
@@ -54,58 +46,18 @@ 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(),
/**
* 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),
localDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date invalide'),
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 },
@@ -147,34 +99,22 @@ 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,
localDates,
localDate: formData.get('localDate'),
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.' };
@@ -182,11 +122,37 @@ 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** 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);
// 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.",
);
}
const schedule = await ensureSchedule(
db,
@@ -200,127 +166,38 @@ 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,
);
}
const created = await db.shift.create({
data: {
weeklyScheduleId: schedule.id,
membershipId: parsed.data.membershipId ?? null,
localDate: new Date(`${localDate}T00:00:00Z`),
startAt,
endAt,
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',
entityType: 'Shift',
entityId: created.id,
after: {
teamId: team.id,
membershipId: created.membershipId,
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
minutes: worked,
mealCount: created.mealCount,
},
});
if (parsed.data.membershipId) {
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`),
startAt,
endAt,
breakMinutes: parsed.data.breakMinutes,
labelId: parsed.data.labelId ?? null,
note: parsed.data.note ?? null,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'planning.shift.create',
entityType: 'Shift',
entityId: created.id,
after: {
teamId: team.id,
membershipId: created.membershipId,
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
minutes: worked,
},
});
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.');
@@ -330,50 +207,6 @@ 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(),
@@ -381,12 +214,6 @@ 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(
@@ -400,17 +227,12 @@ 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({
@@ -446,14 +268,7 @@ export async function updateShiftAction(
);
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
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) {
if (shiftMinutes(startAt, endAt, parsed.data.breakMinutes) <= 0) {
throw new ValidationError(
'La pause dépasse la durée du créneau — rien ne serait travaillé.',
);
@@ -471,32 +286,11 @@ export async function updateShiftAction(
localDate: new Date(`${parsed.data.localDate}T00:00:00Z`),
startAt,
endAt,
breakMinutes: totals.breakMinutes,
paidBreakMinutes: totals.paidBreakMinutes,
mealCount: parsed.data.mealCount,
labelId: parsed.data.labelId ?? null,
note: parsed.data.note ?? null,
breakMinutes: parsed.data.breakMinutes,
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',
@@ -988,9 +782,6 @@ 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 };
-43
View File
@@ -147,22 +147,6 @@ export async function getWeekBoard(
schedules.map((schedule) => [schedule.teamId, schedule]),
);
// Repos posés sur la semaine. Seul le compensateur alimente le compteur
// RC du bandeau ; le repos hebdomadaire est chargé avec, parce que les
// deux se posent sur la même table et que filtrer ici coûterait une
// seconde requête pour une distinction que le domaine fait déjà.
const rests = await db.rest.findMany({
where: {
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
},
select: {
weeklyScheduleId: true,
membershipId: true,
restType: true,
minutes: true,
},
});
const shifts = await db.shift.findMany({
where: {
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
@@ -177,20 +161,9 @@ 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,
},
},
},
});
@@ -251,15 +224,6 @@ 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,
@@ -272,13 +236,6 @@ export async function getWeekBoard(
location.timezone,
isPublished,
absenceInputs,
rests
.filter((rest) => rest.weeklyScheduleId === schedule?.id)
.map((rest) => ({
membershipId: rest.membershipId,
restType: rest.restType,
minutes: rest.minutes,
})),
);
const teamAlerts = alerts
-147
View File
@@ -1,147 +0,0 @@
import { query } from '@/server/context';
/**
* Journal d'activité — PLAN.md §9, `/rapports/activite`.
*
* Le journal d'audit est append-only, imposé par un trigger. Cet écran ne fait
* que le lire : aucune action ne s'y trouve, et c'est le point. Une piste que
* l'on peut trier, filtrer et exporter, mais jamais corriger, est la seule qui
* prouve quelque chose.
*
* L'accès passe par `audit.view`, capacité distincte de l'administration : on
* confie la lecture de la piste à un contrôleur sans lui donner le droit de
* modifier ce qu'elle enregistre.
*/
export interface ActivityEntry {
id: string;
occurredAt: Date;
action: string;
entityType: string;
entityId: string;
actorName: string;
reason: string | null;
ip: string | null;
/** Champs dont la valeur a changé, sans leur contenu. */
changedFields: string[];
}
export interface ActivityFilters {
/** Préfixe d'action, par exemple `planning` ou `payroll.period`. */
domain?: string;
entityType?: string;
from?: string;
to?: string;
}
export interface ActivityPage {
entries: ActivityEntry[];
/** Domaines présents dans le journal, pour le filtre. */
domains: string[];
entityTypes: string[];
total: number;
truncated: boolean;
}
const PAGE_SIZE = 200;
/**
* Noms des champs modifiés, sans leur valeur.
*
* La piste enregistre l'avant et l'après en clair — c'est ce qui la rend
* opposable. Les afficher dans une liste, en revanche, exposerait un salaire ou
* une donnée de santé à quiconque ouvre l'écran. Le journal dit **ce qui** a
* changé ; le détail se lit sur la fiche, sous ses propres autorisations.
*/
function changedFields(before: unknown, after: unknown): string[] {
const keys = new Set<string>();
for (const value of [before, after]) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const key of Object.keys(value)) keys.add(key);
}
}
return [...keys].sort();
}
export async function getActivity(
filters: ActivityFilters = {},
): Promise<ActivityPage> {
return query('audit.view', async (db) => {
const where = {
...(filters.domain ? { action: { startsWith: filters.domain } } : {}),
...(filters.entityType ? { entityType: filters.entityType } : {}),
...(filters.from || filters.to
? {
occurredAt: {
...(filters.from
? { gte: new Date(`${filters.from}T00:00:00Z`) }
: {}),
// Borne haute inclusive : filtrer « jusqu'au 31 » doit contenir
// le 31, pas s'arrêter à son premier instant.
...(filters.to
? { lte: new Date(`${filters.to}T23:59:59.999Z`) }
: {}),
},
}
: {}),
};
const [rows, total] = await Promise.all([
db.auditLog.findMany({
where,
orderBy: { occurredAt: 'desc' },
take: PAGE_SIZE,
include: {
actor: {
include: {
profile: { select: { firstName: true, lastName: true } },
},
},
},
}),
db.auditLog.count({ where }),
]);
// Les listes de filtres viennent du journal lui-même : une action ajoutée
// par un lot futur y apparaît sans qu'on ait à tenir une énumération à
// jour, et une énumération figée finirait par masquer ce qu'elle omet.
const [actions, types] = await Promise.all([
db.auditLog.findMany({
distinct: ['action'],
select: { action: true },
orderBy: { action: 'asc' },
take: 500,
}),
db.auditLog.findMany({
distinct: ['entityType'],
select: { entityType: true },
orderBy: { entityType: 'asc' },
take: 200,
}),
]);
return {
entries: rows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt,
action: row.action,
entityType: row.entityType,
entityId: row.entityId,
actorName: row.actor
? `${row.actor.profile?.firstName ?? ''} ${row.actor.profile?.lastName ?? row.actor.employeeNumber}`.trim()
: 'Système',
reason: row.reason,
ip: row.ip,
changedFields: changedFields(row.before, row.after),
})),
domains: [
...new Set(
actions.map((row) => row.action.split('.')[0] ?? row.action),
),
].sort(),
entityTypes: types.map((row) => row.entityType),
total,
truncated: total > rows.length,
};
});
}
-267
View File
@@ -1,267 +0,0 @@
'use server';
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';
/**
* Types d'absence — PLAN.md §9, réglage « /reglages/types-absence ».
*
* Ce référentiel n'est pas décoratif : chaque drapeau porte une conséquence de
* paie ou de droit.
*
* - `isSocialSecurity` isole maladie, maternité et accident du travail. Le
* journal des absences les filtre séparément, et ce sont des **données de
* santé** : la vue manager ne doit pas en montrer le motif (matrice n° 9).
* - `affectsPaidLeaveAccrual` décide si la période acquiert des congés payés.
* - `countsAsWorkTime` décide si la durée entre dans le décompte horaire, donc
* dans les seuils de convention.
* - `silaeCode` est la partie `<code>` de `AB-<code>` à l'export (§8.2). Sans
* lui, l'export de la période **échoue** plutôt que de produire un fichier
* partiel — c'est voulu.
*
* **Autorisation.** Les mutations passent par `settings.agreement.manage` et
* non par un code dédié : le catalogue de §5 n'en déclare pas, et ces drapeaux
* relèvent du paramétrage conventionnel et légal, exactement ce que cette
* capacité gouverne. Créer un code sans mandat aurait été inventer une règle.
*/
export interface AbsenceTypeRow {
id: string;
code: string;
name: string;
colorKey: string;
isPaid: boolean;
countsAsWorkTime: boolean;
affectsPaidLeaveAccrual: boolean;
isSocialSecurity: boolean;
requiresJustification: boolean;
minNoticeDays: number | null;
silaeCode: string | null;
archivedAt: Date | null;
/** Absences saisies avec ce type — un type porté ne se supprime pas. */
timeOffCount: number;
}
export async function listAbsenceTypes(
includeArchived = false,
): Promise<AbsenceTypeRow[]> {
return query('settings.access', async (db) => {
const types = await db.absenceType.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: { code: 'asc' },
});
const counts = await db.timeOff.groupBy({
by: ['absenceTypeId'],
_count: { _all: true },
});
const byId = new Map(
counts.map((row) => [row.absenceTypeId, row._count._all] as const),
);
return types.map((type) => ({
id: type.id,
code: type.code,
name: type.name,
colorKey: type.colorKey,
isPaid: type.isPaid,
countsAsWorkTime: type.countsAsWorkTime,
affectsPaidLeaveAccrual: type.affectsPaidLeaveAccrual,
isSocialSecurity: type.isSocialSecurity,
requiresJustification: type.requiresJustification,
minNoticeDays: type.minNoticeDays,
silaeCode: type.silaeCode,
archivedAt: type.archivedAt,
timeOffCount: byId.get(type.id) ?? 0,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
class DuplicateAbsenceType extends Error {}
const flags = {
isPaid: z.coerce.boolean(),
countsAsWorkTime: z.coerce.boolean(),
affectsPaidLeaveAccrual: z.coerce.boolean(),
isSocialSecurity: z.coerce.boolean(),
requiresJustification: z.coerce.boolean(),
minNoticeDays: z
.union([z.literal(''), z.coerce.number().int().min(0).max(365)])
.transform((value) => (value === '' ? null : value)),
silaeCode: z
.string()
.trim()
.max(20)
.transform((value) => (value === '' ? null : value)),
};
const createInput = z.object({
code: z
.string()
.trim()
.min(1, 'Code requis')
.max(20)
.regex(/^[A-Za-z0-9_-]+$/, 'Lettres, chiffres, tirets et soulignés'),
name: z.string().trim().min(1, 'Nom requis').max(120),
colorKey: z.string().refine(isAbsenceColorKey, 'Famille de couleur inconnue'),
...flags,
});
function readForm(formData: FormData) {
return {
code: formData.get('code'),
name: formData.get('name'),
colorKey: formData.get('colorKey') || 'sans-solde',
isPaid: formData.get('isPaid') === 'on',
countsAsWorkTime: formData.get('countsAsWorkTime') === 'on',
affectsPaidLeaveAccrual: formData.get('affectsPaidLeaveAccrual') === 'on',
isSocialSecurity: formData.get('isSocialSecurity') === 'on',
requiresJustification: formData.get('requiresJustification') === 'on',
minNoticeDays: (formData.get('minNoticeDays') ?? '') as string,
silaeCode: (formData.get('silaeCode') ?? '') as string,
};
}
export async function createAbsenceTypeAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = createInput.safeParse(readForm(formData));
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const code = parsed.data.code.toUpperCase();
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const existing = await db.absenceType.findFirst({ where: { code } });
if (existing) throw new DuplicateAbsenceType();
const { code: _ignored, ...rest } = parsed.data;
const created = await db.absenceType.create({
data: { ...rest, code } as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'absence_type.create',
entityType: 'AbsenceType',
entityId: created.id,
after: { code: created.code, name: created.name },
});
});
} catch (error) {
if (error instanceof DuplicateAbsenceType) {
return { error: 'Ce code de type d’absence est déjà pris.' };
}
if (error instanceof AuthorizationError) {
return {
error: "Vous n'avez pas le droit de gérer les types d'absence.",
};
}
throw error;
}
revalidatePath('/reglages/types-absence');
return { ok: true };
}
const updateInput = z.object({
id: z.string().min(1),
name: z.string().trim().min(1, 'Nom requis').max(120),
colorKey: z.string().refine(isAbsenceColorKey, 'Famille de couleur inconnue'),
...flags,
});
export async function updateAbsenceTypeAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = updateInput.safeParse({
...readForm(formData),
id: formData.get('id'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const { id, ...data } = parsed.data;
const before = await db.absenceType.findUnique({ where: { id } });
if (!before) throw new AuthorizationError('settings.agreement.manage');
await db.absenceType.update({ where: { id }, data });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'absence_type.update',
entityType: 'AbsenceType',
entityId: id,
before: {
name: before.name,
isPaid: before.isPaid,
countsAsWorkTime: before.countsAsWorkTime,
affectsPaidLeaveAccrual: before.affectsPaidLeaveAccrual,
isSocialSecurity: before.isSocialSecurity,
requiresJustification: before.requiresJustification,
minNoticeDays: before.minNoticeDays,
silaeCode: before.silaeCode,
},
after: data,
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return {
error: "Vous n'avez pas le droit de gérer les types d'absence.",
};
}
throw error;
}
revalidatePath('/reglages/types-absence');
revalidatePath('/absences/calendrier');
return { ok: true };
}
export async function archiveAbsenceTypeAction(
formData: FormData,
): Promise<void> {
const id = String(formData.get('id') ?? '');
const restore = formData.get('restore') === '1';
if (!id) return;
await mutate('settings.agreement.manage', async (db, actor) => {
const before = await db.absenceType.findUnique({ where: { id } });
if (!before) return;
// Archiver, jamais supprimer : les absences passées gardent leur type, et
// les compteurs de congés s'appuient dessus pour justifier une écriture.
const archivedAt = restore ? null : new Date();
await db.absenceType.update({ where: { id }, data: { archivedAt } });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: restore ? 'absence_type.restore' : 'absence_type.archive',
entityType: 'AbsenceType',
entityId: id,
before: { archivedAt: before.archivedAt },
after: { archivedAt: archivedAt?.toISOString() ?? null },
});
});
revalidatePath('/reglages/types-absence');
revalidatePath('/absences/calendrier');
}
-385
View File
@@ -1,385 +0,0 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import {
isPrintDensity,
PREFERENCE_KEYS,
timeToMinutes,
} from '@/domain/settings/preferences';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Compte et préférences — PLAN.md §9, `/reglages/compte` et
* `/reglages/preferences`.
*
* Les préférences sont une ligne créée à la demande : l'absence de ligne vaut
* « tous les défauts ». Semer une ligne à l'installation obligerait à migrer
* chaque compte au premier réglage ajouté ; la lire avec un repli sur les
* défauts du modèle ne coûte rien et ne se périme pas.
*/
export interface AccountIdentity {
id: string;
name: string;
siren: string | null;
apeCode: string | null;
addressLine: string | null;
postalCode: string | null;
city: string | null;
country: string;
timezone: string;
}
export interface Preferences {
defaultMealPerShift: boolean;
paidBreaks: boolean;
lockEmployeeMeals: boolean;
employeesSeeOwnTotals: boolean;
employeesSeeTeamPlanning: boolean;
hideForfaitCounters: boolean;
eveningShiftStartMinutes: number;
employeesEditOwnProfile: boolean;
employeesSeeOwnTimesheets: boolean;
employeesSeeTeamContacts: boolean;
managerCreatedArePlannable: boolean;
directorsSharePeople: boolean;
directorsAccessHrDashboard: boolean;
directorsSeePaidLeave: boolean;
managersSeePaidLeave: boolean;
smoothOvertimeMonthly: boolean;
includeRestInNormalHours: boolean;
autoEmployeeNumber: boolean;
printLandscape: boolean;
printDensity: string;
printContractTotals: boolean;
printOtherTeams: boolean;
printSunday: boolean;
printSignatureColumn: boolean;
/** Chaîne décimale, ou `null` quand aucun objectif n'est fixé. */
productivityTargetPerHour: string | null;
}
const DEFAULTS: Preferences = {
defaultMealPerShift: false,
paidBreaks: false,
lockEmployeeMeals: false,
employeesSeeOwnTotals: true,
employeesSeeTeamPlanning: false,
hideForfaitCounters: true,
eveningShiftStartMinutes: 1200,
employeesEditOwnProfile: true,
employeesSeeOwnTimesheets: true,
employeesSeeTeamContacts: false,
managerCreatedArePlannable: false,
directorsSharePeople: false,
directorsAccessHrDashboard: true,
directorsSeePaidLeave: true,
managersSeePaidLeave: false,
smoothOvertimeMonthly: false,
includeRestInNormalHours: false,
autoEmployeeNumber: false,
printLandscape: true,
printDensity: 'large',
printContractTotals: true,
printOtherTeams: false,
printSunday: true,
printSignatureColumn: true,
productivityTargetPerHour: null,
};
export async function getAccountIdentity(): Promise<AccountIdentity | null> {
return query('settings.access', async (db, actor) => {
const account = await db.account.findUnique({
where: { id: actor.accountId },
});
if (!account) return null;
return {
id: account.id,
name: account.name,
siren: account.siren,
apeCode: account.apeCode,
addressLine: account.addressLine,
postalCode: account.postalCode,
city: account.city,
country: account.country,
timezone: account.timezone,
};
});
}
export async function getPreferences(): Promise<Preferences> {
return query('settings.access', async (db, actor) => {
const row = await db.accountPreferences.findUnique({
where: { accountId: actor.accountId },
});
if (!row) return DEFAULTS;
const {
accountId: _ignored,
updatedAt: _stamp,
productivityTargetPerHour,
...values
} = row;
return {
...values,
// Un Decimal Prisma ne traverse pas la frontière serveur/client. Une
// chaîne le fait sans perdre de décimale, ce qu'un `number` ne garantit
// pas sur un montant.
productivityTargetPerHour: productivityTargetPerHour?.toString() ?? null,
};
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
const identityInput = z.object({
name: z.string().trim().min(1, 'Raison sociale requise').max(200),
siren: z
.string()
.trim()
.regex(/^\d{9}$/, 'Le SIREN compte 9 chiffres')
.or(z.literal('')),
apeCode: z
.string()
.trim()
.regex(/^\d{4}[A-Z]$/, 'Le code APE s’écrit « 4759B »')
.or(z.literal('')),
addressLine: z.string().trim().max(200),
postalCode: z
.string()
.trim()
.regex(/^\d{5}$/, 'Le code postal compte 5 chiffres')
.or(z.literal('')),
city: z.string().trim().max(120),
timezone: z.string().trim().min(1).max(64),
});
export async function saveAccountIdentityAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = identityInput.safeParse({
name: formData.get('name'),
siren: formData.get('siren') ?? '',
apeCode: formData.get('apeCode') ?? '',
addressLine: formData.get('addressLine') ?? '',
postalCode: formData.get('postalCode') ?? '',
city: formData.get('city') ?? '',
timezone: formData.get('timezone') || 'Europe/Paris',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.locations.manage', async (db, actor) => {
const before = await db.account.findUnique({
where: { id: actor.accountId },
});
if (!before) throw new AuthorizationError('settings.locations.manage');
const data = {
name: parsed.data.name,
siren: parsed.data.siren || null,
apeCode: parsed.data.apeCode || null,
addressLine: parsed.data.addressLine || null,
postalCode: parsed.data.postalCode || null,
city: parsed.data.city || null,
timezone: parsed.data.timezone,
};
await db.account.update({ where: { id: actor.accountId }, data });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'account.update',
entityType: 'Account',
entityId: actor.accountId,
before: {
name: before.name,
siren: before.siren,
apeCode: before.apeCode,
city: before.city,
timezone: before.timezone,
},
after: data,
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de modifier le compte." };
}
throw error;
}
revalidatePath('/reglages/compte');
return { ok: true };
}
/**
* Enregistre un bloc de préférences.
*
* Les cases non cochées n'arrivent pas dans le `FormData` : le formulaire porte
* donc la liste des clés qu'il gouverne, et seules celles-là sont réécrites.
* Sans cette liste, enregistrer le bloc « Plannings » remettrait à faux tout le
* bloc « Droits » — un formulaire partiel effacerait ce qu'il n'affiche pas.
*/
export async function savePreferencesAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const scope = formData
.getAll('scope')
.map(String)
.filter((key) => PREFERENCE_KEYS.includes(key));
if (scope.length === 0) {
return { error: 'Formulaire incomplet : aucun réglage à enregistrer.' };
}
const values: Record<string, boolean | number | string> = {};
for (const key of scope) {
values[key] = formData.get(key) === 'on';
}
// L'orientation et la densité vivent dans le même formulaire que les
// interrupteurs d'impression : elles ne sont lues que si le formulaire les
// porte, sinon un autre bloc les remettrait à leur défaut.
const orientation = formData.get('printOrientation');
if (typeof orientation === 'string' && orientation !== '') {
values.printLandscape = orientation === 'landscape';
}
const density = formData.get('printDensity');
if (typeof density === 'string' && density !== '') {
if (!isPrintDensity(density)) {
return { error: 'Densité d’impression inconnue.' };
}
values.printDensity = density;
}
const eveningRaw = formData.get('eveningShiftStart');
if (typeof eveningRaw === 'string' && eveningRaw !== '') {
const minutes = timeToMinutes(eveningRaw);
if (minutes === null) {
return { error: 'Heure de bascule du soir invalide (format HH:MM).' };
}
values.eveningShiftStartMinutes = minutes;
}
try {
await mutate('settings.access', async (db, actor) => {
const before = await db.accountPreferences.findUnique({
where: { accountId: actor.accountId },
});
await db.accountPreferences.upsert({
where: { accountId: actor.accountId },
create: values as never,
update: values as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'account.preferences.update',
entityType: 'AccountPreferences',
entityId: actor.accountId,
before: before
? Object.fromEntries(
Object.keys(values).map((key) => [
key,
(before as Record<string, unknown>)[key],
]),
)
: null,
after: values,
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de modifier les réglages." };
}
throw error;
}
revalidatePath('/reglages/preferences');
revalidatePath('/reglages/paie');
revalidatePath('/reglages/impression');
return { ok: true };
}
const productivityInput = z.object({
target: z
.union([
z.literal(''),
z.coerce
.number()
.min(0, 'Un objectif négatif n’a pas de sens')
.max(100_000),
])
.transform((value) => (value === '' ? null : value)),
});
/**
* Objectif de productivité, en euros de chiffre d'affaires par heure.
*
* Vider le champ **efface** l'objectif au lieu de le mettre à zéro : zéro est
* un objectif inatteignable, l'absence de valeur dit qu'aucun objectif n'a été
* fixé. Un écran qui afficherait « 0 €/h attendu » pousserait à corriger un
* réglage qui n'existe pas.
*/
export async function saveProductivityTargetAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = productivityInput.safeParse({
target: formData.get('target') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.access', async (db, actor) => {
const before = await db.accountPreferences.findUnique({
where: { accountId: actor.accountId },
});
await db.accountPreferences.upsert({
where: { accountId: actor.accountId },
create: { productivityTargetPerHour: parsed.data.target } as never,
update: { productivityTargetPerHour: parsed.data.target },
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'account.productivity.update',
entityType: 'AccountPreferences',
entityId: actor.accountId,
before: {
productivityTargetPerHour:
before?.productivityTargetPerHour?.toString() ?? null,
},
after: { productivityTargetPerHour: parsed.data.target },
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de modifier les réglages." };
}
throw error;
}
revalidatePath('/reglages/productivite');
return { ok: true };
}
-306
View File
@@ -1,306 +0,0 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import {
agreementParametersSchema,
parseAgreementParameters,
type AgreementParameters,
} from '@/domain/compliance/parameters';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Convention collective — PLAN.md §9, réglage « /reglages/convention ».
*
* **Une convention ne se modifie pas : elle se réédite.** Chaque enregistrement
* crée une version datée, laissant intacte la précédente. C'est l'exigence
* effectif-datée de §12.2 : une paie de mars doit rester reproductible après un
* changement de règle en juin, ce qu'une mise à jour en place rendrait
* impossible — le moteur relirait les nouveaux seuils et produirait un autre
* résultat sur les mêmes données.
*
* L'origine de chaque valeur est portée par le registre de paramétrage
* (`LegalConfigEntry`, §12.7) : ordre public, disposition conventionnelle ou
* accord d'entreprise ne se corrigent pas de la même main.
*/
export interface AgreementVersion {
id: string;
idcc: string;
name: string;
version: number;
effectiveFrom: Date;
source: string | null;
approvedBy: string | null;
approvedAt: Date | null;
createdAt: Date;
parameters: AgreementParameters;
/** Vraie pour la version applicable aujourd'hui. */
isCurrent: boolean;
}
export async function listAgreementVersions(): Promise<AgreementVersion[]> {
return query('settings.access', async (db) => {
const rows = await db.collectiveAgreement.findMany({
orderBy: [{ effectiveFrom: 'desc' }, { version: 'desc' }],
});
const today = new Date();
// La version courante est la plus récente **déjà en vigueur** : une version
// datée du mois prochain existe en base sans gouverner le calcul du jour.
const currentId = rows.find((row) => row.effectiveFrom <= today)?.id ?? null;
return rows.map((row) => ({
id: row.id,
idcc: row.idcc,
name: row.name,
version: row.version,
effectiveFrom: row.effectiveFrom,
source: row.source,
approvedBy: row.approvedBy,
approvedAt: row.approvedAt,
createdAt: row.createdAt,
parameters: parseAgreementParameters(row.parameters),
isCurrent: row.id === currentId,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
/**
* Tranches d'heures supplémentaires, saisies une par ligne : `début;fin;taux`.
*
* Un tableau de longueur variable ne se saisit pas en champs fixes, et un JSON
* brut exposerait la forme interne. Le format compact reste vérifiable à l'œil,
* ce qui compte pour une valeur que le gestionnaire de paie doit relire.
* `fin` vide signifie « jusqu'à l'infini ».
*/
function parseTiers(raw: string) {
const lines = raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return lines.map((line, index) => {
const parts = line.split(';').map((part) => part.trim());
if (parts.length !== 3) {
throw new ParameterFormatError(
`Tranche ${index + 1} : trois valeurs attendues « début;fin;taux ».`,
);
}
const [from, to, rate] = parts as [string, string, string];
const fromMinutes = Number(from);
const toMinutes = to === '' ? null : Number(to);
const ratePercent = Number(rate);
if (
!Number.isFinite(fromMinutes) ||
(toMinutes !== null && !Number.isFinite(toMinutes)) ||
!Number.isFinite(ratePercent)
) {
throw new ParameterFormatError(
`Tranche ${index + 1} : valeurs numériques attendues.`,
);
}
return { fromMinutes, toMinutes, ratePercent };
});
}
/** Dérogations de temps partiel : `code;libellé;minutes` par ligne. */
function parseDerogations(raw: string) {
return raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const parts = line.split(';').map((part) => part.trim());
if (parts.length !== 3) {
throw new ParameterFormatError(
`Dérogation ${index + 1} : « code;libellé;minutes » attendu.`,
);
}
const [code, label, min] = parts as [string, string, string];
const minWeeklyMinutes = Number(min);
if (!code || !label || !Number.isFinite(minWeeklyMinutes)) {
throw new ParameterFormatError(
`Dérogation ${index + 1} : code, libellé et minutes requis.`,
);
}
return { code, label, minWeeklyMinutes };
});
}
class ParameterFormatError extends Error {}
const metaInput = z.object({
idcc: z.string().trim().min(1, 'IDCC requis').max(10),
name: z.string().trim().min(1, 'Intitulé requis').max(160),
effectiveFrom: z.coerce.date(),
source: z.string().trim().max(400),
approvedBy: z.string().trim().max(160),
});
function num(formData: FormData, key: string): number {
return Number(formData.get(key) ?? Number.NaN);
}
/**
* Reconstruit le jeu complet depuis le formulaire, puis le valide.
*
* La validation passe par le **même** schéma que le chargement du moteur : un
* jeu accepté ici est donc, par construction, un jeu que le moteur saura lire.
*/
function readParameters(formData: FormData): AgreementParameters {
const candidate = {
weeklyReferenceMinutes: num(formData, 'weeklyReferenceMinutes'),
maxDailyWorkMinutes: num(formData, 'maxDailyWorkMinutes'),
maxDailyAmplitudeMinutes:
String(formData.get('maxDailyAmplitudeMinutes') ?? '') === ''
? null
: num(formData, 'maxDailyAmplitudeMinutes'),
minDailyRestMinutes: num(formData, 'minDailyRestMinutes'),
minWeeklyRestMinutes: num(formData, 'minWeeklyRestMinutes'),
maxWeeklyWorkAbsoluteMinutes: num(formData, 'maxWeeklyWorkAbsoluteMinutes'),
averagedWeeklyWork: {
windowWeeks: num(formData, 'averagedWindowWeeks'),
maxAverageMinutes: num(formData, 'averagedMaxMinutes'),
},
maxConsecutiveWorkDays: num(formData, 'maxConsecutiveWorkDays'),
breakAfterThreshold: {
thresholdMinutes: num(formData, 'breakThresholdMinutes'),
minBreakMinutes: num(formData, 'breakMinMinutes'),
},
partTime: {
minWeeklyMinutes: num(formData, 'partTimeMinWeeklyMinutes'),
derogations: parseDerogations(String(formData.get('derogations') ?? '')),
},
contractDeviationToleranceMinutes: num(formData, 'deviationToleranceMinutes'),
overtime: {
tiers: parseTiers(String(formData.get('overtimeTiers') ?? '')),
annualQuotaMinutes: num(formData, 'overtimeQuotaMinutes'),
beyondQuotaRestPercent: num(formData, 'overtimeBeyondRestPercent'),
},
complementaryHours: {
firstTierRatePercent: num(formData, 'complementaryFirstRate'),
beyondRatePercent: num(formData, 'complementaryBeyondRate'),
firstTierFraction: num(formData, 'complementaryFirstFraction'),
capFraction: num(formData, 'complementaryCapFraction'),
},
sunday: {
premiumPercent: num(formData, 'sundayPremiumPercent'),
compensatoryRestPercent: num(formData, 'sundayRestPercent'),
mayorQuotaPerYear: num(formData, 'sundayMayorQuota'),
},
holiday: {
workedPremiumPercent: num(formData, 'holidayWorkedPremium'),
labourDayPremiumPercent: num(formData, 'holidayLabourDayPremium'),
paidOffDaysPerYear: num(formData, 'holidayPaidOffDays'),
substitutionRestPercent: num(formData, 'holidaySubstitutionRest'),
},
night: {
startMinutes: num(formData, 'nightStartMinutes'),
endMinutes: num(formData, 'nightEndMinutes'),
notEnforceableFromAge: num(formData, 'nightNotEnforceableFromAge'),
},
forfaitJours: {
maxDaysPerYear: num(formData, 'forfaitMaxDaysPerYear'),
workloadReviewIntervalMonths: num(formData, 'forfaitReviewMonths'),
},
};
const parsed = agreementParametersSchema.safeParse(candidate);
if (!parsed.success) {
const issue = parsed.error.issues[0];
throw new ParameterFormatError(
`${issue?.path.join('.') ?? 'paramètre'} : ${issue?.message ?? 'valeur refusée'}`,
);
}
return parsed.data;
}
export async function publishAgreementVersionAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const meta = metaInput.safeParse({
idcc: formData.get('idcc'),
name: formData.get('name'),
effectiveFrom: formData.get('effectiveFrom'),
source: formData.get('source') ?? '',
approvedBy: formData.get('approvedBy') ?? '',
});
if (!meta.success) {
return { error: meta.error.issues[0]?.message ?? 'Formulaire invalide' };
}
let parameters: AgreementParameters;
try {
parameters = readParameters(formData);
} catch (error) {
if (error instanceof ParameterFormatError) return { error: error.message };
throw error;
}
if (!meta.data.source) {
// Une valeur sans source n'est pas opposable : le registre de paramétrage
// exige de dire d'où elle vient avant de l'appliquer (§12.7).
return {
error:
'Indiquez la source du texte : sans elle, le paramètre n’est pas opposable.',
};
}
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const last = await db.collectiveAgreement.findFirst({
where: { idcc: meta.data.idcc },
orderBy: { version: 'desc' },
});
const created = await db.collectiveAgreement.create({
data: {
idcc: meta.data.idcc,
name: meta.data.name,
parameters: parameters as never,
version: (last?.version ?? 0) + 1,
effectiveFrom: meta.data.effectiveFrom,
source: meta.data.source,
approvedBy: meta.data.approvedBy || null,
approvedAt: meta.data.approvedBy ? new Date() : null,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'agreement.publish_version',
entityType: 'CollectiveAgreement',
entityId: created.id,
before: last
? { version: last.version, effectiveFrom: last.effectiveFrom }
: null,
after: {
version: created.version,
effectiveFrom: created.effectiveFrom,
source: created.source,
},
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer la convention." };
}
throw error;
}
revalidatePath('/reglages/convention');
return { ok: true };
}
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more