Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c311423d | ||
|
|
dd01fb58f4 | ||
|
|
9356c6e3f4 | ||
|
|
d90afe53a0 | ||
|
|
791dd523eb | ||
|
|
ef3dd5c027 | ||
|
|
fab497acdc | ||
|
|
aa7336d749 | ||
|
|
804e14bf04 | ||
|
|
a0524c56d7 | ||
|
|
0a916ff73e | ||
|
|
8033553222 | ||
|
|
5f656d05af | ||
|
|
d8060eb4a9 | ||
|
|
f0ef6f12a1 | ||
|
|
8c12b08961 | ||
|
|
db03768025 | ||
|
|
5b60e7ddb6 | ||
|
|
1519b6bf9d | ||
|
|
e4be93a208 | ||
|
|
98b289784a | ||
|
|
527ea2e78a | ||
|
|
9e57467138 | ||
|
|
b608aa87cf | ||
|
|
06198ddbce | ||
|
|
e27d61dd4c | ||
|
|
3bf2cbabff | ||
|
|
8808fcdb95 | ||
|
|
b49ceb1f29 | ||
|
|
894e880198 | ||
|
|
1d64989239 | ||
|
|
93724b9d66 | ||
|
|
91f3b240e1 | ||
|
|
5a113de02f | ||
|
|
bb8f84696a | ||
|
|
c6abbaadc5 | ||
|
|
5a05fda2f9 | ||
|
|
61a273548b |
No files matched your search
@@ -67,8 +67,8 @@ jobs:
|
||||
- name: Appliquer les migrations
|
||||
run: pnpm db:deploy
|
||||
|
||||
- name: Installer le jeu de données
|
||||
run: pnpm db:seed
|
||||
- name: Installer le jeu de démonstration (Playwright)
|
||||
run: pnpm db:seed:demo
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
|
||||
@@ -104,10 +104,12 @@ docker compose restart app
|
||||
|
||||
### Première installation
|
||||
|
||||
Les migrations posent le schéma, rien de plus : une instance neuve n'a **aucun compte et aucun utilisateur**. Le jeu de données de démonstration (`pnpm db:seed`) n'y remédie pas et refuse de tourner en production, à raison — personne ne veut de « Maison Rivage » et de salariés fictifs dans son registre du personnel.
|
||||
Les migrations posent le schéma, rien de plus : une instance neuve n'a **aucun compte et aucun utilisateur**. Il n'y a rien à semer pour démarrer — et surtout pas le jeu de démonstration (`pnpm db:seed:demo`), réservé au harnais de tests : des salariés inventés dans un annuaire réel se confondent avec de vrais salariés, et il refuse de toute façon de tourner en production.
|
||||
|
||||
À la place, la première visite est redirigée vers `/installation`. L'écran demande le nom de l'entreprise, un premier établissement avec son fuseau horaire, et le compte qui administrera l'instance. Il crée le compte, le catalogue des capacités, les cinq rôles fournis, et vous connecte.
|
||||
|
||||
Il pose aussi les **référentiels** sans lesquels rien ne s'accroche : les douze étiquettes de planning, cinq types d'absence, la convention d'amorce IDCC 1517 avec l'origine de chacun de ses paramètres, les durées de conservation et les jours fériés des deux prochaines années. Ce ne sont pas des exemples mais des minima : sans type d'absence aucune demande n'est saisissable, et sans convention le moteur de règles laisse passer une semaine de soixante heures sans rien dire. Tout se modifie ensuite depuis les réglages — la convention notamment, qui se réédite en version datée.
|
||||
|
||||
Deux choses à savoir :
|
||||
|
||||
- **L'écran ne se rouvre pas.** Il crée un propriétaire sans demander de session ; le laisser accessible ensuite reviendrait à offrir tous les droits au premier visiteur. Une ligne en base marque l'installation, et cette ligne ne se modifie ni ne s'efface depuis l'application. Remettre une instance à zéro est un geste d'exploitant, fait depuis la base.
|
||||
|
||||
+3
-2
@@ -19,11 +19,11 @@
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"mfa:reset": "tsx scripts/mfa-reset.ts",
|
||||
"retention:purge": "tsx scripts/retention-purge.ts",
|
||||
"verify": "pnpm typecheck && pnpm lint && pnpm test"
|
||||
"verify": "pnpm typecheck && pnpm lint && pnpm test",
|
||||
"db:seed:demo": "tsx prisma/seed-demo.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
@@ -34,6 +34,7 @@
|
||||
"@prisma/client": "7.9.1",
|
||||
"next": "16.3.0",
|
||||
"nodemailer": "^9.0.5",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "8.22.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.8",
|
||||
|
||||
Generated
+37
@@ -23,6 +23,9 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^9.0.5
|
||||
version: 9.0.5
|
||||
pdf-lib:
|
||||
specifier: ^1.17.1
|
||||
version: 1.17.1
|
||||
pg:
|
||||
specifier: 8.22.0
|
||||
version: 8.22.0
|
||||
@@ -769,6 +772,12 @@ packages:
|
||||
'@oxc-project/types@0.143.0':
|
||||
resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==}
|
||||
|
||||
'@pdf-lib/standard-fonts@1.0.0':
|
||||
resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
|
||||
|
||||
'@pdf-lib/upng@1.0.1':
|
||||
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
|
||||
|
||||
'@playwright/test@1.62.1':
|
||||
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2689,6 +2698,9 @@ packages:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2707,6 +2719,9 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdf-lib@1.17.1:
|
||||
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
|
||||
|
||||
perfect-debounce@2.1.0:
|
||||
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
|
||||
|
||||
@@ -3137,6 +3152,9 @@ packages:
|
||||
tsconfig-paths@3.15.0:
|
||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -3900,6 +3918,14 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.143.0': {}
|
||||
|
||||
'@pdf-lib/standard-fonts@1.0.0':
|
||||
dependencies:
|
||||
pako: 1.0.11
|
||||
|
||||
'@pdf-lib/upng@1.0.1':
|
||||
dependencies:
|
||||
pako: 1.0.11
|
||||
|
||||
'@playwright/test@1.62.1':
|
||||
dependencies:
|
||||
playwright: 1.62.1
|
||||
@@ -5943,6 +5969,8 @@ snapshots:
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -5955,6 +5983,13 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdf-lib@1.17.1:
|
||||
dependencies:
|
||||
'@pdf-lib/standard-fonts': 1.0.0
|
||||
'@pdf-lib/upng': 1.0.1
|
||||
pako: 1.0.11
|
||||
tslib: 1.14.1
|
||||
|
||||
perfect-debounce@2.1.0: {}
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
@@ -6432,6 +6467,8 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-bom: 3.0.0
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.20.6:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- État civil complet du dossier salarié.
|
||||
--
|
||||
-- Ces mentions manquaient au schéma alors que la loi les réclame ou que la
|
||||
-- paie s'appuie dessus :
|
||||
--
|
||||
-- - le **sexe** est exigé au registre unique du personnel (art. D1221-23) ;
|
||||
-- sans lui, tout dossier y sort incomplet ;
|
||||
-- - le **nom de naissance** ne se déduit pas du nom d'usage, et c'est lui que
|
||||
-- portent la déclaration sociale et le registre ;
|
||||
-- - le **pays** et le **département** de naissance sont demandés séparément
|
||||
-- par la déclaration : les extraire d'une commune saisie librement échouerait
|
||||
-- au premier « Bar-le-Duc (Meuse) » ;
|
||||
-- - la **situation de famille** et les **personnes à charge** commandent des
|
||||
-- droits que le salarié seul peut déclarer ;
|
||||
-- - l'**heure d'embauche** est demandée par la DPAE, que la date seule ne
|
||||
-- suffit pas à remplir.
|
||||
--
|
||||
-- Toutes les colonnes sont facultatives : un dossier incomplet doit pouvoir
|
||||
-- exister — c'est au registre de signaler ce qui lui manque, pas à la base de
|
||||
-- refuser l'embauche. Seule exception, l'envoi des plannings par SMS, qui est
|
||||
-- un consentement : explicitement faux par défaut, la charge de la preuve
|
||||
-- pesant sur l'employeur.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Gender" AS ENUM ('FEMALE', 'MALE', 'UNSPECIFIED');
|
||||
|
||||
-- Le PACS est distinct du concubinage et du mariage : les trois n'ouvrent pas
|
||||
-- les mêmes droits, et les confondre fausserait la paie.
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MaritalStatus" AS ENUM ('SINGLE', 'MARRIED', 'PACS', 'COHABITING', 'DIVORCED', 'WIDOWED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EmployeeProfile" ADD COLUMN "addressLine2" TEXT,
|
||||
ADD COLUMN "birthCountry" TEXT,
|
||||
ADD COLUMN "birthDepartment" TEXT,
|
||||
ADD COLUMN "birthName" TEXT,
|
||||
ADD COLUMN "dependents" INTEGER,
|
||||
ADD COLUMN "gender" "Gender",
|
||||
ADD COLUMN "landline" TEXT,
|
||||
ADD COLUMN "maritalStatus" "MaritalStatus",
|
||||
ADD COLUMN "smsSchedules" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserContract" ADD COLUMN "startTime" TEXT;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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());
|
||||
@@ -0,0 +1,67 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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());
|
||||
@@ -0,0 +1,17 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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);
|
||||
@@ -0,0 +1,61 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Pauses détaillées d'un créneau — PLAN.md §4.4.
|
||||
--
|
||||
-- `Shift.breakMinutes` garde son sens : la somme des pauses **non rémunérées**,
|
||||
-- déduite du temps de travail. Tous les calculs de paie et de compteurs
|
||||
-- continuent de la lire sans changer d'un caractère.
|
||||
--
|
||||
-- `paidBreakMinutes` s'ajoute à côté. Une pause payée n'est pas déduite du
|
||||
-- temps travaillé mais reste une pause : sans cette colonne, la règle de pause
|
||||
-- minimale au-delà de six heures verrait zéro et alerterait à tort sur un
|
||||
-- créneau qui respecte la convention.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE "Shift" ADD COLUMN "paidBreakMinutes" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE "ShiftBreak" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"shiftId" TEXT NOT NULL,
|
||||
"startMinutes" INTEGER,
|
||||
"durationMinutes" INTEGER NOT NULL,
|
||||
"isPaid" BOOLEAN NOT NULL DEFAULT false,
|
||||
"label" TEXT,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "ShiftBreak_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "ShiftBreak_accountId_idx" ON "ShiftBreak"("accountId");
|
||||
CREATE INDEX "ShiftBreak_shiftId_idx" ON "ShiftBreak"("shiftId");
|
||||
|
||||
ALTER TABLE "ShiftBreak" ADD CONSTRAINT "ShiftBreak_shiftId_fkey"
|
||||
FOREIGN KEY ("shiftId") REFERENCES "Shift"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Reprise des créneaux existants : leur total devient une pause unique, non
|
||||
-- rémunérée et non située. Laisser la table vide ferait disparaître de l'écran
|
||||
-- des pauses qui comptent pourtant toujours dans les heures payées.
|
||||
INSERT INTO "ShiftBreak" ("id", "accountId", "shiftId", "durationMinutes", "isPaid", "position")
|
||||
SELECT
|
||||
gen_random_uuid()::text,
|
||||
"accountId",
|
||||
"id",
|
||||
"breakMinutes",
|
||||
false,
|
||||
0
|
||||
FROM "Shift"
|
||||
WHERE "breakMinutes" > 0;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Isolation par compte — une pause dit à quelle heure quelqu'un s'absente de
|
||||
-- son poste. Même défense en profondeur que le créneau qui la porte.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE "ShiftBreak" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "ShiftBreak" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON "ShiftBreak"
|
||||
USING ("accountId" = planflow_current_account());
|
||||
|
||||
CREATE POLICY tenant_insert ON "ShiftBreak"
|
||||
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
|
||||
@@ -0,0 +1,14 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Nature d'envoi distincte pour l'affectation d'un créneau.
|
||||
--
|
||||
-- Réutiliser PLANNING_PUBLISHED aurait rendu le journal d'envois illisible : le
|
||||
-- jour où un salarié affirme n'avoir pas été prévenu, il faut pouvoir
|
||||
-- distinguer « la semaine a été publiée » de « un créneau a été ajouté après
|
||||
-- coup ».
|
||||
--
|
||||
-- Dans sa propre migration : PostgreSQL refuse qu'une valeur d'énumération soit
|
||||
-- ajoutée puis employée dans la même transaction. Les séparer évite d'avoir à
|
||||
-- s'en souvenir le jour où la migration suivante voudra s'en servir.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TYPE "EmailKind" ADD VALUE IF NOT EXISTS 'SHIFT_ASSIGNED';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Retrait de l'envoi des plannings par SMS.
|
||||
--
|
||||
-- Le drapeau était un consentement recueilli pour un canal qui n'a jamais
|
||||
-- existé : aucun envoi de SMS n'est branché, et il n'est pas prévu de l'être.
|
||||
-- Un consentement conservé sans finalité est une donnée collectée sans base
|
||||
-- légale — la minimisation impose de la supprimer, pas de la garder « au cas
|
||||
-- où ».
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE "EmployeeProfile" DROP COLUMN IF EXISTS "smsSchedules";
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Types d'absence sur les comptes installés avant WP-02 — PLAN.md §7.
|
||||
--
|
||||
-- `installReferentials()` pose le référentiel des absences depuis WP-02, mais
|
||||
-- il ne tourne qu'à la création du compte. Une instance installée **avant**
|
||||
-- garde donc zéro type d'absence, et le menu « Type » du formulaire de demande
|
||||
-- reste vide : le module de congés est inutilisable, sans qu'aucune erreur ne
|
||||
-- le signale. Déployer le correctif ne suffit pas — d'où ce rattrapage.
|
||||
--
|
||||
-- Il ne s'applique qu'aux comptes dont le référentiel est **entièrement** vide.
|
||||
-- Un compte qui a renommé ou archivé ses types garde les siens : personne ne
|
||||
-- doit voir réapparaître un type qu'il a délibérément retiré.
|
||||
--
|
||||
-- La liste double celle de `referentials.ts`, à dessein. Une migration est
|
||||
-- figée par nature — elle décrit l'état à rattraper au 12 août 2026, pas la
|
||||
-- liste courante. Les évolutions ultérieures du référentiel se font depuis
|
||||
-- `/reglages/types-absence`, pas en rééditant ce fichier.
|
||||
|
||||
-- `AbsenceType` porte FORCE ROW LEVEL SECURITY : la politique d'insertion vaut
|
||||
-- pour le propriétaire de la table comme pour tout le monde, et une migration
|
||||
-- n'a pas de compte courant. La levée tient le temps de la transaction — un
|
||||
-- échec l'annule, la table ne peut pas rester ouverte.
|
||||
ALTER TABLE "AbsenceType" NO FORCE ROW LEVEL SECURITY;
|
||||
|
||||
INSERT INTO "AbsenceType" (
|
||||
"id", "accountId", "code", "name", "colorKey",
|
||||
"isPaid", "countsAsWorkTime", "affectsPaidLeaveAccrual",
|
||||
"isSocialSecurity", "requiresJustification", "minNoticeDays"
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid()::text,
|
||||
a."id",
|
||||
t."code", t."name", t."colorKey",
|
||||
t."isPaid", FALSE, t."accrual",
|
||||
t."social", t."justify", t."notice"
|
||||
FROM "Account" a
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
-- Congés et repos
|
||||
('CP', 'Congé payé', 'cp', TRUE, TRUE, FALSE, FALSE, 30),
|
||||
('RTT', 'RTT', 'rtt', TRUE, TRUE, FALSE, FALSE, 7),
|
||||
('RC', 'Repos compensateur', 'rtt', TRUE, TRUE, FALSE, FALSE, 7),
|
||||
('RC_NUIT', 'Repos compensateur de nuit', 'rtt', TRUE, TRUE, FALSE, FALSE, 7),
|
||||
('RC_HAB', 'Repos compensateur d''habillement', 'rtt', TRUE, TRUE, FALSE, FALSE, 7),
|
||||
('RECUP_JF', 'Récupération jour férié', 'rtt', TRUE, TRUE, FALSE, FALSE, 7),
|
||||
('JF', 'Jour férié', 'ferie', TRUE, TRUE, FALSE, FALSE, NULL),
|
||||
('CSS', 'Congé sans solde', 'sans-solde', FALSE, FALSE, FALSE, FALSE, 15),
|
||||
|
||||
-- Santé et accidents — catégories particulières au sens du RGPD
|
||||
('MAL', 'Arrêt maladie', 'maladie', FALSE, FALSE, TRUE, TRUE, NULL),
|
||||
('MP', 'Maladie professionnelle', 'maladie', FALSE, TRUE, TRUE, TRUE, NULL),
|
||||
('AT', 'Accident du travail', 'maladie', FALSE, TRUE, TRUE, TRUE, NULL),
|
||||
('AT_TRAJET', 'Accident de trajet', 'maladie', FALSE, TRUE, TRUE, TRUE, NULL),
|
||||
('VM', 'Visite médicale', 'maladie', TRUE, TRUE, FALSE, TRUE, NULL),
|
||||
|
||||
-- Famille
|
||||
('MAT', 'Congé maternité', 'famille', FALSE, TRUE, TRUE, TRUE, NULL),
|
||||
('PAT', 'Congé paternité', 'famille', FALSE, TRUE, TRUE, TRUE, NULL),
|
||||
('PAR', 'Congé parental', 'famille', FALSE, FALSE, FALSE, TRUE, 30),
|
||||
('NAISS', 'Congé supplémentaire de naissance', 'famille', TRUE, TRUE, FALSE, TRUE, NULL),
|
||||
('EVT_FAM', 'Évènement familial', 'famille', TRUE, TRUE, FALSE, TRUE, NULL),
|
||||
|
||||
-- Formation et obligations
|
||||
('FORM', 'Formation', 'formation', TRUE, TRUE, FALSE, FALSE, 15),
|
||||
('EXAM', 'Congé pour examen', 'formation', TRUE, TRUE, FALSE, TRUE, 15),
|
||||
|
||||
-- Absences non planifiées et sanctions
|
||||
('ABS_JUS', 'Absence justifiée', 'sans-solde', FALSE, FALSE, FALSE, TRUE, NULL),
|
||||
('ABS_INJ', 'Absence injustifiée', 'sans-solde', FALSE, FALSE, FALSE, FALSE, NULL),
|
||||
('INDISPO', 'Indisponibilité ponctuelle', 'sans-solde', FALSE, FALSE, FALSE, FALSE, NULL),
|
||||
('MAP_D', 'Mise à pied disciplinaire', 'sanction', FALSE, FALSE, FALSE, TRUE, NULL),
|
||||
('MAP_C', 'Mise à pied conservatoire', 'sanction', FALSE, FALSE, FALSE, TRUE, NULL)
|
||||
) AS t ("code", "name", "colorKey", "isPaid", "accrual", "social", "justify", "notice")
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "AbsenceType" existing WHERE existing."accountId" = a."id"
|
||||
);
|
||||
|
||||
ALTER TABLE "AbsenceType" FORCE ROW LEVEL SECURITY;
|
||||
+204
-2
@@ -19,6 +19,14 @@ 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.
|
||||
@@ -34,8 +42,91 @@ 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.
|
||||
@@ -481,14 +572,35 @@ model EmployeeProfile {
|
||||
firstName String
|
||||
lastName String
|
||||
|
||||
/// Nom de naissance, distinct du nom d'usage. La paie, la déclaration
|
||||
/// sociale et le registre du personnel se tiennent au nom de naissance ;
|
||||
/// l'un ne remplace pas l'autre.
|
||||
birthName String?
|
||||
|
||||
/// Sexe — mention exigée au registre unique du personnel (art. D1221-23).
|
||||
/// Facultatif en base : un dossier incomplet doit pouvoir exister, et c'est
|
||||
/// le registre qui signale ce qui lui manque.
|
||||
gender Gender?
|
||||
|
||||
birthDate DateTime? @db.Date
|
||||
birthPlace String?
|
||||
/// Commune de naissance. Le pays et le département sont portés à part :
|
||||
/// la déclaration sociale les demande séparément, et les recoller depuis une
|
||||
/// chaîne libre échouerait au premier « Bar-le-Duc (Meuse) ».
|
||||
birthPlace String?
|
||||
birthCountry String?
|
||||
birthDepartment String?
|
||||
nationality String?
|
||||
/// Situation de famille et personnes à charge : la paie en dépend, et le
|
||||
/// salarié seul peut les déclarer.
|
||||
maritalStatus MaritalStatus?
|
||||
dependents Int?
|
||||
addressLine1 String?
|
||||
addressLine2 String?
|
||||
postalCode String?
|
||||
city String?
|
||||
country String?
|
||||
phone String?
|
||||
landline String?
|
||||
personalEmail String?
|
||||
|
||||
socialSecurityNumberEnc Bytes?
|
||||
@@ -505,6 +617,26 @@ model EmployeeProfile {
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
/// Sexe au sens de l'état civil, tel que le registre du personnel le demande.
|
||||
/// Trois valeurs et non deux : un dossier peut être ouvert avant que la
|
||||
/// mention soit connue, et « non précisé » se distingue d'un champ jamais rempli.
|
||||
enum Gender {
|
||||
FEMALE
|
||||
MALE
|
||||
UNSPECIFIED
|
||||
}
|
||||
|
||||
/// Situation de famille. Le PACS est distinct du concubinage et du mariage :
|
||||
/// les trois n'ouvrent pas les mêmes droits, et les confondre fausserait la paie.
|
||||
enum MaritalStatus {
|
||||
SINGLE
|
||||
MARRIED
|
||||
PACS
|
||||
COHABITING
|
||||
DIVORCED
|
||||
WIDOWED
|
||||
}
|
||||
|
||||
/// Titre de séjour et son échéance. Le tableau de bord RH surveille les
|
||||
/// expirations : un titre périmé interdit l'emploi.
|
||||
model WorkPermit {
|
||||
@@ -530,6 +662,9 @@ model UserContract {
|
||||
|
||||
contractType ContractType
|
||||
startDate DateTime @db.Date
|
||||
/// Heure de prise de poste le premier jour, au format « HH:MM ».
|
||||
/// Une date seule ne suffit pas à la DPAE, qui demande l'heure d'embauche.
|
||||
startTime String?
|
||||
endDate DateTime? @db.Date
|
||||
trialEndDate DateTime? @db.Date
|
||||
|
||||
@@ -680,7 +815,13 @@ model Shift {
|
||||
startAt DateTime @db.Timestamptz
|
||||
endAt DateTime @db.Timestamptz
|
||||
|
||||
/// Somme des pauses **non rémunérées**, déduite du temps de travail. C'est
|
||||
/// le total qui compte en paie ; le détail vit dans `ShiftBreak`.
|
||||
breakMinutes Int @default(0)
|
||||
/// Somme des pauses **rémunérées**. Non déduite du temps travaillé, mais
|
||||
/// comptée comme repos : la règle de pause minimale regarde les deux, sans
|
||||
/// quoi une pause payée passerait pour une absence de pause.
|
||||
paidBreakMinutes Int @default(0)
|
||||
actualStartAt DateTime? @db.Timestamptz
|
||||
actualEndAt DateTime? @db.Timestamptz
|
||||
actualBreakMinutes Int?
|
||||
@@ -694,12 +835,41 @@ model Shift {
|
||||
version Int @default(0)
|
||||
|
||||
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
|
||||
breaks ShiftBreak[]
|
||||
|
||||
@@index([accountId])
|
||||
@@index([weeklyScheduleId, localDate])
|
||||
@@index([membershipId, localDate])
|
||||
}
|
||||
|
||||
/// Pause d'un créneau.
|
||||
///
|
||||
/// Plusieurs pauses par créneau, et pas un total unique : une coupure de deux
|
||||
/// heures en milieu de journée et deux pauses de vingt minutes ne se planifient
|
||||
/// pas de la même façon et ne se contrôlent pas pareil — la seconde satisfait
|
||||
/// la pause minimale de six heures, la première interroge l'amplitude.
|
||||
///
|
||||
/// `startMinutes` est facultatif. Beaucoup de pauses se prennent « quand c'est
|
||||
/// calme » : imposer une heure obligerait à inventer une précision que le
|
||||
/// planning n'a pas, et qu'un contrôle prendrait pour un engagement.
|
||||
model ShiftBreak {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
shiftId String
|
||||
/// Minutes depuis le début du créneau. Null = pause non située.
|
||||
startMinutes Int?
|
||||
durationMinutes Int
|
||||
/// Rémunérée : la durée n'est pas déduite du temps de travail effectif.
|
||||
isPaid Boolean @default(false)
|
||||
label String?
|
||||
position Int @default(0)
|
||||
|
||||
shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([accountId])
|
||||
@@index([shiftId])
|
||||
}
|
||||
|
||||
/// Repos de journée — hebdomadaire ou compensateur. Les pauses *dans* un
|
||||
/// créneau sont `Shift.breakMinutes`.
|
||||
model Rest {
|
||||
@@ -744,6 +914,33 @@ 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
|
||||
@@ -763,6 +960,8 @@ 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).
|
||||
@@ -772,7 +971,8 @@ model Document {
|
||||
deletedAt DateTime?
|
||||
deletedBy String?
|
||||
|
||||
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade)
|
||||
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade)
|
||||
template DocumentTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([accountId])
|
||||
@@index([membershipId])
|
||||
@@ -1261,6 +1461,8 @@ enum EmailKind {
|
||||
INVITATION
|
||||
PASSWORD_RESET
|
||||
PLANNING_PUBLISHED
|
||||
/// Créneau ajouté ou déplacé hors d'une publication de semaine.
|
||||
SHIFT_ASSIGNED
|
||||
TIMEOFF_DECISION
|
||||
LEAVE_NOTICE
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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' },
|
||||
];
|
||||
@@ -22,15 +22,29 @@ 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 données de départ — PLAN.md §11.
|
||||
* Jeu de démonstration — PLAN.md §11.
|
||||
*
|
||||
* **Entièrement fictif.** Deux établissements, des équipes, et les rôles
|
||||
* fournis. Le mot de passe de démonstration n'a de sens qu'en développement ;
|
||||
* il est refusé si NODE_ENV vaut production.
|
||||
* **Entièrement fictif, et réservé aux tests.** Deux établissements, des
|
||||
* salariés inventés, quatre semaines de planning, des absences. Il sert au
|
||||
* harnais Playwright et à qui veut voir l'application peuplée ; il n'a rien à
|
||||
* faire sur une instance de travail, fût-elle de développement — des noms
|
||||
* inventés dans un annuaire réel se confondent avec de vrais salariés.
|
||||
*
|
||||
* Une instance neuve ne passe donc **plus** par ce fichier : l'écran de
|
||||
* première installation pose le compte, le propriétaire et les référentiels
|
||||
* (`src/server/install/referentials.ts`). Il n'y a rien à semer pour démarrer.
|
||||
*
|
||||
* Le mot de passe de démonstration n'a de sens qu'en développement ; il est
|
||||
* refusé si NODE_ENV vaut production.
|
||||
*/
|
||||
|
||||
const DEMO_PASSWORD = 'planflow-demo-2026';
|
||||
@@ -586,9 +600,10 @@ async function main() {
|
||||
|
||||
|
||||
console.log('→ Types d’absence');
|
||||
// `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.
|
||||
// `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.
|
||||
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 },
|
||||
@@ -600,12 +615,17 @@ 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 },
|
||||
update: {
|
||||
name: type.name,
|
||||
colorKey: type.colorKey,
|
||||
silaeCode: ABSENCE_CODES[type.code] ?? null,
|
||||
},
|
||||
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,
|
||||
@@ -709,12 +729,18 @@ async function main() {
|
||||
console.log(` ${demoAbsences.length} absences`);
|
||||
|
||||
console.log('→ Correspondances Silae');
|
||||
// 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.
|
||||
// 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;
|
||||
for (const definition of PAYROLL_ELEMENT_DEFINITIONS) {
|
||||
if (!definition.suggestedCode) continue;
|
||||
const code = ELEMENT_CODES[definition.key] ?? definition.suggestedCode;
|
||||
if (!code) continue;
|
||||
const existing = await prisma.silaeCodeMapping.findFirst({
|
||||
where: { accountId: account.id, sourceKey: definition.key },
|
||||
});
|
||||
@@ -724,16 +750,29 @@ async function main() {
|
||||
data: {
|
||||
accountId: account.id,
|
||||
sourceKey: definition.key,
|
||||
silaeCode: definition.suggestedCode,
|
||||
silaeCode: code,
|
||||
label: definition.label,
|
||||
kind: definition.kind,
|
||||
confirmed: false,
|
||||
confirmed: Boolean(ELEMENT_CODES[definition.key]),
|
||||
},
|
||||
});
|
||||
seededMappings += 1;
|
||||
}
|
||||
console.log(
|
||||
` ${PAYROLL_ELEMENT_DEFINITIONS.filter((d) => d.suggestedCode).length} proposées, aucune confirmée`,
|
||||
const missingElements = PAYROLL_ELEMENT_DEFINITIONS.filter(
|
||||
(definition) =>
|
||||
!ELEMENT_CODES[definition.key] && !definition.suggestedCode,
|
||||
);
|
||||
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.
|
||||
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
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('/');
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+17
-327
@@ -1,332 +1,22 @@
|
||||
import Link from 'next/link';
|
||||
import { permanentRedirect } from 'next/navigation';
|
||||
|
||||
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) => `/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,
|
||||
/**
|
||||
* 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,
|
||||
}: {
|
||||
board: Awaited<ReturnType<typeof getAbsenceBoard>>;
|
||||
searchParams: Promise<{ mois?: string }>;
|
||||
}) {
|
||||
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>
|
||||
const { mois } = await searchParams;
|
||||
permanentRedirect(
|
||||
mois ? `/absences/calendrier?mois=${mois}` : '/absences/calendrier',
|
||||
);
|
||||
}
|
||||
|
||||
function frenchDate(isoDate: string): string {
|
||||
return isoDate.split('-').reverse().join('/');
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type {
|
||||
ContractLocation,
|
||||
HiringOptions,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
/**
|
||||
* Embauche, en panneau latéral.
|
||||
*
|
||||
* Une trentaine de champs : posés au bas de la liste, ils occupaient plus de
|
||||
* place que l'effectif qu'on vient consulter. Le panneau glisse par la droite
|
||||
* et laisse l'annuaire visible — on embauche en regardant qui est déjà là.
|
||||
*
|
||||
* `<dialog>` natif plutôt qu'un panneau maison : le piège de focus, la
|
||||
* fermeture par Échap et le fond inerte viennent avec, et une `<div>` doit les
|
||||
* réimplémenter sans jamais les tenir tout à fait.
|
||||
*/
|
||||
export function AddEmployeeDialog({
|
||||
locations,
|
||||
options,
|
||||
}: {
|
||||
locations: ContractLocation[];
|
||||
options: HiringOptions;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
if (!element) return;
|
||||
if (open && !element.open) element.showModal();
|
||||
if (!open && element.open) element.close();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setOpen(true)}>
|
||||
Ajouter un collaborateur
|
||||
</Button>
|
||||
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-labelledby="titre-embauche"
|
||||
onClose={() => setOpen(false)}
|
||||
className="mt-0 mr-0 mb-0 ml-auto h-full max-h-none w-[min(30rem,100vw)] max-w-none border-l border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-3 border-b border-line-1 px-5 py-4">
|
||||
<h2 id="titre-embauche" className="text-md font-semibold">
|
||||
Nouvel employé
|
||||
</h2>
|
||||
<span className="flex-1" />
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Fermer
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pt-5">
|
||||
{open ? (
|
||||
// Monté à l'ouverture : un formulaire démonté entre deux usages
|
||||
// repart vide, sans traîner la saisie abandonnée la fois d'avant.
|
||||
<AddEmployeeForm
|
||||
locations={locations}
|
||||
options={options}
|
||||
onSaved={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { useActionState, useState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
@@ -8,38 +8,364 @@ import {
|
||||
createEmployeeAction,
|
||||
type ActionState,
|
||||
} from '@/server/employees/actions';
|
||||
import type {
|
||||
ContractLocation,
|
||||
HiringOptions,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
export function AddEmployeeForm() {
|
||||
/**
|
||||
* Embauche.
|
||||
*
|
||||
* Deux sections et un seul envoi : ce qui identifie la personne, puis ce qui
|
||||
* l'emploie. Un salarié créé sans contrat ni équipe est un dossier que rien ne
|
||||
* rattache — il n'apparaît sur aucune grille et ne se déclare pas — d'où un
|
||||
* contrat proposé d'emblée, et décochable seulement pour les cas où
|
||||
* l'établissement n'est pas encore tranché.
|
||||
*/
|
||||
|
||||
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é'],
|
||||
['EXTRA', 'Extra'],
|
||||
['INTERIM', 'Intérim'],
|
||||
['SAISONNIER', 'Saisonnier'],
|
||||
['STAGIAIRE', 'Stagiaire'],
|
||||
] as const;
|
||||
|
||||
/** Marque d'obligation, annoncée aussi aux lecteurs d'écran. */
|
||||
function Required() {
|
||||
return (
|
||||
<span className="text-danger">
|
||||
<span aria-hidden>*</span>
|
||||
<span className="sr-only"> (requis)</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
'h-9 min-w-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
export function AddEmployeeForm({
|
||||
locations,
|
||||
options,
|
||||
onSaved,
|
||||
}: {
|
||||
/** Vide quand la capacité d'ouvrir un contrat manque : la section disparaît. */
|
||||
locations: ContractLocation[];
|
||||
options: HiringOptions;
|
||||
/** Appelé une fois l'ajout accepté — le panneau s'y referme. */
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const [state, formAction] = useActionState<ActionState, FormData>(
|
||||
createEmployeeAction,
|
||||
{},
|
||||
empty,
|
||||
);
|
||||
const [withContract, setWithContract] = useState(locations.length > 0);
|
||||
const [locationId, setLocationId] = useState(locations[0]?.id ?? '');
|
||||
const [forfait, setForfait] = useState(false);
|
||||
const [autoNumber, setAutoNumber] = useState(true);
|
||||
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
|
||||
|
||||
// Acquitté une fois pour toutes : sans cela, un succès resté en mémoire
|
||||
// refermerait le panneau à sa réouverture.
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
onSaved?.();
|
||||
}
|
||||
|
||||
const teams =
|
||||
locations.find((location) => location.id === locationId)?.teams ?? [];
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={formAction}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-3"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Section title="Informations salarié">
|
||||
<Field label="Prénom" name="firstName" required />
|
||||
<Field label="Nom" name="lastName" required />
|
||||
<Field label="Matricule" name="employeeNumber" required />
|
||||
</div>
|
||||
<Field
|
||||
label="Nom de naissance"
|
||||
name="birthName"
|
||||
hint="Laissez vide s’il est identique au nom de famille."
|
||||
/>
|
||||
<Field label="Nom de famille" name="lastName" required />
|
||||
|
||||
<Field
|
||||
label="Adresse électronique"
|
||||
name="email"
|
||||
type="email"
|
||||
hint="Facultative. Un salarié sans adresse reste plannifiable et déclarable ; il n'aura simplement pas d'accès à l'application."
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-sm font-medium">Matricule</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoNumber(!autoNumber)}
|
||||
className="text-micro font-medium text-accent-soft-ink underline underline-offset-2"
|
||||
>
|
||||
{autoNumber
|
||||
? 'Saisir un matricule'
|
||||
: 'Passer à la génération automatique'}
|
||||
</button>
|
||||
</div>
|
||||
{autoNumber ? (
|
||||
<p className="text-micro text-ink-3">
|
||||
Attribué à la suite du dernier, au moment de l’enregistrement.
|
||||
</p>
|
||||
) : (
|
||||
<input
|
||||
name="employeeNumber"
|
||||
aria-label="Matricule"
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field label="Date de naissance" name="birthDate" type="date" />
|
||||
|
||||
<Field
|
||||
label="Adresse électronique"
|
||||
name="email"
|
||||
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" />
|
||||
|
||||
<Field label="Téléphone fixe" name="landline" type="tel" />
|
||||
</Section>
|
||||
|
||||
{locations.length > 0 ? (
|
||||
<Section title="Contrat">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="withContract"
|
||||
checked={withContract}
|
||||
onChange={(event) => setWithContract(event.target.checked)}
|
||||
className="size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
Ouvrir un contrat maintenant
|
||||
</label>
|
||||
|
||||
{withContract ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Date de début de contrat"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required
|
||||
defaultValue={today()}
|
||||
/>
|
||||
<Field
|
||||
label="Heure de début de contrat"
|
||||
name="startTime"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="09:00"
|
||||
hint="Demandée par la DPAE."
|
||||
/>
|
||||
</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>
|
||||
{CONTRACT_TYPES.map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
Organisation du temps
|
||||
</span>
|
||||
<select
|
||||
name="workTimeArrangement"
|
||||
className={selectClass}
|
||||
onChange={(event) =>
|
||||
setForfait(event.target.value === 'FORFAIT_JOURS')
|
||||
}
|
||||
>
|
||||
<option value="HOURLY">Décompte horaire</option>
|
||||
<option value="FORFAIT_JOURS">Forfait jours</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{forfait ? (
|
||||
<Field
|
||||
label="Jours par an"
|
||||
name="forfaitDaysPerYear"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="218"
|
||||
defaultValue="218"
|
||||
/>
|
||||
) : (
|
||||
<Field
|
||||
label="Temps de travail hebdomadaire"
|
||||
name="weeklyHours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="60"
|
||||
defaultValue="35"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{forfait ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Référence de la convention de forfait"
|
||||
name="forfaitAgreementRef"
|
||||
required
|
||||
hint="Sans convention individuelle écrite, le forfait est inopposable."
|
||||
/>
|
||||
<Field
|
||||
label="Date d’accord du salarié"
|
||||
name="forfaitAgreedAt"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label="Fin du contrat"
|
||||
name="endDate"
|
||||
type="date"
|
||||
hint="Vide pour un contrat sans terme."
|
||||
/>
|
||||
|
||||
<label className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
Établissement par défaut
|
||||
</span>
|
||||
<select
|
||||
name="locationId"
|
||||
className={selectClass}
|
||||
value={locationId}
|
||||
onChange={(event) => setLocationId(event.target.value)}
|
||||
>
|
||||
{locations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
{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">
|
||||
<span className="text-sm font-medium">
|
||||
Responsable hiérarchique
|
||||
</span>
|
||||
<select name="lineManagerId" className={selectClass}>
|
||||
<option value="">Aucun</option>
|
||||
{options.managers.map((manager) => (
|
||||
<option key={manager.id} value={manager.id}>
|
||||
{manager.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{options.rttPolicies.length > 0 ? (
|
||||
<label className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Politique RTT</span>
|
||||
<select name="rttPolicyId" className={selectClass}>
|
||||
<option value="">Aucune</option>
|
||||
{options.rttPolicies.map((policy) => (
|
||||
<option key={policy.id} value={policy.id}>
|
||||
{policy.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Ajouter</SubmitButton>
|
||||
<div className="sticky bottom-0 -mx-5 border-t border-line-1 bg-surface px-5 py-4">
|
||||
<SubmitButton className="w-full">Enregistrer</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Salarié ajouté.</span>
|
||||
<p className="mt-2 text-center text-xs text-ok-soft-ink">
|
||||
Salarié ajouté.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Filtres de l'annuaire.
|
||||
*
|
||||
* L'état vit dans l'adresse, pas dans le composant : un effectif filtré
|
||||
* s'envoie par lien, se met en favori, et le retour arrière défait le dernier
|
||||
* choix plutôt que de vider la page.
|
||||
*/
|
||||
|
||||
/** Pastilles plutôt que champs : une barre de filtres se survole, elle ne se
|
||||
* remplit pas. */
|
||||
const selectClass =
|
||||
'h-10 min-w-0 cursor-pointer rounded-full border border-line-2 bg-surface px-4 text-sm text-ink-1 outline-none hover:bg-surface-2 focus-visible:border-focus';
|
||||
|
||||
export interface FilterOptions {
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
roles: Array<{ id: string; name: string }>;
|
||||
contractTypes: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
export function DirectoryFilters({ options }: { options: FilterOptions }) {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const current = (key: string) => params.get(key) ?? '';
|
||||
|
||||
function apply(key: string, value: string) {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
router.replace(next.size > 0 ? `/equipe?${next}` : '/equipe');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="sr-only">Rechercher un collaborateur</span>
|
||||
<input
|
||||
name="q"
|
||||
type="search"
|
||||
defaultValue={current('q')}
|
||||
placeholder="Rechercher par prénom, nom ou matricule."
|
||||
className="h-11 rounded-full border border-line-2 bg-surface px-4 text-sm text-ink-1 outline-none placeholder:text-ink-3 focus-visible:border-focus"
|
||||
onChange={(event) => {
|
||||
// La frappe ne déclenche pas une requête par caractère : elle
|
||||
// attend un silence. Sans cela, chercher « Dupont » en lance six.
|
||||
const value = event.target.value;
|
||||
if (debounce.current) clearTimeout(debounce.current);
|
||||
debounce.current = setTimeout(() => apply('q', value), 300);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
aria-label="Établissement"
|
||||
className={selectClass}
|
||||
value={current('etablissement')}
|
||||
onChange={(event) => apply('etablissement', event.target.value)}
|
||||
>
|
||||
<option value="">Tous les établissements</option>
|
||||
{options.locations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
aria-label="Rôle"
|
||||
className={selectClass}
|
||||
value={current('role')}
|
||||
onChange={(event) => apply('role', event.target.value)}
|
||||
>
|
||||
<option value="">Tous les rôles</option>
|
||||
{options.roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
aria-label="Type de contrat"
|
||||
className={selectClass}
|
||||
value={current('contrat')}
|
||||
onChange={(event) => apply('contrat', event.target.value)}
|
||||
>
|
||||
<option value="">Tous les types de contrats</option>
|
||||
{options.contractTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
aria-label="Présence"
|
||||
className={selectClass}
|
||||
value={current('presence')}
|
||||
onChange={(event) => apply('presence', event.target.value)}
|
||||
>
|
||||
<option value="">Utilisateurs actifs</option>
|
||||
<option value="archived">Archivés</option>
|
||||
<option value="all">Tous</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
aria-label="Tri"
|
||||
className={selectClass}
|
||||
value={current('tri')}
|
||||
onChange={(event) => apply('tri', event.target.value)}
|
||||
>
|
||||
<option value="">Tri par nom</option>
|
||||
<option value="number">Tri par matricule</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
/**
|
||||
* Édition du registre unique du personnel.
|
||||
*
|
||||
* L'établissement est demandé avant le téléchargement, et non déduit : le
|
||||
* registre se tient **par établissement**, et en tirer un au hasard pour une
|
||||
* entreprise qui en compte trente ne répondrait à aucune demande d'inspection.
|
||||
*
|
||||
* L'avertissement sur les mentions manquantes arrive avant le fichier, pas
|
||||
* dedans : découvrir un registre incomplet en l'ouvrant, c'est le découvrir
|
||||
* devant l'inspection.
|
||||
*/
|
||||
|
||||
export interface RegisterLocation {
|
||||
id: string;
|
||||
name: string;
|
||||
incomplete: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function RegisterDialog({
|
||||
locations,
|
||||
}: {
|
||||
locations: RegisterLocation[];
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [locationId, setLocationId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
if (!element) return;
|
||||
if (open && !element.open) element.showModal();
|
||||
if (!open && element.open) element.close();
|
||||
}, [open]);
|
||||
|
||||
const chosen = locations.find((location) => location.id === locationId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
Registre unique du personnel
|
||||
</Button>
|
||||
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-labelledby="titre-rup"
|
||||
onClose={() => setOpen(false)}
|
||||
className="m-auto w-[min(38rem,calc(100vw-2rem))] rounded-3 border border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]"
|
||||
>
|
||||
<header className="flex items-center gap-3 px-5 pt-5">
|
||||
<h2 id="titre-rup" className="text-lg font-semibold">
|
||||
Téléchargez votre registre unique du personnel
|
||||
</h2>
|
||||
<span className="flex-1" />
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Fermer
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<p className="rounded-3 border border-line-1 bg-surface-2 p-4 text-sm leading-[var(--lh-prose)] text-ink-2">
|
||||
Tout employeur doit tenir un registre unique du personnel par
|
||||
établissement. Aucune forme n’est imposée : en cas de contrôle par
|
||||
l’inspection du travail, le format numérique peut être présenté.
|
||||
</p>
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
Établissement pour lequel éditer le registre
|
||||
</span>
|
||||
<select
|
||||
value={locationId}
|
||||
onChange={(event) => setLocationId(event.target.value)}
|
||||
className="h-10 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
|
||||
>
|
||||
<option value="">Choisissez un établissement…</option>
|
||||
{locations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{chosen && chosen.incomplete > 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-3 border border-warn bg-warn-soft p-4 text-sm text-warn-soft-ink"
|
||||
>
|
||||
<p className="font-semibold">
|
||||
Des mentions manquent pour {chosen.incomplete} salarié
|
||||
{chosen.incomplete > 1 ? 's' : ''} de cet établissement
|
||||
</p>
|
||||
<p className="mt-1 leading-[var(--lh-prose)]">
|
||||
Le registre reste éditable, et les manques y figurent en clair.
|
||||
Un registre incomplet expose à une contravention par salarié
|
||||
concerné : complétez les dossiers avant de le présenter.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{chosen && chosen.total === 0 ? (
|
||||
<p className="text-sm text-ink-3">
|
||||
Aucun contrat n’est rattaché à cet établissement : le registre
|
||||
sera vide.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-3 border-t border-line-1 px-5 py-4">
|
||||
<Button onClick={() => setOpen(false)}>Annuler</Button>
|
||||
{/* Un lien, pas un bouton : le navigateur télécharge sans quitter la
|
||||
page, et l'adresse reste rejouable. */}
|
||||
<a
|
||||
href={
|
||||
locationId ? `/equipe/registre?etablissement=${locationId}` : '#'
|
||||
}
|
||||
aria-disabled={!locationId}
|
||||
onClick={(event) => {
|
||||
if (!locationId) event.preventDefault();
|
||||
else setOpen(false);
|
||||
}}
|
||||
className={
|
||||
locationId
|
||||
? 'inline-flex h-8 items-center rounded-2 border border-accent bg-accent px-3.5 text-sm font-medium text-accent-ink hover:bg-accent-hover'
|
||||
: 'pointer-events-none inline-flex h-8 items-center rounded-2 border border-line-2 bg-surface-3 px-3.5 text-sm font-medium text-ink-3'
|
||||
}
|
||||
>
|
||||
Télécharger
|
||||
</a>
|
||||
</footer>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Carte de consultation du dossier.
|
||||
*
|
||||
* Deux colonnes par ligne — l'intitulé, puis la valeur — et un filet entre
|
||||
* chaque : c'est la forme d'un état civil, qui se parcourt du regard en
|
||||
* cherchant un champ précis plutôt qu'en lisant de haut en bas.
|
||||
*/
|
||||
export function InfoCard({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-3 border border-line-1 bg-surface p-5">
|
||||
<header className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-7 flex-none rounded-2 border border-accent bg-accent-soft"
|
||||
/>
|
||||
<h2 className="text-md font-semibold">{title}</h2>
|
||||
</header>
|
||||
<dl className="mt-4">{children}</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoRow({
|
||||
label,
|
||||
value,
|
||||
tnum = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
/** Chiffres alignés : dates, téléphones, numéros. */
|
||||
tnum?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line-1 py-3 last:border-b-0">
|
||||
<dt className="min-w-[11rem] flex-none text-sm font-medium text-ink-1">
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={
|
||||
value
|
||||
? `text-sm text-ink-2${tnum ? ' tnum' : ''}`
|
||||
: 'text-sm text-ink-3'
|
||||
}
|
||||
>
|
||||
{value || 'Non renseigné'}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Grille à deux colonnes des cartes du dossier. */
|
||||
export function InfoGrid({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid items-start gap-5 [grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr))]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
/**
|
||||
* Onglets de la fiche salarié.
|
||||
*
|
||||
* Chaque onglet est une **route**, pas un état de composant : une fiche
|
||||
* s'envoie par lien, se rouvre au même endroit, et le retour arrière ramène à
|
||||
* l'onglet précédent plutôt qu'à la liste.
|
||||
*/
|
||||
|
||||
const TABS = [
|
||||
{ segment: '', label: 'Informations personnelles' },
|
||||
{ segment: 'contrats', label: 'Contrats' },
|
||||
{ segment: 'planification', label: 'Planification et accès' },
|
||||
{ segment: 'absences', label: 'Congés et Absences' },
|
||||
{ segment: 'documents', label: 'Documents' },
|
||||
] as const;
|
||||
|
||||
export function MemberTabs({ id }: { id: string }) {
|
||||
const pathname = usePathname();
|
||||
const base = `/equipe/${id}`;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections de la fiche"
|
||||
className="flex gap-1 overflow-x-auto rounded-3 border border-line-1 bg-surface p-1.5"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const href = tab.segment ? `${base}/${tab.segment}` : base;
|
||||
const active = tab.segment
|
||||
? pathname.startsWith(href)
|
||||
: pathname === base;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.segment}
|
||||
href={href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cx(
|
||||
'shrink-0 rounded-full px-3.5 py-1.5 text-sm font-medium whitespace-nowrap',
|
||||
'transition-colors duration-[var(--d-1)] ease-organic',
|
||||
active
|
||||
? 'bg-surface-3 text-ink-1'
|
||||
: 'text-ink-2 hover:bg-surface-2 hover:text-ink-1',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
'use client';
|
||||
|
||||
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 {
|
||||
GENDERS,
|
||||
MARITAL_STATUSES,
|
||||
genderLabel,
|
||||
maritalStatusLabel,
|
||||
} from '@/domain/hr/civil-status';
|
||||
import {
|
||||
COUNTRY_OPTIONS,
|
||||
NATIONALITY_OPTIONS,
|
||||
countryLabel,
|
||||
nationalityLabel,
|
||||
} from '@/domain/legal/countries';
|
||||
import { DEPARTMENTS, departmentLabel } from '@/domain/legal/departments';
|
||||
import {
|
||||
updateProfileAction,
|
||||
updateSensitiveAction,
|
||||
type ProfileActionState,
|
||||
} from '@/server/employees/profile-actions';
|
||||
|
||||
/**
|
||||
* Dossier personnel : consultation, puis saisie.
|
||||
*
|
||||
* Un seul écran et deux modes, plutôt qu'une page de lecture et une page de
|
||||
* formulaire. Le dossier se corrige champ par champ, souvent au téléphone avec
|
||||
* la personne concernée : la faire changer de page pour changer un chiffre
|
||||
* ferait perdre le reste de vue.
|
||||
*/
|
||||
|
||||
export interface ProfileFields {
|
||||
gender: string;
|
||||
firstName: string;
|
||||
birthName: string;
|
||||
lastName: string;
|
||||
birthDate: string;
|
||||
birthPlace: string;
|
||||
birthCountry: string;
|
||||
birthDepartment: string;
|
||||
nationality: string;
|
||||
maritalStatus: string;
|
||||
dependents: string;
|
||||
personalEmail: string;
|
||||
phone: string;
|
||||
landline: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
country: string;
|
||||
emergencyContactName: string;
|
||||
emergencyContactPhone: string;
|
||||
}
|
||||
|
||||
export interface SensitiveFields {
|
||||
socialSecurityNumber: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
}
|
||||
|
||||
const empty: ProfileActionState = {};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
|
||||
/**
|
||||
* Liste déroulante à valeur facultative.
|
||||
*
|
||||
* L'option vide est explicite : sans elle, ouvrir le formulaire poserait la
|
||||
* première valeur de la liste sur un champ que personne n'a renseigné.
|
||||
*/
|
||||
function Select({
|
||||
label,
|
||||
name,
|
||||
defaultValue,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
defaultValue: string;
|
||||
options: ReadonlyArray<readonly [string, string]>;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<select
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus"
|
||||
>
|
||||
<option value="">Non renseigné</option>
|
||||
{options.map(([value, text]) => (
|
||||
<option key={value} value={value}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function readableDate(value: string): string {
|
||||
if (!value) return '';
|
||||
const parsed = new Date(`${value}T00:00:00Z`);
|
||||
return Number.isNaN(parsed.getTime()) ? value : dateFormat.format(parsed);
|
||||
}
|
||||
|
||||
export function PersonalInfoPanel({
|
||||
membershipId,
|
||||
profile,
|
||||
sensitive,
|
||||
canEdit,
|
||||
}: {
|
||||
membershipId: string;
|
||||
profile: ProfileFields;
|
||||
/** Absent quand la capacité de lecture manque : la carte n'est pas rendue. */
|
||||
sensitive: SensitiveFields | null;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [state, save] = useActionState(updateProfileAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
|
||||
|
||||
// L'enregistrement referme le mode saisie, mais seulement après la réponse :
|
||||
// refermer à l'envoi ferait disparaître le message d'erreur avec le
|
||||
// formulaire, et avec lui ce que la personne venait de taper.
|
||||
//
|
||||
// La réponse est **acquittée** une fois pour toutes : sans cela, un succès
|
||||
// resté en mémoire refermerait aussitôt le formulaire à la prochaine
|
||||
// ouverture, et le dossier ne serait plus jamais modifiable.
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{editing ? (
|
||||
<form action={save} className="flex flex-col gap-5">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
<InfoGrid>
|
||||
<InfoCard title="État civil">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Select
|
||||
label="Genre"
|
||||
name="gender"
|
||||
defaultValue={profile.gender}
|
||||
options={GENDERS}
|
||||
/>
|
||||
<Field label="Prénom" name="firstName" required defaultValue={profile.firstName} />
|
||||
<Field
|
||||
label="Nom de naissance"
|
||||
name="birthName"
|
||||
defaultValue={profile.birthName}
|
||||
/>
|
||||
<Field label="Nom de famille" name="lastName" required defaultValue={profile.lastName} />
|
||||
<Select
|
||||
label="Nationalité"
|
||||
name="nationality"
|
||||
defaultValue={profile.nationality}
|
||||
options={NATIONALITY_OPTIONS}
|
||||
/>
|
||||
<Field
|
||||
label="Date de naissance"
|
||||
name="birthDate"
|
||||
type="date"
|
||||
defaultValue={profile.birthDate}
|
||||
/>
|
||||
<Select
|
||||
label="Pays de naissance"
|
||||
name="birthCountry"
|
||||
defaultValue={profile.birthCountry}
|
||||
options={COUNTRY_OPTIONS}
|
||||
/>
|
||||
{/* Renseigné pour une naissance en France ; laissé vide
|
||||
ailleurs, où la notion n'existe pas. */}
|
||||
<Select
|
||||
label="Département de naissance"
|
||||
name="birthDepartment"
|
||||
defaultValue={profile.birthDepartment}
|
||||
options={DEPARTMENTS}
|
||||
/>
|
||||
<Field
|
||||
label="Commune de naissance"
|
||||
name="birthPlace"
|
||||
defaultValue={profile.birthPlace}
|
||||
/>
|
||||
<Select
|
||||
label="Situation familiale"
|
||||
name="maritalStatus"
|
||||
defaultValue={profile.maritalStatus}
|
||||
options={MARITAL_STATUSES}
|
||||
/>
|
||||
<Field
|
||||
label="Nombre de personnes à charge"
|
||||
name="dependents"
|
||||
type="number"
|
||||
min="0"
|
||||
max="99"
|
||||
defaultValue={profile.dependents}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Coordonnées">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Field
|
||||
label="Adresse électronique personnelle"
|
||||
name="personalEmail"
|
||||
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} />
|
||||
<Field label="Adresse" name="addressLine1" defaultValue={profile.addressLine1} />
|
||||
<Field
|
||||
label="Complément d’adresse"
|
||||
name="addressLine2"
|
||||
defaultValue={profile.addressLine2}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Code postal" name="postalCode" defaultValue={profile.postalCode} />
|
||||
<Field label="Ville" name="city" defaultValue={profile.city} />
|
||||
</div>
|
||||
<Select
|
||||
label="Pays"
|
||||
name="country"
|
||||
defaultValue={profile.country}
|
||||
options={COUNTRY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Contact d’urgence">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Field
|
||||
label="Nom"
|
||||
name="emergencyContactName"
|
||||
defaultValue={profile.emergencyContactName}
|
||||
/>
|
||||
<Field
|
||||
label="Téléphone"
|
||||
name="emergencyContactPhone"
|
||||
type="tel"
|
||||
defaultValue={profile.emergencyContactPhone}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
{/* Barre collante : le dossier est long, et le bouton d'enregistrement
|
||||
ne doit pas se trouver hors de l'écran au moment où l'on finit de
|
||||
corriger un champ du haut. */}
|
||||
<div className="sticky bottom-4 flex flex-wrap items-center justify-center gap-3 rounded-3 border border-line-1 bg-surface p-3 shadow-[var(--shadow-1,0_1px_2px_rgba(0,0,0,0.06))]">
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<SubmitButton>Enregistrer les modifications</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<InfoGrid>
|
||||
<InfoCard title="État civil">
|
||||
<InfoRow label="Genre" value={genderLabel(profile.gender || null)} />
|
||||
<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="Date de naissance" value={readableDate(profile.birthDate)} tnum />
|
||||
<InfoRow label="Pays de naissance" value={countryLabel(profile.birthCountry || null)} />
|
||||
<InfoRow
|
||||
label="Département de naissance"
|
||||
value={departmentLabel(profile.birthDepartment || null)}
|
||||
/>
|
||||
<InfoRow label="Commune de naissance" value={profile.birthPlace} />
|
||||
<InfoRow
|
||||
label="Situation familiale"
|
||||
value={maritalStatusLabel(profile.maritalStatus || null)}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Nombre de personnes à charge"
|
||||
value={profile.dependents}
|
||||
tnum
|
||||
/>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Coordonnées">
|
||||
<InfoRow label="Adresse électronique personnelle" value={profile.personalEmail} />
|
||||
<InfoRow label="Téléphone mobile" value={profile.phone} tnum />
|
||||
<InfoRow label="Téléphone fixe" value={profile.landline} tnum />
|
||||
<InfoRow label="Adresse" value={profile.addressLine1} />
|
||||
<InfoRow label="Complément d’adresse" value={profile.addressLine2} />
|
||||
<InfoRow label="Code postal" value={profile.postalCode} tnum />
|
||||
<InfoRow label="Ville" value={profile.city} />
|
||||
<InfoRow label="Pays" value={countryLabel(profile.country || null)} />
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Contact d’urgence">
|
||||
<InfoRow label="Nom" value={profile.emergencyContactName} />
|
||||
<InfoRow label="Téléphone" value={profile.emergencyContactPhone} tnum />
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
|
||||
{canEdit ? (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="primary" onClick={() => setEditing(true)}>
|
||||
Modifier les informations personnelles
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{sensitive ? (
|
||||
<SensitivePanel
|
||||
membershipId={membershipId}
|
||||
sensitive={sensitive}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NIR, IBAN et BIC.
|
||||
*
|
||||
* Séparés du reste, et pas seulement à l'écran : ils sont chiffrés au repos,
|
||||
* leur lecture demande sa propre capacité, et leur écriture passe par une autre
|
||||
* action. Les mêler au formulaire commun ferait dépendre leur sort d'un droit
|
||||
* qui ne les concerne pas.
|
||||
*/
|
||||
function SensitivePanel({
|
||||
membershipId,
|
||||
sensitive,
|
||||
canEdit,
|
||||
}: {
|
||||
membershipId: string;
|
||||
sensitive: SensitiveFields;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [state, save] = useActionState(updateSensitiveAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title="Données protégées">
|
||||
{editing ? (
|
||||
<form action={save} className="flex flex-col gap-3 pt-1">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
<Field
|
||||
label="Numéro de sécurité sociale"
|
||||
name="socialSecurityNumber"
|
||||
inputMode="numeric"
|
||||
defaultValue={sensitive.socialSecurityNumber}
|
||||
hint="Quinze chiffres. Conservé chiffré ; jamais écrit au journal."
|
||||
/>
|
||||
<Field label="IBAN" name="iban" defaultValue={sensitive.iban} />
|
||||
<Field label="BIC" name="bic" defaultValue={sensitive.bic} />
|
||||
|
||||
<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="Numéro de sécurité sociale"
|
||||
value={sensitive.socialSecurityNumber}
|
||||
tnum
|
||||
/>
|
||||
<InfoRow label="IBAN" value={sensitive.iban} tnum />
|
||||
<InfoRow label="BIC" value={sensitive.bic} tnum />
|
||||
{canEdit ? (
|
||||
<div className="pt-3">
|
||||
<Button onClick={() => setEditing(true)}>Modifier</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { listMemberAbsences, statusLabel } from '@/server/absences/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
|
||||
|
||||
const STATUS_TONES: Record<string, Tone> = {
|
||||
PENDING: 'warn',
|
||||
ACCEPTED: 'ok',
|
||||
DECLINED: 'danger',
|
||||
CANCELLED: 'neutral',
|
||||
EXPIRED: 'neutral',
|
||||
};
|
||||
|
||||
const readable = (iso: string) => dateFormat.format(new Date(`${iso}T00:00:00Z`));
|
||||
|
||||
export default async function AbsencesTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const absences = await listMemberAbsences(id);
|
||||
|
||||
if (!absences) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Absences non consultables"
|
||||
description="Votre rôle ne donne pas accès aux congés des autres salariés."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader title="Compteurs" />
|
||||
{absences.counters.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun compteur"
|
||||
description="Les compteurs s’ouvrent à la première acquisition de la période."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-x-8 gap-y-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(170px,1fr))]">
|
||||
{absences.counters.map((counter) => (
|
||||
<div key={`${counter.membershipId}-${counter.counterType}`}>
|
||||
<p className="text-micro font-semibold text-ink-1">
|
||||
{counter.counterType}
|
||||
</p>
|
||||
<p className="tnum mt-1 text-lg font-semibold">
|
||||
{counter.balance}
|
||||
</p>
|
||||
<p className="tnum text-micro text-ink-3">
|
||||
{counter.accrued} acquis · {counter.taken} pris
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Congés et absences"
|
||||
badge={<Badge tone="neutral">{absences.requests.length}</Badge>}
|
||||
/>
|
||||
{absences.requests.length === 0 ? (
|
||||
<EmptyState title="Aucune absence enregistrée" />
|
||||
) : (
|
||||
<ul>
|
||||
{absences.requests.map((request) => (
|
||||
<li
|
||||
key={request.id}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<span className="font-medium">{request.typeLabel}</span>
|
||||
<span className="tnum text-xs text-ink-2">
|
||||
{readable(request.startDate)}
|
||||
{request.endDate !== request.startDate
|
||||
? ` → ${readable(request.endDate)}`
|
||||
: ''}
|
||||
</span>
|
||||
<span className="tnum text-xs text-ink-3">
|
||||
{request.days} j
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
|
||||
{statusLabel(request.status)}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
|
||||
import { InfoCard, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import {
|
||||
createAmendmentAction,
|
||||
endContractAction,
|
||||
type ActionState,
|
||||
} from '@/server/employees/actions';
|
||||
|
||||
/**
|
||||
* Vie du contrat en cours.
|
||||
*
|
||||
* Trois gestes et un seul menu : lire le détail, déclarer un changement,
|
||||
* terminer. Ils sont réunis parce qu'ils portent sur le même objet, et séparés
|
||||
* du formulaire de création parce qu'un contrat qui existe ne se recrée pas.
|
||||
*/
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
|
||||
export interface ActiveContract {
|
||||
id: string;
|
||||
label: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
trialEndDate: string | null;
|
||||
weeklyHours: string;
|
||||
forfaitJours: boolean;
|
||||
forfaitDaysPerYear: string | null;
|
||||
forfaitAgreementRef: string | null;
|
||||
isModulated: boolean;
|
||||
classification: string | null;
|
||||
coefficient: string | null;
|
||||
locationName: string | null;
|
||||
monthlySalary: string | null;
|
||||
jobTitle: string | null;
|
||||
}
|
||||
|
||||
const readable = (iso: string | null) =>
|
||||
iso ? dateFormat.format(new Date(`${iso}T00:00:00Z`)) : '';
|
||||
|
||||
type Panel = 'none' | 'detail' | 'amend' | 'end';
|
||||
|
||||
export function ContractActions({
|
||||
contract,
|
||||
canEdit,
|
||||
canSeeSalary,
|
||||
}: {
|
||||
contract: ActiveContract;
|
||||
canEdit: boolean;
|
||||
canSeeSalary: boolean;
|
||||
}) {
|
||||
const [panel, setPanel] = useState<Panel>('none');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-md font-semibold">Contrat en cours</h2>
|
||||
<span className="flex-1" />
|
||||
<Button
|
||||
onClick={() => setPanel(panel === 'detail' ? 'none' : 'detail')}
|
||||
>
|
||||
{panel === 'detail' ? 'Masquer le détail' : 'Afficher le détail'}
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setPanel(panel === 'amend' ? 'none' : 'amend')}
|
||||
>
|
||||
Déclarer un changement
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setPanel(panel === 'end' ? 'none' : 'end')}
|
||||
>
|
||||
Terminer le contrat
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<InfoCard title={contract.label}>
|
||||
<InfoRow label="Début du contrat" value={readable(contract.startDate)} tnum />
|
||||
<InfoRow label="Fin du contrat" value={readable(contract.endDate)} tnum />
|
||||
<InfoRow label="Emploi" value={contract.jobTitle} />
|
||||
<InfoRow
|
||||
label="Organisation du temps"
|
||||
value={
|
||||
contract.forfaitJours
|
||||
? `Forfait jours · ${contract.forfaitDaysPerYear ?? '—'} jours par an`
|
||||
: `${contract.weeklyHours} heures hebdomadaires`
|
||||
}
|
||||
/>
|
||||
{canSeeSalary ? (
|
||||
<InfoRow
|
||||
label="Rémunération mensuelle brute"
|
||||
value={contract.monthlySalary ? `${contract.monthlySalary} €` : ''}
|
||||
tnum
|
||||
/>
|
||||
) : null}
|
||||
<InfoRow label="Établissement" value={contract.locationName} />
|
||||
|
||||
{panel === 'detail' ? (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Fin de période d’essai"
|
||||
value={readable(contract.trialEndDate)}
|
||||
tnum
|
||||
/>
|
||||
<InfoRow label="Classification" value={contract.classification} />
|
||||
<InfoRow label="Coefficient" value={contract.coefficient} tnum />
|
||||
<InfoRow
|
||||
label="Modulation"
|
||||
value={contract.isModulated ? 'Oui' : 'Non'}
|
||||
/>
|
||||
{contract.forfaitJours ? (
|
||||
<InfoRow
|
||||
label="Convention de forfait"
|
||||
value={contract.forfaitAgreementRef}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</InfoCard>
|
||||
|
||||
{panel === 'amend' ? (
|
||||
<AmendmentForm
|
||||
contractId={contract.id}
|
||||
weeklyHours={contract.weeklyHours}
|
||||
onDone={() => setPanel('none')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{panel === 'end' ? (
|
||||
<EndForm contractId={contract.id} onDone={() => setPanel('none')} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Avenant.
|
||||
*
|
||||
* Le motif est obligatoire : un changement de durée sans raison écrite est
|
||||
* indéfendable devant un contrôle, et illisible six mois plus tard.
|
||||
*/
|
||||
function AmendmentForm({
|
||||
contractId,
|
||||
weeklyHours,
|
||||
onDone,
|
||||
}: {
|
||||
contractId: string;
|
||||
weeklyHours: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [state, submit] = useActionState(createAmendmentAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title="Déclarer un changement">
|
||||
<form action={submit} className="flex flex-col gap-3 pt-1">
|
||||
<input type="hidden" name="contractId" value={contractId} />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Date d’effet"
|
||||
name="effectiveDate"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label="Durée hebdomadaire"
|
||||
name="weeklyHours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="60"
|
||||
defaultValue={weeklyHours}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="Motif"
|
||||
name="reason"
|
||||
required
|
||||
hint="Ce que l’avenant change, et pourquoi. Conservé au journal."
|
||||
/>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer l’avenant</SubmitButton>
|
||||
<Button type="button" onClick={onDone}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
function EndForm({
|
||||
contractId,
|
||||
onDone,
|
||||
}: {
|
||||
contractId: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [state, submit] = useActionState(endContractAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title="Terminer le contrat">
|
||||
<form action={submit} className="flex flex-col gap-3 pt-1">
|
||||
<input type="hidden" name="contractId" value={contractId} />
|
||||
<p className="text-sm text-ink-2">
|
||||
Le contrat est daté et clos, non supprimé : la période travaillée
|
||||
reste opposable, et le solde de tout compte s’appuie dessus.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Date de fin" name="endDate" type="date" required />
|
||||
<Field
|
||||
label="Motif de fin"
|
||||
name="endReason"
|
||||
required
|
||||
hint="Démission, fin de CDD, rupture conventionnelle…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Terminer le contrat</SubmitButton>
|
||||
<Button type="button" onClick={onDone}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
|
||||
import { InfoCard } from '@/app/(app)/equipe/[id]/InfoCard';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import {
|
||||
createContractAction,
|
||||
type ActionState,
|
||||
} from '@/server/employees/actions';
|
||||
|
||||
/**
|
||||
* Ouverture d'un contrat.
|
||||
*
|
||||
* Le forfait jours découvre ses propres champs : la convention individuelle
|
||||
* écrite et sa date d'accord sont exigées par la règle métier, et les demander
|
||||
* en permanence ferait porter à tous les contrats horaires deux champs qui ne
|
||||
* les concernent pas.
|
||||
*/
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
const CONTRACT_TYPES = [
|
||||
['CDI', 'CDI'],
|
||||
['CDD', 'CDD'],
|
||||
['APPRENTISSAGE', 'Apprentissage'],
|
||||
['STAGIAIRE', 'Stagiaire'],
|
||||
['SAISONNIER', 'Saisonnier'],
|
||||
['EXTRA', 'Extra'],
|
||||
['INTERIM', 'Intérim'],
|
||||
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
|
||||
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
|
||||
] as const;
|
||||
|
||||
const selectClass =
|
||||
'h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
|
||||
|
||||
export function NewContractForm({
|
||||
membershipId,
|
||||
locations,
|
||||
}: {
|
||||
membershipId: string;
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
const [state, submit] = useActionState(createContractAction, empty);
|
||||
const [forfait, setForfait] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="primary" onClick={() => setOpen(true)}>
|
||||
Ouvrir un contrat
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title="Nouveau contrat">
|
||||
<form action={submit} className="flex flex-col gap-3 pt-1">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Établissement</span>
|
||||
<select name="locationId" required className={selectClass}>
|
||||
{locations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Type de contrat</span>
|
||||
<select name="contractType" required className={selectClass}>
|
||||
{CONTRACT_TYPES.map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Début du contrat" name="startDate" type="date" required />
|
||||
<Field
|
||||
label="Fin du contrat"
|
||||
name="endDate"
|
||||
type="date"
|
||||
hint="Laissez vide pour un contrat sans terme."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Organisation du temps</span>
|
||||
<select
|
||||
name="workTimeArrangement"
|
||||
className={selectClass}
|
||||
onChange={(event) =>
|
||||
setForfait(event.target.value === 'FORFAIT_JOURS')
|
||||
}
|
||||
>
|
||||
<option value="HOURLY">Décompte horaire</option>
|
||||
<option value="FORFAIT_JOURS">Forfait jours</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{forfait ? (
|
||||
<Field
|
||||
label="Jours par an"
|
||||
name="forfaitDaysPerYear"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="218"
|
||||
defaultValue="218"
|
||||
hint="Plafond conventionnel : 218 jours, journée de solidarité incluse."
|
||||
/>
|
||||
) : (
|
||||
<Field
|
||||
label="Durée hebdomadaire"
|
||||
name="weeklyHours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="60"
|
||||
defaultValue="35"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{forfait ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Référence de la convention de forfait"
|
||||
name="forfaitAgreementRef"
|
||||
required
|
||||
hint="Sans convention individuelle écrite, le forfait est inopposable."
|
||||
/>
|
||||
<Field
|
||||
label="Date d’accord du salarié"
|
||||
name="forfaitAgreedAt"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Ouvrir le contrat</SubmitButton>
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import {
|
||||
ContractActions,
|
||||
type ActiveContract,
|
||||
} from '@/app/(app)/equipe/[id]/contrats/ContractActions';
|
||||
import { NewContractForm } from '@/app/(app)/equipe/[id]/contrats/NewContractForm';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
getEmployee,
|
||||
listContractLocations,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
|
||||
/** Les dates traversent la frontière serveur/client en ISO court : un `Date`
|
||||
* y perd son fuseau, et « 1er novembre » deviendrait « 31 octobre » à l'ouest. */
|
||||
const iso = (value: Date | null) => value?.toISOString().slice(0, 10) ?? null;
|
||||
|
||||
export default async function ContractsTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
const active = employee.contracts.find(
|
||||
(contract) => contract.status === 'ACTIVE',
|
||||
);
|
||||
|
||||
// La liste n'est chargée que si elle sert : sans contrat en cours, l'écran
|
||||
// propose d'en ouvrir un, et la capacité peut manquer.
|
||||
const locations = active ? [] : await listContractLocations().catch(() => []);
|
||||
|
||||
const activeContract: ActiveContract | null = active
|
||||
? {
|
||||
id: active.id,
|
||||
label: active.label,
|
||||
startDate: iso(active.startDate) as string,
|
||||
endDate: iso(active.endDate),
|
||||
trialEndDate: iso(active.trialEndDate),
|
||||
weeklyHours: active.weeklyHours,
|
||||
forfaitJours: active.forfaitJours,
|
||||
forfaitDaysPerYear: active.forfaitDaysPerYear,
|
||||
forfaitAgreementRef: active.forfaitAgreementRef,
|
||||
isModulated: active.isModulated,
|
||||
classification: active.classification,
|
||||
coefficient: active.coefficient,
|
||||
locationName: active.locationName,
|
||||
monthlySalary: active.monthlySalary,
|
||||
jobTitle: employee.headline.jobTitle,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{activeContract ? (
|
||||
<ContractActions
|
||||
contract={activeContract}
|
||||
canEdit={employee.canEdit}
|
||||
canSeeSalary={employee.canSeeSalary}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<EmptyState
|
||||
title="Aucun contrat en cours"
|
||||
description="Ce salarié ne peut être ni planifié ni déclaré tant qu’aucune période n’est ouverte."
|
||||
/>
|
||||
{employee.canEdit && locations.length > 0 ? (
|
||||
<NewContractForm membershipId={employee.id} locations={locations} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Tous les contrats et avenants"
|
||||
badge={<Badge tone="neutral">{employee.contracts.length}</Badge>}
|
||||
/>
|
||||
{employee.contracts.length === 0 ? (
|
||||
<EmptyState title="Aucun contrat enregistré" />
|
||||
) : (
|
||||
<ul>
|
||||
{employee.contracts.map((contract) => (
|
||||
<li
|
||||
key={contract.id}
|
||||
className="border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{contract.label}</span>
|
||||
{contract.forfaitJours ? (
|
||||
<Badge tone="accent">Forfait jours</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">{contract.weeklyHours} h</Badge>
|
||||
)}
|
||||
<span className="tnum text-xs text-ink-2">
|
||||
{dateFormat.format(contract.startDate)}
|
||||
{contract.endDate
|
||||
? ` → ${dateFormat.format(contract.endDate)}`
|
||||
: ' → en cours'}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{employee.canSeeSalary && contract.monthlySalary ? (
|
||||
<span className="tnum text-sm">
|
||||
{contract.monthlySalary} € brut
|
||||
</span>
|
||||
) : null}
|
||||
<Badge tone={contract.status === 'ACTIVE' ? 'ok' : 'neutral'}>
|
||||
{contract.status === 'ACTIVE' ? 'En cours' : 'Terminé'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{contract.endReason ? (
|
||||
<p className="mt-1 text-xs text-ink-3">
|
||||
Fin · {contract.endReason}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{contract.amendments.length > 0 ? (
|
||||
<ul className="mt-2 border-l-2 border-line-2 pl-3">
|
||||
{contract.amendments.map((amendment) => (
|
||||
<li key={amendment.id} className="py-1 text-xs text-ink-2">
|
||||
<span className="tnum">
|
||||
{dateFormat.format(amendment.effectiveDate)}
|
||||
</span>{' '}
|
||||
— avenant
|
||||
{amendment.reason ? ` · ${amendment.reason}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel';
|
||||
import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { listDocuments } from '@/server/documents/queries';
|
||||
import { getEmployee } from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function DocumentsTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
// `members.documents.view` peut manquer là où `members.view` est accordée :
|
||||
// la section disparaît alors, plutôt que d'échouer sur toute la page.
|
||||
const documents = await listDocuments(id).catch(() => null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{documents ? (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Pièces du dossier"
|
||||
badge={<Badge tone="neutral">{documents.documents.length}</Badge>}
|
||||
/>
|
||||
<DocumentsPanel
|
||||
membershipId={employee.id}
|
||||
documents={documents.documents}
|
||||
canManage={documents.canManage}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Documents non consultables"
|
||||
description="Votre rôle ne donne pas accès aux pièces du dossier."
|
||||
/>
|
||||
)}
|
||||
|
||||
{employee.canInvite ? (
|
||||
<Card>
|
||||
<CardHeader title="Accès à l’application" />
|
||||
<InvitationPanel
|
||||
membershipId={employee.id}
|
||||
defaultEmail={employee.email ?? employee.profile?.personalEmail ?? ''}
|
||||
hasAccount={employee.hasAccount}
|
||||
invitation={employee.invitation}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { MemberTabs } from '@/app/(app)/equipe/[id]/MemberTabs';
|
||||
import { PageBody } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { getEmployee, getMemberPlacement } from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'full' });
|
||||
|
||||
/**
|
||||
* Gabarit de la fiche salarié.
|
||||
*
|
||||
* Le bandeau et les onglets sont tenus ici, et non répétés dans chaque onglet :
|
||||
* ils ne changent pas d'un onglet à l'autre, et les rejouer ferait clignoter
|
||||
* l'identité de la personne consultée à chaque navigation.
|
||||
*/
|
||||
export default async function MemberLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
const placement = await getMemberPlacement(id);
|
||||
const primaryTeam =
|
||||
placement?.teams.find((team) => team.isPrimary) ?? placement?.teams[0];
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<header className="rounded-3 bg-surface-2 p-6">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-14 flex-none items-center justify-center rounded-full bg-accent-soft text-md font-semibold text-accent-soft-ink"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.018em]">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-ink-2">
|
||||
{employee.headline.jobTitle ??
|
||||
`Matricule ${employee.employeeNumber}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!employee.hasAccount ? (
|
||||
<Badge tone="info">Sans accès applicatif</Badge>
|
||||
) : null}
|
||||
<Badge tone="accent">{employee.roleName}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-6 grid gap-x-6 gap-y-4 [grid-template-columns:repeat(auto-fit,minmax(150px,1fr))]">
|
||||
<HeadlineItem
|
||||
label="Début du contrat"
|
||||
value={
|
||||
employee.contract
|
||||
? dateFormat.format(employee.contract.since)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Fin du contrat"
|
||||
value={
|
||||
employee.headline.contractEnd
|
||||
? dateFormat.format(employee.headline.contractEnd)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Type de contrat"
|
||||
value={employee.contract?.label ?? null}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Établissement"
|
||||
value={employee.locationName}
|
||||
/>
|
||||
<HeadlineItem label="Équipe" value={primaryTeam?.name ?? null} />
|
||||
<HeadlineItem
|
||||
label="Responsable hiérarchique"
|
||||
value={employee.headline.lineManagerName}
|
||||
/>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<MemberTabs id={id} />
|
||||
|
||||
{children}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
/** Un tiret, et non « non renseigné » : le bandeau se lit d'un balayage. */
|
||||
function HeadlineItem({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-micro font-semibold text-ink-1">{label}</dt>
|
||||
<dd className="mt-1 truncate text-sm text-ink-2">{value ?? '—'}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel';
|
||||
import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { listDocuments } from '@/server/documents/queries';
|
||||
import {
|
||||
PersonalInfoPanel,
|
||||
type ProfileFields,
|
||||
type SensitiveFields,
|
||||
} from '@/app/(app)/equipe/[id]/PersonalInfoPanel';
|
||||
import { EmptyState } from '@/components/ui/Card';
|
||||
import { getEmployee } from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
/** `null` et `undefined` deviennent la chaîne vide : un `<input>` non contrôlé
|
||||
* affiche « null » sinon. */
|
||||
const text = (value: string | null | undefined): string => value ?? '';
|
||||
|
||||
export default async function FichePage({
|
||||
export default async function PersonalTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -21,181 +23,60 @@ export default async function FichePage({
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
// `members.documents.view` peut manquer là où `members.view` est accordée :
|
||||
// la section disparaît alors, plutôt que d'échouer sur toute la page.
|
||||
const documents = await listDocuments(id).catch(() => null);
|
||||
if (!employee.profile) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Aucun dossier personnel"
|
||||
description="Ce salarié n’a pas de dossier. Il a probablement été créé hors de l’application ; reprenez sa création depuis la liste de l’équipe."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const active = employee.contracts.find(
|
||||
(contract) => contract.status === 'ACTIVE',
|
||||
);
|
||||
const profile: ProfileFields = {
|
||||
gender: text(employee.profile.gender),
|
||||
firstName: text(employee.profile.firstName),
|
||||
birthName: text(employee.profile.birthName),
|
||||
lastName: text(employee.profile.lastName),
|
||||
birthDate: employee.profile.birthDate
|
||||
? employee.profile.birthDate.toISOString().slice(0, 10)
|
||||
: '',
|
||||
birthPlace: text(employee.profile.birthPlace),
|
||||
birthCountry: text(employee.profile.birthCountry),
|
||||
birthDepartment: text(employee.profile.birthDepartment),
|
||||
nationality: text(employee.profile.nationality),
|
||||
maritalStatus: text(employee.profile.maritalStatus),
|
||||
dependents:
|
||||
employee.profile.dependents === null
|
||||
? ''
|
||||
: String(employee.profile.dependents),
|
||||
personalEmail: text(employee.profile.personalEmail),
|
||||
phone: text(employee.profile.phone),
|
||||
landline: text(employee.profile.landline),
|
||||
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),
|
||||
emergencyContactName: text(employee.profile.emergencyContactName),
|
||||
emergencyContactPhone: text(employee.profile.emergencyContactPhone),
|
||||
};
|
||||
|
||||
const sensitive: SensitiveFields | null = employee.profile.sensitive
|
||||
? {
|
||||
socialSecurityNumber: text(
|
||||
employee.profile.sensitive.socialSecurityNumber,
|
||||
),
|
||||
iban: text(employee.profile.sensitive.iban),
|
||||
bic: text(employee.profile.sensitive.bic),
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title={`${employee.firstName} ${employee.lastName}`}
|
||||
subtitle={[
|
||||
employee.contract?.label,
|
||||
employee.locationName,
|
||||
`matricule ${employee.employeeNumber}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-3 border border-line-1 bg-surface p-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-11 flex-none items-center justify-center rounded-full bg-surface-3 text-sm font-semibold text-ink-2"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{employee.email ?? 'Aucun compte applicatif'}
|
||||
</p>
|
||||
<p className="text-micro text-ink-3">Rôle · {employee.roleName}</p>
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
{active?.forfaitJours ? (
|
||||
<Badge tone="accent">
|
||||
Forfait jours · {active.forfaitDaysPerYear ?? '—'} j
|
||||
</Badge>
|
||||
) : active ? (
|
||||
<Badge tone="neutral">{active.weeklyHours} h hebdomadaires</Badge>
|
||||
) : null}
|
||||
{!employee.hasAccount ? (
|
||||
<Badge tone="info">Sans accès applicatif</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{documents ? (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Pièces du dossier"
|
||||
badge={<Badge tone="neutral">{documents.documents.length}</Badge>}
|
||||
/>
|
||||
<DocumentsPanel
|
||||
membershipId={employee.id}
|
||||
documents={documents.documents}
|
||||
canManage={documents.canManage}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{employee.canInvite ? (
|
||||
<Card>
|
||||
<CardHeader title="Accès à l’application" />
|
||||
<InvitationPanel
|
||||
membershipId={employee.id}
|
||||
defaultEmail={
|
||||
employee.email ?? employee.profile?.personalEmail ?? ''
|
||||
}
|
||||
hasAccount={employee.hasAccount}
|
||||
invitation={employee.invitation}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Contrats et avenants"
|
||||
badge={<Badge tone="neutral">{employee.contracts.length}</Badge>}
|
||||
/>
|
||||
{employee.contracts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun contrat"
|
||||
description="Ce salarié n’a pas encore de contrat. Il ne peut pas être planifié tant qu’aucune période n’est ouverte."
|
||||
/>
|
||||
) : (
|
||||
<ul>
|
||||
{employee.contracts.map((contract) => (
|
||||
<li
|
||||
key={contract.id}
|
||||
className="border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{contract.label}</span>
|
||||
{contract.forfaitJours ? (
|
||||
<Badge tone="accent">Forfait jours</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">{contract.weeklyHours} h</Badge>
|
||||
)}
|
||||
<span className="tnum text-xs text-ink-2">
|
||||
{dateFormat.format(contract.startDate)}
|
||||
{contract.endDate
|
||||
? ` → ${dateFormat.format(contract.endDate)}`
|
||||
: ' → en cours'}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{employee.canSeeSalary && contract.monthlySalary ? (
|
||||
<span className="tnum text-sm">
|
||||
{contract.monthlySalary} € brut
|
||||
</span>
|
||||
) : null}
|
||||
<Badge
|
||||
tone={contract.status === 'ACTIVE' ? 'ok' : 'neutral'}
|
||||
>
|
||||
{contract.status === 'ACTIVE' ? 'En cours' : 'Terminé'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{contract.amendments.length > 0 ? (
|
||||
<ul className="mt-2 border-l-2 border-line-2 pl-3">
|
||||
{contract.amendments.map((amendment) => (
|
||||
<li
|
||||
key={amendment.id}
|
||||
className="py-1 text-xs text-ink-2"
|
||||
>
|
||||
<span className="tnum">
|
||||
{dateFormat.format(amendment.effectiveDate)}
|
||||
</span>{' '}
|
||||
— avenant{amendment.reason ? ` · ${amendment.reason}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{employee.profile ? (
|
||||
<Card>
|
||||
<CardHeader title="Dossier personnel" />
|
||||
<dl className="grid gap-x-8 gap-y-3 p-4 text-sm [grid-template-columns:repeat(auto-fit,minmax(200px,1fr))]">
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">Téléphone</dt>
|
||||
<dd className="tnum">{employee.profile.phone ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">Ville</dt>
|
||||
<dd>{employee.profile.city ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">
|
||||
Numéro de sécurité sociale
|
||||
</dt>
|
||||
<dd className="tnum">
|
||||
{/* Non chargé quand la capacité manque : un champ absent de la
|
||||
réponse ne peut fuiter ni par le HTML ni par un journal. */}
|
||||
{employee.profile.socialSecurityNumber ?? (
|
||||
<span className="text-ink-3">Accès non autorisé</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">IBAN</dt>
|
||||
<dd className="tnum">
|
||||
{employee.profile.iban ?? (
|
||||
<span className="text-ink-3">Accès non autorisé</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
) : null}
|
||||
</PageBody>
|
||||
<PersonalInfoPanel
|
||||
membershipId={employee.id}
|
||||
profile={profile}
|
||||
sensitive={sensitive}
|
||||
canEdit={employee.canEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
|
||||
import { InfoCard } from '@/app/(app)/equipe/[id]/InfoCard';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import {
|
||||
updateScopeAction,
|
||||
updateTeamsAction,
|
||||
type PlacementActionState,
|
||||
} from '@/server/employees/placement-actions';
|
||||
import type { ContractLocation } from '@/server/employees/queries';
|
||||
|
||||
/**
|
||||
* Périmètre et rattachement, en saisie.
|
||||
*
|
||||
* Deux formulaires plutôt qu'un : ils ne portent ni la même décision ni la
|
||||
* même capacité, et les réunir donnerait à l'un le pouvoir de l'autre.
|
||||
*/
|
||||
|
||||
const empty: PlacementActionState = {};
|
||||
|
||||
export function ScopeForm({
|
||||
membershipId,
|
||||
locations,
|
||||
allLocations,
|
||||
scopedLocationIds,
|
||||
}: {
|
||||
membershipId: string;
|
||||
locations: ContractLocation[];
|
||||
allLocations: boolean;
|
||||
scopedLocationIds: string[];
|
||||
}) {
|
||||
const [state, save] = useActionState(updateScopeAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<PlacementActionState>(empty);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [all, setAll] = useState(allLocations);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Button onClick={() => setEditing(true)}>Modifier le périmètre</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={save} className="flex flex-col gap-3 pt-3">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allLocations"
|
||||
checked={all}
|
||||
onChange={(event) => setAll(event.target.checked)}
|
||||
className="mt-0.5 size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Accès généralisé</span>
|
||||
<span className="block text-micro text-ink-3">
|
||||
Tous les établissements, y compris ceux qui ouvriront plus tard.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{all ? null : (
|
||||
<fieldset className="flex flex-col gap-2 rounded-3 border border-line-1 p-3">
|
||||
<legend className="px-1 text-micro font-semibold text-ink-1">
|
||||
Établissements
|
||||
</legend>
|
||||
{locations.map((location) => (
|
||||
<label key={location.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="locationIds"
|
||||
value={location.id}
|
||||
defaultChecked={scopedLocationIds.includes(location.id)}
|
||||
className="size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
{location.name}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer le périmètre</SubmitButton>
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamsForm({
|
||||
membershipId,
|
||||
locations,
|
||||
teamIds,
|
||||
primaryTeamId,
|
||||
}: {
|
||||
membershipId: string;
|
||||
locations: ContractLocation[];
|
||||
teamIds: string[];
|
||||
primaryTeamId: string | null;
|
||||
}) {
|
||||
const [state, save] = useActionState(updateTeamsAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<PlacementActionState>(empty);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
const withTeams = locations.filter((location) => location.teams.length > 0);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Button onClick={() => setEditing(true)}>Modifier les équipes</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={save} className="flex flex-col gap-3 pt-3">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
{withTeams.length === 0 ? (
|
||||
<p className="text-sm text-ink-2">
|
||||
Aucune équipe déclarée. Elles se créent dans les réglages des
|
||||
établissements.
|
||||
</p>
|
||||
) : (
|
||||
<InfoCard title="Équipes">
|
||||
{withTeams.map((location) => (
|
||||
<fieldset key={location.id} className="flex flex-col gap-2 py-2">
|
||||
<legend className="text-micro font-semibold text-ink-1">
|
||||
{location.name}
|
||||
</legend>
|
||||
{location.teams.map((team) => (
|
||||
<div key={team.id} className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="teamIds"
|
||||
value={team.id}
|
||||
defaultChecked={teamIds.includes(team.id)}
|
||||
className="size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
{team.name}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-micro text-ink-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="primaryTeamId"
|
||||
value={team.id}
|
||||
defaultChecked={primaryTeamId === team.id}
|
||||
className="size-3.5 accent-[var(--accent)]"
|
||||
/>
|
||||
Principale
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
))}
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer les équipes</SubmitButton>
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
|
||||
import {
|
||||
ScopeForm,
|
||||
TeamsForm,
|
||||
} from '@/app/(app)/equipe/[id]/planification/PlacementPanel';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import { requireSession } from '@/server/context';
|
||||
import {
|
||||
getEmployee,
|
||||
getMemberPlacement,
|
||||
listPlacementOptions,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Rattachement et périmètre.
|
||||
*
|
||||
* L'établissement de rattachement reste en lecture seule : il est porté par le
|
||||
* contrat, et le changer sans avenant ferait diverger le planning du document
|
||||
* opposable.
|
||||
*/
|
||||
export default async function PlacementTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
const [placement, locations, session] = await Promise.all([
|
||||
getMemberPlacement(id),
|
||||
listPlacementOptions(),
|
||||
requireSession(),
|
||||
]);
|
||||
|
||||
const canScope = can(session.actor, 'settings.roles.manage');
|
||||
const canTeams = can(session.actor, 'settings.teams.manage');
|
||||
|
||||
return (
|
||||
<InfoGrid>
|
||||
<InfoCard title="Rattachement">
|
||||
<InfoRow
|
||||
label="Établissement"
|
||||
value={placement?.contractLocationName ?? employee.locationName}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Équipes"
|
||||
value={
|
||||
placement && placement.teams.length > 0
|
||||
? placement.teams
|
||||
.map((team) =>
|
||||
team.isPrimary ? `${team.name} (principale)` : team.name,
|
||||
)
|
||||
.join(', ')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<InfoRow label="Matricule" value={employee.employeeNumber} tnum />
|
||||
<InfoRow
|
||||
label="Responsable hiérarchique"
|
||||
value={employee.headline.lineManagerName}
|
||||
/>
|
||||
|
||||
<p className="pt-3 text-micro text-ink-3">
|
||||
L’établissement vient du contrat : il se change par avenant, pas ici.
|
||||
</p>
|
||||
|
||||
{canTeams ? (
|
||||
<div className="pt-3">
|
||||
<TeamsForm
|
||||
membershipId={employee.id}
|
||||
locations={locations}
|
||||
teamIds={placement?.teamIds ?? []}
|
||||
primaryTeamId={placement?.primaryTeamId ?? null}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Périmètre d’accès">
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line-1 py-3">
|
||||
<dt className="min-w-[11rem] flex-none text-sm font-medium text-ink-1">
|
||||
Accès généralisé
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
{placement?.allLocations ? (
|
||||
<Badge tone="ok">Tous les établissements</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">Limité</Badge>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<InfoRow
|
||||
label="Établissements"
|
||||
value={
|
||||
placement?.allLocations
|
||||
? 'Tous, ouvertures futures comprises'
|
||||
: (placement?.scopedLocations.join(', ') ?? '')
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Équipes"
|
||||
value={placement?.scopedTeams.join(', ') ?? ''}
|
||||
/>
|
||||
<InfoRow label="Rôle" value={employee.roleName} />
|
||||
|
||||
{canScope ? (
|
||||
<div className="pt-3">
|
||||
<ScopeForm
|
||||
membershipId={employee.id}
|
||||
locations={locations}
|
||||
allLocations={placement?.allLocations ?? false}
|
||||
scopedLocationIds={placement?.scopedLocationIds ?? []}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="pt-3 text-micro text-ink-3">
|
||||
Élargir un périmètre revient à distribuer des droits : la capacité
|
||||
« Gérer les rôles » est requise.
|
||||
</p>
|
||||
)}
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
);
|
||||
}
|
||||
+178
-106
@@ -1,129 +1,201 @@
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { AddEmployeeDialog } from '@/app/(app)/equipe/AddEmployeeDialog';
|
||||
import { DirectoryFilters } from '@/app/(app)/equipe/DirectoryFilters';
|
||||
import { RegisterDialog } from '@/app/(app)/equipe/RegisterDialog';
|
||||
import { listRegisterLocations } from '@/server/employees/rup';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm';
|
||||
import { listEmployees } from '@/server/employees/queries';
|
||||
import { EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
listContractLocations,
|
||||
listEmployees,
|
||||
listHiringOptions,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
export const metadata = { title: 'Équipe · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const STATUS_TONES: Record<string, { label: string; tone: Tone }> = {
|
||||
ACTIVE: { label: 'Actif', tone: 'ok' },
|
||||
INVITED: { label: 'Invitation en attente', tone: 'info' },
|
||||
ARCHIVED: { label: 'Archivé', tone: 'neutral' },
|
||||
const INVITATION_TONES: Record<string, { label: string; tone: Tone }> = {
|
||||
ACCEPTED: { label: 'Acceptée', tone: 'ok' },
|
||||
PENDING: { label: 'En attente', tone: 'info' },
|
||||
EXPIRED: { label: 'Expirée', tone: 'warn' },
|
||||
REVOKED: { label: 'Révoquée', tone: 'neutral' },
|
||||
};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
|
||||
const one = (value: string | string[] | undefined): string | undefined =>
|
||||
Array.isArray(value) ? value[0] : value;
|
||||
|
||||
export default async function EquipePage() {
|
||||
const employees = await listEmployees();
|
||||
const withoutAccount = employees.filter((e) => !e.hasAccount).length;
|
||||
export default async function EquipePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const presence = one(params.presence);
|
||||
|
||||
const directory = await listEmployees({
|
||||
...(one(params.q) ? { q: one(params.q) as string } : {}),
|
||||
...(one(params.etablissement)
|
||||
? { locationId: one(params.etablissement) as string }
|
||||
: {}),
|
||||
...(one(params.role) ? { roleId: one(params.role) as string } : {}),
|
||||
...(one(params.contrat)
|
||||
? { contractType: one(params.contrat) as string }
|
||||
: {}),
|
||||
...(presence === 'archived' || presence === 'all'
|
||||
? { presence }
|
||||
: { presence: 'active' as const }),
|
||||
...(one(params.tri) === 'number' ? { sort: 'number' as const } : {}),
|
||||
});
|
||||
|
||||
// Sans la capacité d'ouvrir un contrat, la liste revient vide et le
|
||||
// formulaire s'en tient à l'identité.
|
||||
const hiringLocations = await listContractLocations().catch(() => []);
|
||||
|
||||
// Sans `members.register.export`, le bouton du registre ne paraît pas.
|
||||
const registerLocations = await listRegisterLocations().catch(() => []);
|
||||
|
||||
const hiringOptions = await listHiringOptions().catch(() => ({
|
||||
managers: [],
|
||||
rttPolicies: [],
|
||||
}));
|
||||
|
||||
const filtered = directory.rows.length !== directory.total;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Équipe"
|
||||
subtitle={`${employees.length} salarié${employees.length > 1 ? 's' : ''}${
|
||||
withoutAccount > 0 ? ` · ${withoutAccount} sans compte applicatif` : ''
|
||||
}`}
|
||||
// Le décompte n'apparaît que s'il apprend quelque chose : « 87 salariés »
|
||||
// au-dessus d'une liste de 87 lignes ne dit rien que la liste ne dise.
|
||||
{...(filtered
|
||||
? { subtitle: `${directory.rows.length} sur ${directory.total} salariés` }
|
||||
: {})}
|
||||
actions={
|
||||
<>
|
||||
{registerLocations.length > 0 ? (
|
||||
<RegisterDialog locations={registerLocations} />
|
||||
) : null}
|
||||
<AddEmployeeDialog
|
||||
locations={hiringLocations}
|
||||
options={hiringOptions}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Effectif" />
|
||||
{employees.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun salarié"
|
||||
description="Ajoutez un premier salarié pour commencer à planifier."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[880px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 bg-surface-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
<th className="px-4 py-2.5">Collaborateur</th>
|
||||
<th className="px-4 py-2.5">Matricule</th>
|
||||
<th className="px-4 py-2.5">Contrat</th>
|
||||
<th className="px-4 py-2.5">Établissement</th>
|
||||
<th className="px-4 py-2.5">Rôle</th>
|
||||
<th className="px-4 py-2.5">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.map((employee) => {
|
||||
const status =
|
||||
STATUS_TONES[employee.status] ?? STATUS_TONES.ACTIVE!;
|
||||
return (
|
||||
<tr
|
||||
key={employee.id}
|
||||
className="border-b border-line-1 last:border-b-0 hover:bg-surface-2"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<Link
|
||||
href={`/equipe/${employee.id}`}
|
||||
className="flex items-center gap-2.5 rounded-2"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-7 flex-none items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</span>
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
{employee.email ?? 'Sans compte applicatif'}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="tnum px-4 py-2.5 text-ink-2">
|
||||
{employee.employeeNumber}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{employee.contract ? (
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span>{employee.contract.label}</span>
|
||||
{employee.contract.forfaitJours ? (
|
||||
<Badge tone="accent">Forfait jours</Badge>
|
||||
) : null}
|
||||
<span className="tnum text-micro text-ink-3">
|
||||
depuis le{' '}
|
||||
{dateFormat.format(employee.contract.since)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<Badge tone="warn">Sans contrat</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">
|
||||
{employee.locationName ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">
|
||||
{employee.roleName}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge tone={status.tone}>{status.label}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{/* `useSearchParams` impose une frontière de suspense : sans elle, la
|
||||
page entière serait rendue à la demande au lieu d'être servie puis
|
||||
hydratée. */}
|
||||
<Suspense fallback={<div className="h-[6rem]" />}>
|
||||
<DirectoryFilters
|
||||
options={{
|
||||
locations: directory.locations,
|
||||
roles: directory.roles,
|
||||
contractTypes: directory.contractTypes,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Ajouter un salarié" />
|
||||
<div className="p-4">
|
||||
<AddEmployeeForm />
|
||||
{directory.rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title={filtered ? 'Aucun résultat' : 'Aucun salarié'}
|
||||
description={
|
||||
filtered
|
||||
? 'Aucun salarié ne répond à ces critères. Élargissez la recherche.'
|
||||
: 'Ajoutez un premier salarié pour commencer à planifier.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[940px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 text-left text-micro font-semibold tracking-[0.04em] text-ink-3">
|
||||
<th className="px-3 py-3 font-semibold">Collaborateur</th>
|
||||
<th className="px-3 py-3 font-semibold">Rôle</th>
|
||||
<th className="px-3 py-3 font-semibold">Email</th>
|
||||
<th className="px-3 py-3 font-semibold">Mobile</th>
|
||||
<th className="px-3 py-3 font-semibold">Rattachement</th>
|
||||
<th className="px-3 py-3 font-semibold">Invitation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{directory.rows.map((employee) => {
|
||||
const invitation = employee.invitationState
|
||||
? INVITATION_TONES[employee.invitationState]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={employee.id}
|
||||
className="border-b border-line-1 last:border-b-0 hover:bg-surface-2"
|
||||
>
|
||||
<td className="px-3 py-3">
|
||||
<Link
|
||||
href={`/equipe/${employee.id}`}
|
||||
className="flex items-center gap-2.5 rounded-2"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-8 flex-none items-center justify-center rounded-full bg-accent-soft text-micro font-semibold text-accent-soft-ink"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium underline decoration-line-3 underline-offset-2">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</span>
|
||||
{/* Le matricule ne double la colonne « Email » que
|
||||
lorsqu'il n'y en a pas : c'est alors la seule
|
||||
adresse à laquelle on désigne ce salarié. */}
|
||||
{employee.email ? null : (
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
Matricule {employee.employeeNumber}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<Badge tone="accent">{employee.roleName}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-ink-2">
|
||||
{employee.email ?? (
|
||||
<span className="text-ink-3">Sans compte</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="tnum px-3 py-3 text-ink-2">
|
||||
{employee.phone ?? (
|
||||
<span className="text-ink-3">Non renseigné</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-ink-2">
|
||||
{employee.locationName ? (
|
||||
<>
|
||||
{employee.locationName}
|
||||
{employee.teamName ? ` / ${employee.teamName}` : ''}
|
||||
</>
|
||||
) : (
|
||||
<Badge tone="warn">Sans contrat</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
{invitation ? (
|
||||
<Badge tone={invitation.tone}>{invitation.label}</Badge>
|
||||
) : (
|
||||
<span className="text-micro text-ink-3">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -276,7 +276,7 @@ function EmployeeHome() {
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/conges"
|
||||
href="/absences/calendrier"
|
||||
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">
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -100,10 +100,28 @@ export default async function SemainePage({ searchParams }: PageProps) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Le planning s'ordonne par équipe : sans équipe, il n'y a pas de ligne
|
||||
où poser un créneau, même quand des salariés sont rattachés à
|
||||
l'établissement. Dire ce qui manque ne suffit pas — l'écran dit aussi
|
||||
où le créer, sans quoi le message se lit « il n'y a personne ». */}
|
||||
{board.sections.length === 0 ? (
|
||||
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
|
||||
Cet établissement n’a pas encore d’équipe.
|
||||
</p>
|
||||
<div className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
|
||||
<p className="mb-1 font-medium text-ink-1">
|
||||
Cet établissement n’a pas encore d’équipe.
|
||||
</p>
|
||||
<p>
|
||||
Le planning se construit équipe par équipe : chacune porte son propre
|
||||
état de publication. Créez-en une dans{' '}
|
||||
<Link
|
||||
href="/reglages/etablissements"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Réglages · Établissements
|
||||
</Link>
|
||||
, puis rattachez-y les salariés depuis l’onglet « Planification et
|
||||
accès » de leur fiche.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{board.sections.map((section) => (
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -28,27 +28,6 @@ export default async function RegistrePage() {
|
||||
subtitle={`${register.approvedCount} paramètre${register.approvedCount > 1 ? 's' : ''} approuvé${register.approvedCount > 1 ? 's' : ''} · ${register.pendingCount} en attente`}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div className="p-4 text-sm leading-[var(--lh-prose)] text-ink-2">
|
||||
<p>
|
||||
Chaque paramètre appliqué par PlanFlow doit porter sa{' '}
|
||||
<strong className="font-semibold text-ink-1">valeur</strong>, sa{' '}
|
||||
<strong className="font-semibold text-ink-1">source</strong>, sa{' '}
|
||||
<strong className="font-semibold text-ink-1">date d’effet</strong>,
|
||||
la <strong className="font-semibold text-ink-1">population</strong>{' '}
|
||||
concernée et un{' '}
|
||||
<strong className="font-semibold text-ink-1">approbateur</strong>.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Recopier la configuration d’un autre logiciel ne suffit pas : sans
|
||||
justification conservée, un paramètre n’est pas opposable en cas de
|
||||
contrôle. Les valeurs de la convention IDCC 1517 déjà chargées dans
|
||||
le moteur restent à recouper avec le texte consolidé et à faire
|
||||
valider par le gestionnaire de paie.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{register.missingDomains.length > 0 ? (
|
||||
<Card className="border-warn">
|
||||
<CardHeader
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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)}`;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
|
||||
import { asciiFileName, sanitiseFileName } from '@/domain/documents/rules';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { query } from '@/server/context';
|
||||
import { getRegisterData } from '@/server/employees/rup';
|
||||
import { renderRegisterPdf } from '@/server/employees/rup-pdf';
|
||||
|
||||
/**
|
||||
* Téléchargement du registre unique du personnel.
|
||||
*
|
||||
* Une route et non une action : le document est un fichier, pas un écran, et
|
||||
* son édition doit pouvoir être rejouée par une simple adresse — c'est aussi
|
||||
* ce qui permet de la présenter depuis un poste d'inspection.
|
||||
*
|
||||
* L'édition est journalisée. Le registre rassemble l'identité, la nationalité
|
||||
* et le parcours de tout le personnel d'un établissement : savoir qui l'a
|
||||
* extrait, et quand, fait partie de ce qu'on doit pouvoir répondre.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const locationId = request.nextUrl.searchParams.get('etablissement');
|
||||
if (!locationId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Choisissez un établissement.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = await getRegisterData(locationId);
|
||||
if (!data) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Établissement introuvable.' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
await query('members.register.export', async (db, actor) =>
|
||||
recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'members.register.export',
|
||||
entityType: 'Location',
|
||||
entityId: data.locationId,
|
||||
after: { lines: data.people.length, incomplete: data.incomplete },
|
||||
}),
|
||||
);
|
||||
|
||||
const pdf = await renderRegisterPdf(data);
|
||||
const stamp = new Date().toISOString().slice(0, 10).split('-').reverse().join('_');
|
||||
const name = sanitiseFileName(`RUP - ${data.locationName} - ${stamp}.pdf`);
|
||||
|
||||
return new NextResponse(pdf as BodyInit, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${asciiFileName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`,
|
||||
// Un registre se lit à l'instant où il est demandé : le mettre en cache
|
||||
// ferait présenter un effectif périmé au contrôle suivant.
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
+177
-58
@@ -14,74 +14,98 @@
|
||||
========================================================================== */
|
||||
|
||||
@theme {
|
||||
--font-ui: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, 'Cascadia Mono', 'Segoe UI Mono',
|
||||
'Liberation Mono', Menlo, monospace;
|
||||
/* IBM Plex — servi depuis l'application, jamais depuis un CDN (voir
|
||||
src/app/layout.tsx). Le repli système suit en cas d'échec de chargement :
|
||||
un planning doit rester lisible même quand la fonte ne l'est pas. */
|
||||
--font-ui: var(--font-plex-sans), ui-sans-serif, system-ui, -apple-system,
|
||||
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: var(--font-plex-mono), ui-monospace, SFMono-Regular,
|
||||
'Cascadia Mono', 'Segoe UI Mono', 'Liberation Mono', Menlo, monospace;
|
||||
|
||||
--text-micro: 11px;
|
||||
--text-xs: 12px;
|
||||
--text-sm: 13px;
|
||||
--text-md: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 20px;
|
||||
--text-2xl: 26px;
|
||||
--text-3xl: 32px;
|
||||
/* Échelle relevée d'un cran : l'ancienne partait de 14 px de corps et 11 px
|
||||
de micro-libellé, ce qui se lit mal huit heures par jour sur un écran de
|
||||
back-office. Le corps est à 15 px, et rien ne descend sous 12. */
|
||||
--text-micro: 12px;
|
||||
--text-xs: 13px;
|
||||
--text-sm: 14px;
|
||||
--text-md: 15px;
|
||||
--text-lg: 17px;
|
||||
--text-xl: 21px;
|
||||
--text-2xl: 27px;
|
||||
--text-3xl: 34px;
|
||||
|
||||
--radius-1: 3px;
|
||||
--radius-2: 6px;
|
||||
--radius-3: 10px;
|
||||
--radius-4: 14px;
|
||||
/* Rayons courts. Un planning est un tableau de bord, pas une carte de
|
||||
visite : au-delà de 8 px les cellules perdent leur alignement optique. */
|
||||
--radius-1: 2px;
|
||||
--radius-2: 5px;
|
||||
--radius-3: 8px;
|
||||
--radius-4: 12px;
|
||||
--radius-full: 999px;
|
||||
|
||||
--ease-organic: cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
--ease-organic-out: cubic-bezier(0, 0.6, 0.35, 1);
|
||||
|
||||
/* Neutres chauds — la base « organique » du thème. */
|
||||
--color-canvas: oklch(0.976 0.008 85);
|
||||
--color-surface: oklch(0.996 0.005 85);
|
||||
--color-surface-2: oklch(0.958 0.01 85);
|
||||
--color-surface-3: oklch(0.932 0.012 85);
|
||||
--color-line-1: oklch(0.905 0.012 85);
|
||||
--color-line-2: oklch(0.852 0.014 85);
|
||||
--color-line-3: oklch(0.735 0.016 85);
|
||||
--color-ink-1: oklch(0.255 0.02 140);
|
||||
--color-ink-2: oklch(0.445 0.016 135);
|
||||
--color-ink-3: oklch(0.545 0.014 120);
|
||||
/* ---- Neutres froids ---------------------------------------------------
|
||||
Papier légèrement bleuté plutôt que crème. Le crème réchauffe et fatigue
|
||||
moins sur un texte long ; ici l'écran porte des tableaux de chiffres et des
|
||||
aplats colorés, et un fond chaud décale la perception de ces aplats — un
|
||||
jaune de poste vire au vert, un rouge d'alerte s'éteint. Le neutre froid
|
||||
laisse la couleur dire ce qu'elle dit.
|
||||
--------------------------------------------------------------------- */
|
||||
--color-canvas: oklch(0.979 0.004 250);
|
||||
--color-surface: oklch(1 0 0);
|
||||
--color-surface-2: oklch(0.965 0.005 250);
|
||||
--color-surface-3: oklch(0.938 0.008 250);
|
||||
--color-line-1: oklch(0.912 0.008 250);
|
||||
--color-line-2: oklch(0.858 0.011 250);
|
||||
--color-line-3: oklch(0.72 0.015 250);
|
||||
|
||||
--color-accent: oklch(0.44 0.078 158);
|
||||
--color-accent-hover: oklch(0.385 0.08 158);
|
||||
--color-accent-press: oklch(0.335 0.072 158);
|
||||
--color-accent-ink: oklch(0.985 0.008 158);
|
||||
--color-accent-soft: oklch(0.935 0.03 158);
|
||||
--color-accent-soft-ink: oklch(0.315 0.06 158);
|
||||
/* Encre plus dense qu'avant, et franchement bleutée. `ink-3` monte à 0.50 :
|
||||
à 0.545 sur ce papier, les libellés secondaires passaient sous 4,5:1. */
|
||||
--color-ink-1: oklch(0.215 0.018 258);
|
||||
--color-ink-2: oklch(0.405 0.018 256);
|
||||
--color-ink-3: oklch(0.50 0.017 254);
|
||||
|
||||
--color-focus: oklch(0.52 0.16 252);
|
||||
/* Bleu d'acte. Il ne sert qu'à trois choses : le filet actif, les liens, et
|
||||
le bouton qui engage. Tout le reste du produit est en gris et en état. */
|
||||
--color-accent: oklch(0.435 0.135 258);
|
||||
--color-accent-hover: oklch(0.375 0.135 258);
|
||||
--color-accent-press: oklch(0.325 0.12 258);
|
||||
--color-accent-ink: oklch(0.99 0.004 258);
|
||||
--color-accent-soft: oklch(0.945 0.028 258);
|
||||
--color-accent-soft-ink: oklch(0.335 0.11 258);
|
||||
|
||||
--color-danger: oklch(0.475 0.145 30);
|
||||
--color-danger-hover: oklch(0.415 0.145 30);
|
||||
--color-danger-ink: oklch(0.985 0.006 30);
|
||||
--color-danger-soft: oklch(0.94 0.035 30);
|
||||
--color-danger-soft-ink: oklch(0.355 0.105 30);
|
||||
/* Focus en cyan : distinct du bleu d'accent, sinon l'anneau disparaît sur le
|
||||
bouton primaire, c'est-à-dire là où il compte le plus. */
|
||||
--color-focus: oklch(0.58 0.155 218);
|
||||
|
||||
--color-warn: oklch(0.745 0.125 78);
|
||||
--color-warn-ink: oklch(0.255 0.055 60);
|
||||
--color-warn-soft: oklch(0.945 0.045 82);
|
||||
--color-warn-soft-ink: oklch(0.375 0.085 60);
|
||||
--color-danger: oklch(0.505 0.175 27);
|
||||
--color-danger-hover: oklch(0.445 0.175 27);
|
||||
--color-danger-ink: oklch(0.99 0.005 27);
|
||||
--color-danger-soft: oklch(0.955 0.03 27);
|
||||
--color-danger-soft-ink: oklch(0.395 0.13 27);
|
||||
|
||||
--color-ok: oklch(0.475 0.095 148);
|
||||
--color-ok-soft: oklch(0.935 0.035 148);
|
||||
--color-ok-soft-ink: oklch(0.315 0.07 148);
|
||||
--color-warn: oklch(0.70 0.145 65);
|
||||
--color-warn-ink: oklch(0.24 0.06 55);
|
||||
--color-warn-soft: oklch(0.958 0.045 78);
|
||||
--color-warn-soft-ink: oklch(0.415 0.10 55);
|
||||
|
||||
--color-info: oklch(0.475 0.105 245);
|
||||
--color-info-soft: oklch(0.935 0.035 245);
|
||||
--color-info-soft-ink: oklch(0.325 0.08 245);
|
||||
--color-ok: oklch(0.505 0.115 158);
|
||||
--color-ok-soft: oklch(0.955 0.033 158);
|
||||
--color-ok-soft-ink: oklch(0.365 0.085 158);
|
||||
|
||||
--shadow-e1: 0 1px 2px oklch(0.3 0.02 85 / 0.08);
|
||||
--color-info: oklch(0.505 0.115 240);
|
||||
--color-info-soft: oklch(0.955 0.03 240);
|
||||
--color-info-soft-ink: oklch(0.365 0.09 240);
|
||||
|
||||
/* Ombres froides et courtes. Une ombre longue sur un tableau dense fait
|
||||
flotter les cartes les unes au-dessus des autres et brouille la lecture
|
||||
des colonnes. */
|
||||
--shadow-e1: 0 1px 1px oklch(0.35 0.03 258 / 0.06);
|
||||
--shadow-e2:
|
||||
0 1px 2px oklch(0.3 0.02 85 / 0.06), 0 6px 14px oklch(0.3 0.02 85 / 0.07);
|
||||
0 1px 2px oklch(0.35 0.03 258 / 0.07), 0 4px 10px oklch(0.35 0.03 258 / 0.06);
|
||||
--shadow-e3:
|
||||
0 2px 6px oklch(0.3 0.02 85 / 0.08), 0 16px 40px oklch(0.3 0.02 85 / 0.14);
|
||||
0 2px 4px oklch(0.35 0.03 258 / 0.08), 0 14px 34px oklch(0.35 0.03 258 / 0.12);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -111,12 +135,16 @@
|
||||
toujours écrit dans le bloc (voir ShiftChip).
|
||||
--------------------------------------------------------------------- */
|
||||
--tier-dir: -1;
|
||||
--chip-bg-l: 0.905;
|
||||
--chip-bg-c: 0.042;
|
||||
--chip-fg-l: 0.315;
|
||||
--chip-fg-c: 0.055;
|
||||
--chip-edge-l: 0.52;
|
||||
--chip-edge-c: 0.12;
|
||||
/* Retendus pour le papier froid : fonds un cran plus clairs et moins
|
||||
chargés, encre un cran plus sombre. Sur l'ancien fond crème, les aplats
|
||||
tiraient tous vers le même beige moyen et la grille devenait un patchwork ;
|
||||
ici ils restent des repères, et le texte du bloc passe 7:1. */
|
||||
--chip-bg-l: 0.928;
|
||||
--chip-bg-c: 0.038;
|
||||
--chip-fg-l: 0.29;
|
||||
--chip-fg-c: 0.062;
|
||||
--chip-edge-l: 0.545;
|
||||
--chip-edge-c: 0.115;
|
||||
|
||||
--post-cai-h: 250;
|
||||
--post-cai-t: 0;
|
||||
@@ -336,6 +364,30 @@ body {
|
||||
font-size: var(--text-md);
|
||||
line-height: var(--lh-ui);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Zéro barré partout. Sur un écran couvert de codes — AB-300, HS-HS25, un
|
||||
matricule — la confusion entre 0 et O se paie en ligne de paie fausse. */
|
||||
font-variant-numeric: slashed-zero;
|
||||
}
|
||||
|
||||
/* Les titres se resserrent, les micro-libellés s'écartent. Cette respiration
|
||||
inverse est ce qui distingue une hiérarchie d'un simple changement de corps :
|
||||
à 27 px, l'espacement par défaut d'une grotesque paraît lâche ; à 12 px, il
|
||||
colle. */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
letter-spacing: -0.02em;
|
||||
line-height: var(--lh-tight);
|
||||
}
|
||||
|
||||
/* Les codes se lisent en chasse fixe : ils se recopient caractère par
|
||||
caractère, souvent à la main, dans un autre logiciel. */
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95em;
|
||||
font-variant-numeric: slashed-zero;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -357,6 +409,66 @@ body {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Le filet — élément de structure du produit
|
||||
|
||||
Un planning est lu comme un registre : des lignes, des colonnes, et des
|
||||
filets qui disent où commence quoi. Plutôt que d'entourer chaque bloc d'une
|
||||
bordure complète, la structure est portée par un **filet de marge** de 2 px
|
||||
à gauche de ce qui est actif ou de ce qui compte. C'est la marge rouge du
|
||||
cahier, appliquée à une interface.
|
||||
|
||||
Deux règles, pour que ce ne soit pas un ornement :
|
||||
- un filet marque une **appartenance** (cette section, cet onglet), jamais
|
||||
une décoration ;
|
||||
- il ne double jamais une bordure existante.
|
||||
========================================================================== */
|
||||
.spine {
|
||||
border-left: 2px solid var(--color-accent);
|
||||
padding-left: 0.875rem;
|
||||
}
|
||||
|
||||
.spine-quiet {
|
||||
border-left: 2px solid var(--color-line-2);
|
||||
padding-left: 0.875rem;
|
||||
}
|
||||
|
||||
/* Sur-titre : le petit libellé qui nomme une zone sans lui voler la vedette.
|
||||
Interlettré large et gradé haut — à cette taille, la graisse porte mieux la
|
||||
lisibilité que le corps. */
|
||||
.eyebrow {
|
||||
font-size: var(--text-micro);
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.085em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
/* Un chiffre qu'on vient lire, par opposition à un chiffre dans une phrase :
|
||||
compteur d'heures, effectif, écart. Tabulaire, dense, sans ambiguïté entre
|
||||
le zéro et le O. */
|
||||
.figure {
|
||||
font-variant-numeric: tabular-nums lining-nums slashed-zero;
|
||||
letter-spacing: -0.01em;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
/* ---- Le dimanche ---------------------------------------------------------
|
||||
La seule colonne du planning qui porte une conséquence de droit : L3132-27
|
||||
impose une rémunération au moins double **et** un repos compensateur
|
||||
équivalent. Elle est donc marquée dans la grille — fond très légèrement
|
||||
teinté, et un filet double à sa gauche, celui qu'on trace dans un registre
|
||||
pour séparer ce qui se totalise à part.
|
||||
|
||||
Le marquage ne remplace aucune alerte : le moteur de règles reste seul juge.
|
||||
Il évite de découvrir un dimanche planifié au moment de la paie.
|
||||
------------------------------------------------------------------------ */
|
||||
.col-sunday {
|
||||
background: var(--color-warn-soft);
|
||||
box-shadow: inset 2px 0 0 var(--color-warn), inset 5px 0 0 var(--color-surface);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -411,6 +523,13 @@ body {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
/* La colonne centrale est de largeur fixe à l'écran — c'est ce qui rend la
|
||||
grille reconnaissable d'un poste à l'autre. Sur une feuille, cette largeur
|
||||
n'a plus de sens et déborderait de la page. */
|
||||
.w-\[1280px\] {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
[data-print='hide'],
|
||||
nav,
|
||||
aside {
|
||||
|
||||
+42
-1
@@ -1,10 +1,46 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { IBM_Plex_Mono, IBM_Plex_Sans } from 'next/font/google';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { THEME_INIT_SCRIPT } from '@/components/shell/ThemeToggle';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
/**
|
||||
* IBM Plex Sans, et Plex Mono pour les codes.
|
||||
*
|
||||
* Le choix vient de ce que l'écran affiche vraiment : des **identifiants**.
|
||||
* `AB-300`, `HS-HS25`, un matricule `E0004`, un code de poste, un SIRET, un
|
||||
* département `2A`. Plex a été dessiné pour la documentation technique — le
|
||||
* 1 porte un empattement, le l est courbé, le 0 se distingue du O — et ces
|
||||
* trois détails décident si un gestionnaire de paie recopie le bon code.
|
||||
*
|
||||
* Ses formes légèrement carrées tiennent aussi dans une cellule de tableau
|
||||
* dense, là où une grotesque géométrique se referme et où deux lignes de
|
||||
* chiffres cessent de s'aligner à l'œil.
|
||||
*
|
||||
* `next/font` télécharge les fichiers **à la compilation** et les sert depuis
|
||||
* l'application : la directive `font-src 'self'` reste satisfaite, et aucune
|
||||
* requête ne part vers un tiers à l'exécution — ce que la charte de télémétrie
|
||||
* interdit de toute façon.
|
||||
*/
|
||||
const plexSans = IBM_Plex_Sans({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-plex-sans',
|
||||
display: 'swap',
|
||||
// Métriques ajustées sur la fonte de repli : sans cela, la bascule au
|
||||
// chargement décale toute la grille d'un demi-caractère.
|
||||
adjustFontFallback: true,
|
||||
});
|
||||
|
||||
const plexMono = IBM_Plex_Mono({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500'],
|
||||
variable: '--font-plex-mono',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'PlanFlow',
|
||||
description: 'Gestion du personnel, des plannings et des temps.',
|
||||
@@ -19,7 +55,12 @@ export default async function RootLayout({
|
||||
const nonce = (await headers()).get('x-nonce') ?? undefined;
|
||||
|
||||
return (
|
||||
<html lang="fr" data-theme="light" suppressHydrationWarning>
|
||||
<html
|
||||
lang="fr"
|
||||
data-theme="light"
|
||||
className={`${plexSans.variable} ${plexMono.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
nonce={nonce}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,22 @@ export function RequestForm({
|
||||
empty,
|
||||
);
|
||||
|
||||
// Un menu « Type » vide ne se voit pas : le formulaire paraît complet, la
|
||||
// demande part sans type et échoue au serveur. Dire que le référentiel manque
|
||||
// coûte moins qu'une saisie qui ne pouvait pas aboutir.
|
||||
if (absenceTypes.length === 0) {
|
||||
return (
|
||||
<p className="rounded-3 border border-line-2 bg-surface-2 p-3 text-sm text-ink-2">
|
||||
Aucun type d’absence n’est défini : une demande ne peut pas être
|
||||
qualifiée, donc pas enregistrée. Le référentiel se pose depuis{' '}
|
||||
<a className="underline" href="/reglages/types-absence">
|
||||
Réglages · Types d’absence
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
resetAfter={state.ok ? state : null}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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,6 +1,15 @@
|
||||
import type { AbsenceColorKey } from '@/domain/absences/colors';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
export type AbsenceKind = 'cp' | 'rtt' | 'maladie' | 'sans-solde' | 'attente';
|
||||
/**
|
||||
* Familles d'absence, pour la teinte de la barre — voir
|
||||
* `src/domain/absences/colors.ts`.
|
||||
*
|
||||
* Le repli est `sans-solde`, gris, et c'est délibérément le plus terne : une
|
||||
* teinte inconnue doit se remarquer comme une configuration incomplète, pas se
|
||||
* fondre dans les congés payés.
|
||||
*/
|
||||
export type AbsenceKind = AbsenceColorKey;
|
||||
|
||||
const KINDS: Record<AbsenceKind, { bg: string; ink: string; edge: string }> = {
|
||||
cp: {
|
||||
@@ -18,6 +27,28 @@ const KINDS: Record<AbsenceKind, { bg: string; ink: string; edge: string }> = {
|
||||
ink: 'var(--color-ink-2)',
|
||||
edge: 'var(--color-line-3)',
|
||||
},
|
||||
famille: {
|
||||
bg: 'var(--post-rss-bg)',
|
||||
ink: 'var(--post-rss-fg)',
|
||||
edge: 'var(--post-rss-edge)',
|
||||
},
|
||||
formation: {
|
||||
bg: 'var(--post-for-bg)',
|
||||
ink: 'var(--post-for-fg)',
|
||||
edge: 'var(--post-for-edge)',
|
||||
},
|
||||
ferie: {
|
||||
bg: 'var(--post-enc-bg)',
|
||||
ink: 'var(--post-enc-fg)',
|
||||
edge: 'var(--post-enc-edge)',
|
||||
},
|
||||
// Une sanction se distingue d'une absence ordinaire : elle est subie, elle se
|
||||
// conteste, et elle ne se lit pas comme un congé.
|
||||
sanction: {
|
||||
bg: 'var(--color-danger-soft)',
|
||||
ink: 'var(--color-danger-soft-ink)',
|
||||
edge: 'var(--color-danger)',
|
||||
},
|
||||
'sans-solde': {
|
||||
bg: 'var(--color-surface-2)',
|
||||
ink: 'var(--color-ink-2)',
|
||||
|
||||
@@ -27,9 +27,16 @@ function Cell({ label, value }: { label: string; value: string }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Les cinq compteurs d'une ligne salarié : contrat, planifié, absences, écart,
|
||||
* dimanches et repos. Rendus depuis `computeWeekCounters`, jamais recalculés
|
||||
* localement.
|
||||
* 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.
|
||||
*/
|
||||
export function CounterStrip({
|
||||
counters,
|
||||
@@ -57,7 +64,10 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +81,7 @@ 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',
|
||||
@@ -80,8 +91,7 @@ export function CounterStrip({
|
||||
>
|
||||
{counters.deltaLabel}
|
||||
</span>
|
||||
<Cell label="Dim." value={String(counters.sundaysWorked)} />
|
||||
<Cell label="Rep." value={String(counters.restDays)} />
|
||||
<Cell label="RC" value={counters.compensatoryRestLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { WeekGrid } from '@/components/planning/WeekGrid';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import {
|
||||
displayName,
|
||||
@@ -205,6 +206,8 @@ export function TeamSection({
|
||||
rows={section.rows}
|
||||
localDate={dates[target.dayIndex] ?? ''}
|
||||
dayLabel={days[target.dayIndex] ?? ''}
|
||||
dates={dates}
|
||||
days={days}
|
||||
target={target}
|
||||
onClose={() => setTarget(null)}
|
||||
/>
|
||||
@@ -350,6 +353,8 @@ function ShiftComposer({
|
||||
rows,
|
||||
localDate,
|
||||
dayLabel,
|
||||
dates,
|
||||
days,
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
@@ -359,6 +364,8 @@ function ShiftComposer({
|
||||
rows: BoardRow[];
|
||||
localDate: string;
|
||||
dayLabel: string;
|
||||
dates: string[];
|
||||
days: string[];
|
||||
target: Target;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@@ -437,33 +444,67 @@ function ShiftComposer({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Pause (min)" htmlFor="composer-break">
|
||||
<BreakRows
|
||||
initial={editing ? target.shift.breaks : []}
|
||||
fallbackMinutes={editing ? target.shift.breakMinutes : 0}
|
||||
/>
|
||||
|
||||
<Field label="Repas" htmlFor="composer-meals">
|
||||
<input
|
||||
id="composer-break"
|
||||
name="breakMinutes"
|
||||
id="composer-meals"
|
||||
name="mealCount"
|
||||
type="number"
|
||||
min={0}
|
||||
max={600}
|
||||
step={5}
|
||||
defaultValue={editing ? target.shift.breakMinutes : 0}
|
||||
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
max={5}
|
||||
defaultValue={editing ? target.shift.mealCount : 0}
|
||||
className="h-8 w-16 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{editing ? null : (
|
||||
<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>
|
||||
<>
|
||||
<RepeatDays dates={dates} days={days} selected={localDate} />
|
||||
|
||||
{/* Décoché par défaut, à l'inverse du produit audité. Un avis par
|
||||
créneau posé remplit vite une boîte : c'est la publication de la
|
||||
semaine qui prévient, et cette case sert au créneau ajouté après
|
||||
coup — le cas où le salarié ne verrait rien sans elle. */}
|
||||
<label className="flex items-center gap-2 text-sm text-ink-2">
|
||||
<input type="checkbox" name="notify" />
|
||||
Prévenir le salarié par courriel
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -484,6 +525,200 @@ function ShiftComposer({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses d'un créneau, une ligne par pause.
|
||||
*
|
||||
* Un total unique ne disait pas ce qui composait la journée : deux pauses de
|
||||
* vingt minutes et une coupure de deux heures s'écrivaient « 120 », et rien ne
|
||||
* permettait de les distinguer — ni à l'écran, ni au contrôle.
|
||||
*
|
||||
* Le début est facultatif. Beaucoup de pauses se prennent « quand c'est
|
||||
* calme » : imposer une heure obligerait à inventer une précision que le
|
||||
* planning n'a pas, et qu'un contrôle prendrait pour un engagement.
|
||||
*/
|
||||
function BreakRows({
|
||||
initial,
|
||||
fallbackMinutes,
|
||||
}: {
|
||||
initial: ReadonlyArray<{
|
||||
startMinutes: number | null;
|
||||
durationMinutes: number;
|
||||
isPaid: boolean;
|
||||
label: string | null;
|
||||
}>;
|
||||
fallbackMinutes: number;
|
||||
}) {
|
||||
// Un créneau enregistré avant le détail des pauses n'a qu'un total : il est
|
||||
// repris comme une pause unique plutôt que perdu.
|
||||
const seeded =
|
||||
initial.length > 0
|
||||
? initial
|
||||
: fallbackMinutes > 0
|
||||
? [
|
||||
{
|
||||
startMinutes: null,
|
||||
durationMinutes: fallbackMinutes,
|
||||
isPaid: false,
|
||||
label: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const [rows, setRows] = useState(seeded);
|
||||
|
||||
return (
|
||||
<fieldset className="flex w-full flex-col gap-1.5">
|
||||
<legend className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Pauses
|
||||
</legend>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-micro text-ink-3">Aucune pause.</p>
|
||||
) : null}
|
||||
|
||||
{rows.map((row, index) => (
|
||||
<div key={index} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
name="breakDuration"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
step={5}
|
||||
required
|
||||
defaultValue={row.durationMinutes}
|
||||
aria-label={`Durée de la pause ${index + 1}, en minutes`}
|
||||
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
<span className="text-micro text-ink-3">min</span>
|
||||
|
||||
<input
|
||||
name="breakStart"
|
||||
type="number"
|
||||
min={0}
|
||||
step={5}
|
||||
defaultValue={row.startMinutes ?? ''}
|
||||
placeholder="début"
|
||||
aria-label={`Début de la pause ${index + 1}, en minutes après la prise de poste`}
|
||||
className="h-8 w-24 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
|
||||
<input
|
||||
name="breakLabel"
|
||||
type="text"
|
||||
maxLength={60}
|
||||
defaultValue={row.label ?? ''}
|
||||
placeholder="libellé"
|
||||
aria-label={`Libellé de la pause ${index + 1}`}
|
||||
className="h-8 w-28 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-1 text-micro text-ink-2">
|
||||
{/* La valeur porte l'index : une case non cochée n'est pas envoyée,
|
||||
et « on » seul ne dirait pas quelle ligne est payée. */}
|
||||
<input
|
||||
type="checkbox"
|
||||
name="breakPaid"
|
||||
value={String(index)}
|
||||
defaultChecked={row.isPaid}
|
||||
/>
|
||||
rémunérée
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRows(rows.filter((_, at) => at !== index))}
|
||||
className="rounded-2 px-1.5 py-0.5 text-micro text-ink-3 hover:text-danger"
|
||||
>
|
||||
Retirer
|
||||
<span className="sr-only">{` la pause ${index + 1}`}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setRows([
|
||||
...rows,
|
||||
{
|
||||
startMinutes: null,
|
||||
durationMinutes: 20,
|
||||
isPaid: false,
|
||||
label: null,
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
+ Ajouter une pause
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Répétition du créneau sur plusieurs jours.
|
||||
*
|
||||
* Le jour ouvert est coché et **verrouillé** : on ne crée pas « ailleurs qu'ici
|
||||
* » depuis la case d'un jour donné, et le décocher laisserait un formulaire qui
|
||||
* ne produit rien tout en paraissant valide.
|
||||
*
|
||||
* La création est tout ou rien côté serveur : un jour refusé — période close,
|
||||
* chevauchement — annule les autres. Une répétition à demi appliquée laisserait
|
||||
* un planning que personne n'a voulu.
|
||||
*/
|
||||
function RepeatDays({
|
||||
dates,
|
||||
days,
|
||||
selected,
|
||||
}: {
|
||||
dates: string[];
|
||||
days: string[];
|
||||
selected: string;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-1">
|
||||
<legend className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Répéter sur
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{dates.map((date, index) => {
|
||||
const locked = date === selected;
|
||||
return (
|
||||
<label
|
||||
key={date}
|
||||
className={cx(
|
||||
'flex cursor-pointer items-center gap-1 rounded-2 border px-2 py-1 text-micro',
|
||||
locked
|
||||
? 'border-accent bg-accent-soft text-accent-soft-ink'
|
||||
: 'border-line-2 bg-surface text-ink-2 hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="localDates"
|
||||
value={date}
|
||||
defaultChecked={locked}
|
||||
disabled={locked}
|
||||
className="size-3"
|
||||
/>
|
||||
{/* Un champ désactivé n'est pas envoyé : le jour ouvert voyage
|
||||
donc dans un champ caché, sans quoi cocher zéro case
|
||||
produirait une création vide. */}
|
||||
{locked ? (
|
||||
<input type="hidden" name="localDates" value={date} />
|
||||
) : null}
|
||||
{(days[index] ?? date).slice(0, 3)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AbsenceBar, type AbsenceKind } from '@/components/planning/AbsenceBar';
|
||||
import { CounterStrip } from '@/components/planning/CounterStrip';
|
||||
import { ShiftChip } from '@/components/planning/ShiftChip';
|
||||
import { isAbsenceColorKey } from '@/domain/absences/colors';
|
||||
import { computeWeekCounters } from '@/domain/counters/week';
|
||||
import {
|
||||
displayName,
|
||||
@@ -246,8 +247,5 @@ function Row({
|
||||
* repli neutre : une teinte inconnue vaut mieux affichée en gris qu'absente.
|
||||
*/
|
||||
function absenceKind(colorKey: string): AbsenceKind {
|
||||
const known: AbsenceKind[] = ['cp', 'rtt', 'maladie', 'sans-solde', 'attente'];
|
||||
return known.includes(colorKey as AbsenceKind)
|
||||
? (colorKey as AbsenceKind)
|
||||
: 'sans-solde';
|
||||
return isAbsenceColorKey(colorKey) ? colorKey : 'sans-solde';
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useActionState, useState, useTransition } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -28,8 +29,12 @@ export function MfaSettings({
|
||||
enrolled: boolean;
|
||||
required: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [offer, setOffer] = useState<OfferState | null>(null);
|
||||
const [starting, startTransition] = useTransition();
|
||||
// Les codes restent à l'écran tant que personne n'a dit les avoir notés : ce
|
||||
// sont eux, et non l'horloge, qui décident du moment où l'on passe à la suite.
|
||||
const [codesNoted, setCodesNoted] = useState(false);
|
||||
|
||||
const [confirmState, confirm, confirming] = useActionState(
|
||||
confirmEnrolmentAction,
|
||||
@@ -43,8 +48,22 @@ export function MfaSettings({
|
||||
const codes = confirmState.recoveryCodes;
|
||||
const active = (enrolled || confirmState.ok) && !disableState.ok;
|
||||
|
||||
if (codes && codes.length > 0) {
|
||||
return <RecoveryCodes codes={codes} />;
|
||||
if (codes && codes.length > 0 && !codesNoted) {
|
||||
return (
|
||||
<RecoveryCodes
|
||||
codes={codes}
|
||||
onNoted={() => {
|
||||
setCodesNoted(true);
|
||||
// Le layout applicatif décide de l'écran d'enrôlement au rendu, et un
|
||||
// layout n'est pas rejoué par la navigation client : sans ce
|
||||
// rafraîchissement, l'écran resterait posé sur toutes les pages, quel
|
||||
// que soit le menu choisi. Il est déclenché ici, et non par l'action
|
||||
// d'enrôlement, pour que les codes ne s'effacent pas avant d'avoir été
|
||||
// notés.
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (active) {
|
||||
@@ -193,7 +212,13 @@ export function MfaSettings({
|
||||
* Ils sont conservés hachés : ni le support ni un administrateur ne peuvent les
|
||||
* relire. La seule issue, ensuite, est d'en régénérer une série.
|
||||
*/
|
||||
function RecoveryCodes({ codes }: { codes: string[] }) {
|
||||
function RecoveryCodes({
|
||||
codes,
|
||||
onNoted,
|
||||
}: {
|
||||
codes: string[];
|
||||
onNoted: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<p className="text-sm text-ink-1">
|
||||
@@ -214,6 +239,11 @@ function RecoveryCodes({ codes }: { codes: string[] }) {
|
||||
Affichés une seule fois : ils ne sont pas conservés en clair. Chacun ne
|
||||
sert qu’une fois.
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="primary" onClick={onNoted}>
|
||||
J’ai noté mes codes, continuer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,16 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { SectionTabs } from '@/components/shell/SectionTabs';
|
||||
import { SettingsRail } from '@/components/shell/SettingsRail';
|
||||
import { ThemeToggle } from '@/components/shell/ThemeToggle';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { signOutAction } from '@/server/auth/actions';
|
||||
import {
|
||||
isActive,
|
||||
NAVIGATION,
|
||||
placeholderHref,
|
||||
sectionForPath,
|
||||
sectionHref,
|
||||
type NavItem,
|
||||
} from '@/components/shell/navigation';
|
||||
|
||||
export interface AppShellProps {
|
||||
@@ -35,19 +33,36 @@ export function AppShell({
|
||||
}: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const section = sectionForPath(pathname);
|
||||
const isSettings = section.id === 'reglages';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col bg-canvas text-ink-1">
|
||||
<header data-print="hide" className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
|
||||
{/*
|
||||
Barre d'application, collante.
|
||||
|
||||
Elle reste au-dessus quand la page défile : sur une grille de quatre-vingts
|
||||
lignes, revenir en haut pour changer de semaine ou d'établissement est le
|
||||
geste qu'on répète le plus.
|
||||
*/}
|
||||
<header
|
||||
data-print="hide"
|
||||
className="sticky top-0 z-20 flex h-14 flex-none items-center gap-6 border-b border-line-2 bg-surface px-6"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.015em]"
|
||||
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.02em] text-ink-1"
|
||||
>
|
||||
<span aria-hidden className="size-5 rounded-2 bg-accent" />
|
||||
{/* Deux barres inégales : une semaine planifiée, pas un carré de
|
||||
couleur. La marque du produit est sa grille. */}
|
||||
<span aria-hidden className="flex h-4 items-end gap-[3px]">
|
||||
<span className="h-4 w-[3px] rounded-full bg-accent" />
|
||||
<span className="h-2.5 w-[3px] rounded-full bg-accent/55" />
|
||||
<span className="h-3.5 w-[3px] rounded-full bg-accent/80" />
|
||||
</span>
|
||||
PlanFlow
|
||||
</Link>
|
||||
|
||||
<nav aria-label="Sections" className="flex h-full gap-0.5">
|
||||
<nav aria-label="Sections" className="flex h-full">
|
||||
{NAVIGATION.map((entry) => {
|
||||
const current = entry.id === section.id;
|
||||
return (
|
||||
@@ -56,10 +71,15 @@ export function AppShell({
|
||||
href={sectionHref(entry)}
|
||||
aria-current={current ? 'page' : undefined}
|
||||
className={cx(
|
||||
'flex h-full items-center border-b-2 px-3.5 text-sm font-medium whitespace-nowrap',
|
||||
'relative flex h-full items-center px-4 text-sm whitespace-nowrap',
|
||||
'transition-colors duration-[var(--d-2)] ease-organic',
|
||||
// Le filet actif est en bas de la barre, à l'aplomb de
|
||||
// l'onglet : c'est le même trait que celui qui marque la
|
||||
// section ouverte plus bas dans la page.
|
||||
'after:absolute after:inset-x-3 after:bottom-0 after:h-[2px] after:rounded-t-full',
|
||||
current
|
||||
? 'border-accent text-ink-1'
|
||||
: 'border-transparent text-ink-3 hover:text-ink-1',
|
||||
? 'font-semibold text-ink-1 after:bg-accent'
|
||||
: 'font-medium text-ink-3 after:bg-transparent hover:text-ink-1',
|
||||
)}
|
||||
>
|
||||
{entry.label}
|
||||
@@ -70,85 +90,80 @@ export function AppShell({
|
||||
|
||||
<span className="flex-1" />
|
||||
|
||||
<Button size="sm">
|
||||
Nantes Atlantis
|
||||
<span aria-hidden className="text-[10px] text-ink-3">
|
||||
▾
|
||||
</span>
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
|
||||
<span className="h-6 w-px bg-line-1" aria-hidden />
|
||||
|
||||
<span
|
||||
className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-ink"
|
||||
className="flex items-center gap-2.5"
|
||||
title={`${fullName} · ${roleName}`}
|
||||
>
|
||||
<span aria-hidden>{initials}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-8 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-ink"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<span className="flex flex-col leading-tight">
|
||||
<span className="text-xs font-medium text-ink-1">{fullName}</span>
|
||||
<span className="text-micro text-ink-3">{roleName}</span>
|
||||
</span>
|
||||
<span className="sr-only">{`${fullName}, ${roleName}`}</span>
|
||||
</span>
|
||||
|
||||
<form action={signOutAction}>
|
||||
<Button size="sm" type="submit">
|
||||
<Button size="sm" variant="ghost" type="submit">
|
||||
Déconnexion
|
||||
</Button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="flex w-57 flex-none flex-col gap-0.5 overflow-auto border-r border-line-1 bg-surface p-3 pt-4">
|
||||
<p className="px-2.5 pb-2.5 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
{section.label}
|
||||
</p>
|
||||
{section.items.map((item) => (
|
||||
<SidebarLink key={item.id} item={item} pathname={pathname} />
|
||||
))}
|
||||
<span className="flex-1" />
|
||||
<p className="border-t border-line-1 px-2.5 pt-3 text-micro leading-relaxed text-ink-3">
|
||||
Instance auto-hébergée
|
||||
<br />
|
||||
{accountName}
|
||||
</p>
|
||||
</aside>
|
||||
{/*
|
||||
Colonne centrale de **largeur fixe**, pas fluide.
|
||||
|
||||
<main className="min-w-0 flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
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}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,11 @@ export interface PageHeaderProps {
|
||||
|
||||
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.018em]">{title}</h1>
|
||||
<div className="flex flex-wrap items-end gap-4 border-b border-line-1 pb-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||
{subtitle ? (
|
||||
<p className="mt-1 text-sm text-ink-2">{subtitle}</p>
|
||||
<p className="mt-1.5 text-sm text-ink-2">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
@@ -22,7 +22,9 @@ export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||
}
|
||||
|
||||
export function PageBody({ children }: { children: ReactNode }) {
|
||||
// `gap-6` plutôt que `gap-5` : sur un écran de cartes empilées, l'ancien
|
||||
// écart laissait deux cartes se lire comme une seule.
|
||||
return (
|
||||
<div className="flex flex-col gap-5 px-6 pt-5 pb-16">{children}</div>
|
||||
<div className="flex flex-col gap-6 px-6 pt-6 pb-20">{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
isActive,
|
||||
placeholderHref,
|
||||
type NavSection,
|
||||
} from '@/components/shell/navigation';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
/**
|
||||
* Entrées de la section courante, en onglets.
|
||||
*
|
||||
* Elles vivaient dans une barre latérale. Deux raisons de l'avoir retirée :
|
||||
* elle rognait la largeur utile au moment précis où la grille de planning en
|
||||
* manque, et elle donnait à la navigation deux grammaires — des onglets sur la
|
||||
* fiche salarié, une liste verticale ailleurs. Une seule grammaire se retient ;
|
||||
* deux se cherchent.
|
||||
*
|
||||
* Une entrée sans `href` désigne un écran du périmètre qui n'est pas encore
|
||||
* construit. Elle reste affichée et mène à un écran d'attente qui le dit : la
|
||||
* masquer ferait croire le produit plus étroit qu'il n'est visé, et la laisser
|
||||
* inerte ferait croire la navigation cassée.
|
||||
*/
|
||||
export function SectionTabs({
|
||||
section,
|
||||
pathname,
|
||||
}: {
|
||||
section: NavSection;
|
||||
pathname: string;
|
||||
}) {
|
||||
if (section.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
// Les onglets **passent à la ligne**, ils ne défilent pas. Une barre de
|
||||
// défilement horizontale cache la moitié des entrées derrière un geste que
|
||||
// rien n'annonce : on ne sait pas ce qu'on ne voit pas. Deux rangées
|
||||
// prennent seize pixels de plus et montrent tout.
|
||||
<nav
|
||||
aria-label={`Section ${section.label}`}
|
||||
data-print="hide"
|
||||
className="flex flex-wrap gap-1 rounded-3 border border-line-1 bg-surface p-1.5"
|
||||
>
|
||||
{section.items.map((item) => {
|
||||
const current = item.href ? isActive(item.href, pathname) : false;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href ?? placeholderHref(item.label)}
|
||||
aria-current={current ? 'page' : undefined}
|
||||
className={cx(
|
||||
'flex shrink-0 items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium whitespace-nowrap',
|
||||
'transition-colors duration-[var(--d-1)] ease-organic',
|
||||
current
|
||||
? 'bg-surface-3 text-ink-1'
|
||||
: item.href
|
||||
? 'text-ink-2 hover:bg-surface-2 hover:text-ink-1'
|
||||
: 'text-ink-3 hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
{item.badge ? (
|
||||
<Badge subtle tone="neutral">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
isActive,
|
||||
placeholderHref,
|
||||
type NavSection,
|
||||
} from '@/components/shell/navigation';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
/**
|
||||
* Rail latéral des réglages — et **seulement** des réglages.
|
||||
*
|
||||
* Partout ailleurs, la section tient en une poignée d'entrées et se présente en
|
||||
* onglets, qui laissent toute la largeur au contenu. Les réglages en comptent
|
||||
* une vingtaine : en onglets, ils occuperaient trois rangées au-dessus de
|
||||
* chaque écran, et la liste changerait de forme d'une page à l'autre selon la
|
||||
* longueur des libellés.
|
||||
*
|
||||
* Une liste verticale les tient dans un ordre stable, groupés par domaine. Ce
|
||||
* n'est pas la même navigation qu'ailleurs parce que ce n'est pas le même
|
||||
* usage : on traverse les réglages, on ne fait pas d'aller-retour entre eux
|
||||
* pendant qu'on travaille.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regroupement des entrées, dans l'ordre d'un paramétrage.
|
||||
*
|
||||
* L'identité de l'entreprise, puis ce qui organise le travail, puis ce qui le
|
||||
* paie, puis ce qui l'encadre. Un client qui installe l'instance descend la
|
||||
* liste ; c'est cet ordre-là, pas l'alphabet.
|
||||
*/
|
||||
const GROUPS: ReadonlyArray<{ label: string; ids: readonly string[] }> = [
|
||||
{ label: 'Société', ids: ['compte', 'sites', 'convention'] },
|
||||
{ label: 'Organisation', ids: ['emplois', 'etiquettes', 'types-absence'] },
|
||||
{ label: 'Travail', ids: ['preferences', 'impression', 'productivite'] },
|
||||
{ label: 'Paie', ids: ['paie-reglages'] },
|
||||
{ label: 'Accès', ids: ['roles', 'securite', 'email'] },
|
||||
{
|
||||
label: 'Conformité',
|
||||
ids: ['registre', 'conservation', 'rgpd', 'modeles'],
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsRail({
|
||||
section,
|
||||
pathname,
|
||||
}: {
|
||||
section: NavSection;
|
||||
pathname: string;
|
||||
}) {
|
||||
const byId = new Map(section.items.map((item) => [item.id, item]));
|
||||
|
||||
// Une entrée ajoutée à la navigation sans être rangée dans un groupe reste
|
||||
// visible, en fin de liste : la perdre serait pire que de mal la classer.
|
||||
const grouped = new Set(GROUPS.flatMap((group) => group.ids));
|
||||
const rest = section.items.filter((item) => !grouped.has(item.id));
|
||||
|
||||
const groups = [
|
||||
...GROUPS.map((group) => ({
|
||||
label: group.label,
|
||||
items: group.ids
|
||||
.map((id) => byId.get(id))
|
||||
.filter((item) => item !== undefined),
|
||||
})),
|
||||
...(rest.length > 0 ? [{ label: 'Autres', items: rest }] : []),
|
||||
].filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Réglages"
|
||||
data-print="hide"
|
||||
className="flex w-56 flex-none flex-col gap-5 pt-6 pb-4"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className="flex flex-col gap-0.5">
|
||||
<p className="eyebrow px-2.5 pb-1.5">{group.label}</p>
|
||||
|
||||
{group.items.map((item) => {
|
||||
const current = item.href ? isActive(item.href, pathname) : false;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href ?? placeholderHref(item.label)}
|
||||
aria-current={current ? 'page' : undefined}
|
||||
className={cx(
|
||||
'rounded-2 border-l-2 px-2.5 py-1.5 text-sm',
|
||||
'transition-colors duration-[var(--d-2)] ease-organic',
|
||||
current
|
||||
? 'border-accent bg-accent-soft font-semibold text-accent-soft-ink'
|
||||
: item.href
|
||||
? 'border-transparent text-ink-2 hover:bg-surface-2 hover:text-ink-1'
|
||||
: 'border-transparent text-ink-3 hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -32,9 +32,29 @@ 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 : `/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' },
|
||||
// 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -45,6 +65,12 @@ 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" },
|
||||
],
|
||||
@@ -61,9 +87,19 @@ export const NAVIGATION: NavSection[] = [
|
||||
id: 'conges',
|
||||
label: 'Congés',
|
||||
items: [
|
||||
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' },
|
||||
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente' },
|
||||
{ id: 'politiques', label: 'Politiques de congés' },
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -74,14 +110,27 @@ 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é" },
|
||||
{
|
||||
id: 'activite',
|
||||
label: "Journal d'activité",
|
||||
href: '/rapports/activite',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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' },
|
||||
@@ -90,8 +139,24 @@ export const NAVIGATION: NavSection[] = [
|
||||
label: 'Durées de conservation',
|
||||
href: '/reglages/conservation',
|
||||
},
|
||||
{ id: 'convention', label: 'Convention collective' },
|
||||
{ id: 'postes', label: 'Postes et étiquettes' },
|
||||
{ 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: 'roles', label: 'Rôles et permissions', href: '/reglages/roles' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -6,19 +6,23 @@ export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
export type ButtonSize = 'sm' | 'md';
|
||||
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
// Le primaire porte une ombre courte : c'est le seul élément de l'interface
|
||||
// qui se soulève, et il n'y en a qu'un par écran.
|
||||
primary:
|
||||
'border-accent bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-press',
|
||||
'border-accent bg-accent text-accent-ink shadow-e1 hover:bg-accent-hover active:bg-accent-press active:shadow-none',
|
||||
secondary:
|
||||
'border-line-3 bg-surface text-ink-1 hover:bg-surface-2 active:bg-surface-3',
|
||||
'border-line-2 bg-surface text-ink-1 hover:border-line-3 hover:bg-surface-2 active:bg-surface-3',
|
||||
ghost:
|
||||
'border-transparent bg-transparent text-ink-2 hover:bg-surface-2 hover:text-ink-1',
|
||||
danger:
|
||||
'border-danger bg-danger text-danger-ink hover:bg-danger-hover active:bg-danger-hover',
|
||||
'border-danger bg-danger text-danger-ink shadow-e1 hover:bg-danger-hover active:bg-danger-hover active:shadow-none',
|
||||
};
|
||||
|
||||
/* Cibles plus hautes : 26 px était sous le seuil confortable au pointeur, et
|
||||
nettement sous les 44 px recommandés au doigt sur la version installable. */
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: 'h-[26px] px-2.5 text-xs',
|
||||
md: 'h-8 px-3.5 text-sm',
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
md: 'h-9 px-4 text-sm',
|
||||
};
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
|
||||
@@ -12,6 +12,7 @@ export function Card({ children, className }: CardProps) {
|
||||
<section
|
||||
className={cx(
|
||||
'overflow-hidden rounded-3 border border-line-1 bg-surface',
|
||||
'shadow-e1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -29,8 +30,13 @@ export interface CardHeaderProps {
|
||||
|
||||
export function CardHeader({ title, badge, action }: CardHeaderProps) {
|
||||
return (
|
||||
// Le filet de marge marque l'en-tête, comme la marge d'un registre marque
|
||||
// le début d'une écriture. Il remplace le fond gris qu'on met d'ordinaire
|
||||
// là : un aplat de plus sur un écran de tableaux ajoute du bruit, un trait
|
||||
// de 2 px suffit à dire « ici commence quelque chose ».
|
||||
<header className="flex items-center gap-2.5 border-b border-line-1 px-4 py-3">
|
||||
<h2 className="text-md font-semibold">{title}</h2>
|
||||
<span aria-hidden className="-ml-4 h-5 w-[2px] rounded-r-full bg-accent" />
|
||||
<h2 className="text-md font-semibold tracking-[-0.015em]">{title}</h2>
|
||||
{badge}
|
||||
<span className="flex-1" />
|
||||
{action}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Familles de couleur des types d'absence.
|
||||
*
|
||||
* Une famille, pas un type. Le client crée autant de types qu'il lui en faut —
|
||||
* une vingtaine dans un commerce de détail — et leur donner à chacun sa teinte
|
||||
* rendrait la grille illisible bien avant la fin de la liste. Le libellé porte
|
||||
* l'information exacte, la couleur ne fait que grouper.
|
||||
*
|
||||
* Liste fermée pour cette raison : ouvrir le champ à une couleur libre
|
||||
* reviendrait à laisser un utilisateur casser la lisibilité de la grille depuis
|
||||
* un écran de réglage.
|
||||
*/
|
||||
export const ABSENCE_COLORS = [
|
||||
{ key: 'cp', label: 'Congé payé' },
|
||||
{ key: 'rtt', label: 'RTT et repos' },
|
||||
{ key: 'maladie', label: 'Santé et accident' },
|
||||
{ key: 'famille', label: 'Famille' },
|
||||
{ key: 'formation', label: 'Formation' },
|
||||
{ key: 'ferie', label: 'Jour férié' },
|
||||
{ key: 'sanction', label: 'Sanction' },
|
||||
{ key: 'sans-solde', label: 'Sans solde et non justifiée' },
|
||||
{ key: 'attente', label: 'En attente de décision' },
|
||||
] as const;
|
||||
|
||||
export type AbsenceColorKey = (typeof ABSENCE_COLORS)[number]['key'];
|
||||
|
||||
export const ABSENCE_COLOR_KEYS: readonly string[] = ABSENCE_COLORS.map(
|
||||
(color) => color.key,
|
||||
);
|
||||
|
||||
export function isAbsenceColorKey(value: string): value is AbsenceColorKey {
|
||||
return ABSENCE_COLOR_KEYS.includes(value);
|
||||
}
|
||||
@@ -363,16 +363,21 @@ export const minBreakAfterThreshold: Rule = (context) => {
|
||||
(shift.endAt.getTime() - shift.startAt.getTime()) / MINUTE,
|
||||
);
|
||||
if (span <= thresholdMinutes) return [];
|
||||
if (shift.breakMinutes >= minBreakMinutes) return [];
|
||||
|
||||
// Rémunérée ou non, une pause est une pause. Ne compter que la part
|
||||
// déduite ferait alerter un créneau dont la pause de vingt minutes est
|
||||
// payée — c'est-à-dire précisément l'employeur le plus généreux.
|
||||
const restMinutes = shift.breakMinutes + shift.paidBreakMinutes;
|
||||
if (restMinutes >= minBreakMinutes) return [];
|
||||
|
||||
return [
|
||||
violation(
|
||||
'MIN_BREAK_AFTER_THRESHOLD',
|
||||
`${shift.breakMinutes} min de pause pour ${hours(span)} de présence : minimum ${minBreakMinutes} min au-delà de ${hours(thresholdMinutes)}.`,
|
||||
`${restMinutes} min de pause pour ${hours(span)} de présence : minimum ${minBreakMinutes} min au-delà de ${hours(thresholdMinutes)}.`,
|
||||
{
|
||||
localDate: zonedDate(shift.startAt, context.timeZone),
|
||||
context: {
|
||||
breakMinutes: shift.breakMinutes,
|
||||
breakMinutes: restMinutes,
|
||||
requiredMinutes: minBreakMinutes,
|
||||
spanMinutes: span,
|
||||
},
|
||||
|
||||
@@ -98,7 +98,16 @@ export interface ComplianceShift {
|
||||
id: string;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
/** Pauses non rémunérées, déduites du temps de travail. */
|
||||
breakMinutes: number;
|
||||
/**
|
||||
* Pauses rémunérées.
|
||||
*
|
||||
* Elles ne se déduisent pas du temps travaillé, mais restent du repos : la
|
||||
* règle de pause minimale les compte. Sans elles, un créneau où la pause de
|
||||
* vingt minutes est payée passerait pour un créneau sans pause.
|
||||
*/
|
||||
paidBreakMinutes: number;
|
||||
/** Faux pour un besoin non couvert : aucune règle de personne ne s'applique. */
|
||||
assigned: boolean;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,15 @@ 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 {
|
||||
@@ -31,6 +40,7 @@ export interface WeekCountersView extends WeekCounters {
|
||||
plannedLabel: string;
|
||||
absenceLabel: string;
|
||||
deltaLabel: string;
|
||||
compensatoryRestLabel: string;
|
||||
tone: DeltaTone;
|
||||
}
|
||||
|
||||
@@ -74,6 +84,7 @@ export function computeWeekCounters(input: WeekCounters): WeekCountersView {
|
||||
plannedLabel: formatMinutes(input.plannedMinutes),
|
||||
absenceLabel: formatMinutes(input.absenceMinutes),
|
||||
deltaLabel: formatDelta(deltaMinutes),
|
||||
compensatoryRestLabel: formatMinutes(input.compensatoryRestMinutes),
|
||||
tone: deltaTone(deltaMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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])),
|
||||
);
|
||||
}
|
||||
@@ -161,6 +161,43 @@ export function testMessage(to: string, accountName: string): Message {
|
||||
return { to, subject: 'Test d’envoi PlanFlow', text, html };
|
||||
}
|
||||
|
||||
export interface ShiftAssignedInput {
|
||||
firstName: string;
|
||||
/** Jour rédigé, « lundi 10 août ». */
|
||||
day: string;
|
||||
start: string;
|
||||
end: string;
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avis d'affectation d'un créneau.
|
||||
*
|
||||
* Le message dit le jour, l'horaire et l'équipe, et rien de plus : ni le motif,
|
||||
* ni le reste du planning. Un avis qui recopierait la semaine entière ferait
|
||||
* sortir de l'application des données que le destinataire n'a pas demandées, et
|
||||
* qu'un courriel transfère sans contrôle.
|
||||
*/
|
||||
export function shiftAssignedMessage(
|
||||
to: string,
|
||||
input: ShiftAssignedInput,
|
||||
): Message {
|
||||
const subject = `Nouveau créneau le ${input.day}`;
|
||||
const { text, html } = render({
|
||||
title: subject,
|
||||
intro: `Bonjour ${input.firstName},`,
|
||||
body: [
|
||||
`Un créneau vous a été ajouté le ${input.day}, de ${input.start} à ${input.end}.`,
|
||||
`Équipe : ${input.teamName}.`,
|
||||
'Votre planning à jour reste consultable dans PlanFlow.',
|
||||
],
|
||||
footer:
|
||||
'Avis automatique — en cas d’erreur, adressez-vous à votre responsable.',
|
||||
});
|
||||
|
||||
return { to, subject, text, html };
|
||||
}
|
||||
|
||||
/**
|
||||
* Message d'erreur d'envoi, débarrassé de tout secret.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Libellés d'état civil.
|
||||
*
|
||||
* Les codes vivent en base, les mots à l'écran. Les séparer évite qu'un
|
||||
* changement de formulation devienne une migration, et qu'une valeur stockée
|
||||
* dépende de la langue de l'interface.
|
||||
*/
|
||||
|
||||
export const GENDERS = [
|
||||
['FEMALE', 'Femme'],
|
||||
['MALE', 'Homme'],
|
||||
['UNSPECIFIED', 'Non précisé'],
|
||||
] as const;
|
||||
|
||||
export const MARITAL_STATUSES = [
|
||||
['SINGLE', 'Célibataire'],
|
||||
['MARRIED', 'Marié(e)'],
|
||||
['PACS', 'Pacsé(e)'],
|
||||
['COHABITING', 'Concubinage'],
|
||||
['DIVORCED', 'Divorcé(e)'],
|
||||
['WIDOWED', 'Veuf(ve)'],
|
||||
] as const;
|
||||
|
||||
export type GenderCode = (typeof GENDERS)[number][0];
|
||||
export type MaritalStatusCode = (typeof MARITAL_STATUSES)[number][0];
|
||||
|
||||
const GENDER_LABELS = new Map<string, string>(GENDERS);
|
||||
const MARITAL_LABELS = new Map<string, string>(MARITAL_STATUSES);
|
||||
|
||||
export function genderLabel(code: string | null): string | null {
|
||||
return code ? (GENDER_LABELS.get(code) ?? code) : null;
|
||||
}
|
||||
|
||||
export function maritalStatusLabel(code: string | null): string | null {
|
||||
return code ? (MARITAL_LABELS.get(code) ?? code) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abrégé porté au registre du personnel.
|
||||
*
|
||||
* La colonne y est étroite et la mention se lit d'un coup d'œil ; « Non
|
||||
* précisé » y devient un tiret, qui dit la même chose sans déborder.
|
||||
*/
|
||||
export function genderShort(code: string | null): string | null {
|
||||
if (code === 'FEMALE') return 'F';
|
||||
if (code === 'MALE') return 'M';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matricule proposé à l'embauche.
|
||||
*
|
||||
* Une lettre et un rang, et non un horodatage : un matricule se dicte au
|
||||
* téléphone et se recopie à la main. Le rang suit le dernier attribué de la
|
||||
* même forme, ce qui laisse coexister les matricules repris d'un autre outil.
|
||||
*/
|
||||
export function nextEmployeeNumber(
|
||||
existing: string[],
|
||||
prefix = 'E',
|
||||
): string {
|
||||
const pattern = new RegExp(`^${prefix}(\\d+)$`);
|
||||
const highest = existing.reduce((max, value) => {
|
||||
const match = pattern.exec(value.trim());
|
||||
if (!match) return max;
|
||||
return Math.max(max, Number(match[1]));
|
||||
}, 0);
|
||||
|
||||
return `${prefix}${String(highest + 1).padStart(4, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Pays et nationalités — pour le dossier salarié et le registre du personnel.
|
||||
*
|
||||
* Liste fermée plutôt que champ libre. « France », « FRANCE », « Française »,
|
||||
* « FR » et « france » désignaient la même chose et se retrouvaient toutes en
|
||||
* base ; le registre unique du personnel les imprime telles quelles, et un
|
||||
* contrôle y lit cinq pays.
|
||||
*
|
||||
* Le **code ISO 3166-1 alpha-2** est stocké, jamais le libellé : il ne change
|
||||
* pas d'orthographe, tient dans deux caractères, et c'est lui que reprennent
|
||||
* les déclarations sociales. Le libellé reste un affichage.
|
||||
*
|
||||
* La liste couvre les nationalités rencontrées dans un commerce de détail
|
||||
* français : l'Europe, le pourtour méditerranéen, l'Afrique francophone, et les
|
||||
* principaux pays d'origine relevés par l'INSEE. Elle n'est pas universelle et
|
||||
* ne prétend pas l'être — un pays absent s'ajoute ici, en une ligne, plutôt que
|
||||
* de rouvrir la saisie libre pour tout le monde.
|
||||
*/
|
||||
|
||||
export interface Country {
|
||||
/** ISO 3166-1 alpha-2. */
|
||||
code: string;
|
||||
name: string;
|
||||
/** Gentilé au féminin singulier, forme retenue au registre. */
|
||||
nationality: string;
|
||||
}
|
||||
|
||||
export const COUNTRIES: readonly Country[] = [
|
||||
{ code: 'FR', name: 'France', nationality: 'Française' },
|
||||
{ code: 'DE', name: 'Allemagne', nationality: 'Allemande' },
|
||||
{ code: 'AD', name: 'Andorre', nationality: 'Andorrane' },
|
||||
{ code: 'AO', name: 'Angola', nationality: 'Angolaise' },
|
||||
{ code: 'DZ', name: 'Algérie', nationality: 'Algérienne' },
|
||||
{ code: 'AR', name: 'Argentine', nationality: 'Argentine' },
|
||||
{ code: 'AM', name: 'Arménie', nationality: 'Arménienne' },
|
||||
{ code: 'AU', name: 'Australie', nationality: 'Australienne' },
|
||||
{ code: 'AT', name: 'Autriche', nationality: 'Autrichienne' },
|
||||
{ code: 'BE', name: 'Belgique', nationality: 'Belge' },
|
||||
{ code: 'BJ', name: 'Bénin', nationality: 'Béninoise' },
|
||||
{ code: 'BY', name: 'Biélorussie', nationality: 'Biélorusse' },
|
||||
{ code: 'BO', name: 'Bolivie', nationality: 'Bolivienne' },
|
||||
{ code: 'BA', name: 'Bosnie-Herzégovine', nationality: 'Bosnienne' },
|
||||
{ code: 'BR', name: 'Brésil', nationality: 'Brésilienne' },
|
||||
{ code: 'BG', name: 'Bulgarie', nationality: 'Bulgare' },
|
||||
{ code: 'BF', name: 'Burkina Faso', nationality: 'Burkinabè' },
|
||||
{ code: 'KH', name: 'Cambodge', nationality: 'Cambodgienne' },
|
||||
{ code: 'CM', name: 'Cameroun', nationality: 'Camerounaise' },
|
||||
{ code: 'CA', name: 'Canada', nationality: 'Canadienne' },
|
||||
{ code: 'CV', name: 'Cap-Vert', nationality: 'Cap-verdienne' },
|
||||
{ code: 'CL', name: 'Chili', nationality: 'Chilienne' },
|
||||
{ code: 'CN', name: 'Chine', nationality: 'Chinoise' },
|
||||
{ code: 'CY', name: 'Chypre', nationality: 'Chypriote' },
|
||||
{ code: 'CO', name: 'Colombie', nationality: 'Colombienne' },
|
||||
{ code: 'KM', name: 'Comores', nationality: 'Comorienne' },
|
||||
{ code: 'CG', name: 'Congo', nationality: 'Congolaise' },
|
||||
{ code: 'CD', name: 'Congo (RDC)', nationality: 'Congolaise (RDC)' },
|
||||
{ code: 'KR', name: 'Corée du Sud', nationality: 'Sud-coréenne' },
|
||||
{ code: 'CI', name: "Côte d'Ivoire", nationality: 'Ivoirienne' },
|
||||
{ code: 'HR', name: 'Croatie', nationality: 'Croate' },
|
||||
{ code: 'DK', name: 'Danemark', nationality: 'Danoise' },
|
||||
{ code: 'DJ', name: 'Djibouti', nationality: 'Djiboutienne' },
|
||||
{ code: 'EG', name: 'Égypte', nationality: 'Égyptienne' },
|
||||
{ code: 'AE', name: 'Émirats arabes unis', nationality: 'Émirienne' },
|
||||
{ code: 'EC', name: 'Équateur', nationality: 'Équatorienne' },
|
||||
{ code: 'ES', name: 'Espagne', nationality: 'Espagnole' },
|
||||
{ code: 'EE', name: 'Estonie', nationality: 'Estonienne' },
|
||||
{ code: 'US', name: 'États-Unis', nationality: 'Américaine' },
|
||||
{ code: 'ET', name: 'Éthiopie', nationality: 'Éthiopienne' },
|
||||
{ code: 'FI', name: 'Finlande', nationality: 'Finlandaise' },
|
||||
{ code: 'GA', name: 'Gabon', nationality: 'Gabonaise' },
|
||||
{ code: 'GE', name: 'Géorgie', nationality: 'Géorgienne' },
|
||||
{ code: 'GH', name: 'Ghana', nationality: 'Ghanéenne' },
|
||||
{ code: 'GR', name: 'Grèce', nationality: 'Grecque' },
|
||||
{ code: 'GN', name: 'Guinée', nationality: 'Guinéenne' },
|
||||
{ code: 'HT', name: 'Haïti', nationality: 'Haïtienne' },
|
||||
{ code: 'HU', name: 'Hongrie', nationality: 'Hongroise' },
|
||||
{ code: 'IN', name: 'Inde', nationality: 'Indienne' },
|
||||
{ code: 'ID', name: 'Indonésie', nationality: 'Indonésienne' },
|
||||
{ code: 'IQ', name: 'Irak', nationality: 'Irakienne' },
|
||||
{ code: 'IR', name: 'Iran', nationality: 'Iranienne' },
|
||||
{ code: 'IE', name: 'Irlande', nationality: 'Irlandaise' },
|
||||
{ code: 'IS', name: 'Islande', nationality: 'Islandaise' },
|
||||
{ code: 'IL', name: 'Israël', nationality: 'Israélienne' },
|
||||
{ code: 'IT', name: 'Italie', nationality: 'Italienne' },
|
||||
{ code: 'JP', name: 'Japon', nationality: 'Japonaise' },
|
||||
{ code: 'JO', name: 'Jordanie', nationality: 'Jordanienne' },
|
||||
{ code: 'KZ', name: 'Kazakhstan', nationality: 'Kazakhe' },
|
||||
{ code: 'KE', name: 'Kenya', nationality: 'Kényane' },
|
||||
{ code: 'XK', name: 'Kosovo', nationality: 'Kosovare' },
|
||||
{ code: 'LV', name: 'Lettonie', nationality: 'Lettone' },
|
||||
{ code: 'LB', name: 'Liban', nationality: 'Libanaise' },
|
||||
{ code: 'LY', name: 'Libye', nationality: 'Libyenne' },
|
||||
{ code: 'LT', name: 'Lituanie', nationality: 'Lituanienne' },
|
||||
{ code: 'LU', name: 'Luxembourg', nationality: 'Luxembourgeoise' },
|
||||
{ code: 'MK', name: 'Macédoine du Nord', nationality: 'Macédonienne' },
|
||||
{ code: 'MG', name: 'Madagascar', nationality: 'Malgache' },
|
||||
{ code: 'MY', name: 'Malaisie', nationality: 'Malaisienne' },
|
||||
{ code: 'ML', name: 'Mali', nationality: 'Malienne' },
|
||||
{ code: 'MT', name: 'Malte', nationality: 'Maltaise' },
|
||||
{ code: 'MA', name: 'Maroc', nationality: 'Marocaine' },
|
||||
{ code: 'MU', name: 'Maurice', nationality: 'Mauricienne' },
|
||||
{ code: 'MR', name: 'Mauritanie', nationality: 'Mauritanienne' },
|
||||
{ code: 'MX', name: 'Mexique', nationality: 'Mexicaine' },
|
||||
{ code: 'MD', name: 'Moldavie', nationality: 'Moldave' },
|
||||
{ code: 'MC', name: 'Monaco', nationality: 'Monégasque' },
|
||||
{ code: 'ME', name: 'Monténégro', nationality: 'Monténégrine' },
|
||||
{ code: 'MZ', name: 'Mozambique', nationality: 'Mozambicaine' },
|
||||
{ code: 'NE', name: 'Niger', nationality: 'Nigérienne' },
|
||||
{ code: 'NG', name: 'Nigeria', nationality: 'Nigériane' },
|
||||
{ code: 'NO', name: 'Norvège', nationality: 'Norvégienne' },
|
||||
{ code: 'NZ', name: 'Nouvelle-Zélande', nationality: 'Néo-zélandaise' },
|
||||
{ code: 'PK', name: 'Pakistan', nationality: 'Pakistanaise' },
|
||||
{ code: 'NL', name: 'Pays-Bas', nationality: 'Néerlandaise' },
|
||||
{ code: 'PE', name: 'Pérou', nationality: 'Péruvienne' },
|
||||
{ code: 'PH', name: 'Philippines', nationality: 'Philippine' },
|
||||
{ code: 'PL', name: 'Pologne', nationality: 'Polonaise' },
|
||||
{ code: 'PT', name: 'Portugal', nationality: 'Portugaise' },
|
||||
{ code: 'RO', name: 'Roumanie', nationality: 'Roumaine' },
|
||||
{ code: 'GB', name: 'Royaume-Uni', nationality: 'Britannique' },
|
||||
{ code: 'RU', name: 'Russie', nationality: 'Russe' },
|
||||
{ code: 'RW', name: 'Rwanda', nationality: 'Rwandaise' },
|
||||
{ code: 'SN', name: 'Sénégal', nationality: 'Sénégalaise' },
|
||||
{ code: 'RS', name: 'Serbie', nationality: 'Serbe' },
|
||||
{ code: 'SG', name: 'Singapour', nationality: 'Singapourienne' },
|
||||
{ code: 'SK', name: 'Slovaquie', nationality: 'Slovaque' },
|
||||
{ code: 'SI', name: 'Slovénie', nationality: 'Slovène' },
|
||||
{ code: 'SD', name: 'Soudan', nationality: 'Soudanaise' },
|
||||
{ code: 'LK', name: 'Sri Lanka', nationality: 'Sri-lankaise' },
|
||||
{ code: 'SE', name: 'Suède', nationality: 'Suédoise' },
|
||||
{ code: 'CH', name: 'Suisse', nationality: 'Suisse' },
|
||||
{ code: 'SY', name: 'Syrie', nationality: 'Syrienne' },
|
||||
{ code: 'TW', name: 'Taïwan', nationality: 'Taïwanaise' },
|
||||
{ code: 'TD', name: 'Tchad', nationality: 'Tchadienne' },
|
||||
{ code: 'CZ', name: 'Tchéquie', nationality: 'Tchèque' },
|
||||
{ code: 'TH', name: 'Thaïlande', nationality: 'Thaïlandaise' },
|
||||
{ code: 'TG', name: 'Togo', nationality: 'Togolaise' },
|
||||
{ code: 'TN', name: 'Tunisie', nationality: 'Tunisienne' },
|
||||
{ code: 'TR', name: 'Turquie', nationality: 'Turque' },
|
||||
{ code: 'UA', name: 'Ukraine', nationality: 'Ukrainienne' },
|
||||
{ code: 'UY', name: 'Uruguay', nationality: 'Uruguayenne' },
|
||||
{ code: 'VE', name: 'Venezuela', nationality: 'Vénézuélienne' },
|
||||
{ code: 'VN', name: 'Viêt Nam', nationality: 'Vietnamienne' },
|
||||
];
|
||||
|
||||
const BY_CODE = new Map(COUNTRIES.map((country) => [country.code, country]));
|
||||
|
||||
/**
|
||||
* Options du sélecteur de pays : la France en tête, le reste par ordre
|
||||
* alphabétique.
|
||||
*
|
||||
* Placer le pays le plus fréquent en premier évite un défilement à chaque
|
||||
* saisie. L'ordre alphabétique ensuite, parce qu'un classement par fréquence
|
||||
* supposée serait deviné, et se lirait comme un jugement.
|
||||
*/
|
||||
export const COUNTRY_OPTIONS: ReadonlyArray<readonly [string, string]> = [
|
||||
['FR', 'France'],
|
||||
...COUNTRIES.filter((country) => country.code !== 'FR')
|
||||
.map((country) => [country.code, country.name] as const)
|
||||
.sort((a, b) => a[1].localeCompare(b[1], 'fr')),
|
||||
];
|
||||
|
||||
/** Mêmes pays, mais nommés par leur gentilé. */
|
||||
export const NATIONALITY_OPTIONS: ReadonlyArray<readonly [string, string]> = [
|
||||
['FR', 'Française'],
|
||||
...COUNTRIES.filter((country) => country.code !== 'FR')
|
||||
.map((country) => [country.code, country.nationality] as const)
|
||||
.sort((a, b) => a[1].localeCompare(b[1], 'fr')),
|
||||
];
|
||||
|
||||
export function countryLabel(code: string | null): string | null {
|
||||
if (!code) return null;
|
||||
// Les dossiers saisis avant la liste portent un libellé en clair : le rendre
|
||||
// tel quel vaut mieux qu'afficher un vide sur une donnée qui existe.
|
||||
return BY_CODE.get(code)?.name ?? code;
|
||||
}
|
||||
|
||||
export function nationalityLabel(code: string | null): string | null {
|
||||
if (!code) return null;
|
||||
return BY_CODE.get(code)?.nationality ?? code;
|
||||
}
|
||||
|
||||
export function isCountryCode(value: string): boolean {
|
||||
return BY_CODE.has(value);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Départements français — pour le lieu de naissance et le rattachement d'un
|
||||
* établissement.
|
||||
*
|
||||
* Liste fermée plutôt que champ libre : « 54 », « Meurthe-et-Moselle »,
|
||||
* « Meurthe et Moselle » et « meurthe-et-moselle » désignent le même
|
||||
* département et se retrouvaient tous les quatre en base. Le registre unique du
|
||||
* personnel les imprime tels quels, et un contrôle y lit quatre départements.
|
||||
*
|
||||
* Le code est stocké, pas le libellé : il tient dans deux caractères, ne change
|
||||
* pas d'orthographe, et c'est lui que reprennent les déclarations sociales.
|
||||
*
|
||||
* La Corse porte `2A` et `2B` — jamais `20`, qui n'existe plus depuis 1976 mais
|
||||
* survit dans beaucoup de saisies manuelles.
|
||||
*/
|
||||
export const DEPARTMENTS: ReadonlyArray<readonly [string, string]> = [
|
||||
['01', 'Ain'],
|
||||
['02', 'Aisne'],
|
||||
['03', 'Allier'],
|
||||
['04', 'Alpes-de-Haute-Provence'],
|
||||
['05', 'Hautes-Alpes'],
|
||||
['06', 'Alpes-Maritimes'],
|
||||
['07', 'Ardèche'],
|
||||
['08', 'Ardennes'],
|
||||
['09', 'Ariège'],
|
||||
['10', 'Aube'],
|
||||
['11', 'Aude'],
|
||||
['12', 'Aveyron'],
|
||||
['13', 'Bouches-du-Rhône'],
|
||||
['14', 'Calvados'],
|
||||
['15', 'Cantal'],
|
||||
['16', 'Charente'],
|
||||
['17', 'Charente-Maritime'],
|
||||
['18', 'Cher'],
|
||||
['19', 'Corrèze'],
|
||||
['2A', 'Corse-du-Sud'],
|
||||
['2B', 'Haute-Corse'],
|
||||
['21', "Côte-d'Or"],
|
||||
['22', "Côtes-d'Armor"],
|
||||
['23', 'Creuse'],
|
||||
['24', 'Dordogne'],
|
||||
['25', 'Doubs'],
|
||||
['26', 'Drôme'],
|
||||
['27', 'Eure'],
|
||||
['28', 'Eure-et-Loir'],
|
||||
['29', 'Finistère'],
|
||||
['30', 'Gard'],
|
||||
['31', 'Haute-Garonne'],
|
||||
['32', 'Gers'],
|
||||
['33', 'Gironde'],
|
||||
['34', 'Hérault'],
|
||||
['35', 'Ille-et-Vilaine'],
|
||||
['36', 'Indre'],
|
||||
['37', 'Indre-et-Loire'],
|
||||
['38', 'Isère'],
|
||||
['39', 'Jura'],
|
||||
['40', 'Landes'],
|
||||
['41', 'Loir-et-Cher'],
|
||||
['42', 'Loire'],
|
||||
['43', 'Haute-Loire'],
|
||||
['44', 'Loire-Atlantique'],
|
||||
['45', 'Loiret'],
|
||||
['46', 'Lot'],
|
||||
['47', 'Lot-et-Garonne'],
|
||||
['48', 'Lozère'],
|
||||
['49', 'Maine-et-Loire'],
|
||||
['50', 'Manche'],
|
||||
['51', 'Marne'],
|
||||
['52', 'Haute-Marne'],
|
||||
['53', 'Mayenne'],
|
||||
['54', 'Meurthe-et-Moselle'],
|
||||
['55', 'Meuse'],
|
||||
['56', 'Morbihan'],
|
||||
['57', 'Moselle'],
|
||||
['58', 'Nièvre'],
|
||||
['59', 'Nord'],
|
||||
['60', 'Oise'],
|
||||
['61', 'Orne'],
|
||||
['62', 'Pas-de-Calais'],
|
||||
['63', 'Puy-de-Dôme'],
|
||||
['64', 'Pyrénées-Atlantiques'],
|
||||
['65', 'Hautes-Pyrénées'],
|
||||
['66', 'Pyrénées-Orientales'],
|
||||
['67', 'Bas-Rhin'],
|
||||
['68', 'Haut-Rhin'],
|
||||
['69', 'Rhône'],
|
||||
['70', 'Haute-Saône'],
|
||||
['71', 'Saône-et-Loire'],
|
||||
['72', 'Sarthe'],
|
||||
['73', 'Savoie'],
|
||||
['74', 'Haute-Savoie'],
|
||||
['75', 'Paris'],
|
||||
['76', 'Seine-Maritime'],
|
||||
['77', 'Seine-et-Marne'],
|
||||
['78', 'Yvelines'],
|
||||
['79', 'Deux-Sèvres'],
|
||||
['80', 'Somme'],
|
||||
['81', 'Tarn'],
|
||||
['82', 'Tarn-et-Garonne'],
|
||||
['83', 'Var'],
|
||||
['84', 'Vaucluse'],
|
||||
['85', 'Vendée'],
|
||||
['86', 'Vienne'],
|
||||
['87', 'Haute-Vienne'],
|
||||
['88', 'Vosges'],
|
||||
['89', 'Yonne'],
|
||||
['90', 'Territoire de Belfort'],
|
||||
['91', 'Essonne'],
|
||||
['92', 'Hauts-de-Seine'],
|
||||
['93', 'Seine-Saint-Denis'],
|
||||
['94', 'Val-de-Marne'],
|
||||
['95', "Val-d'Oise"],
|
||||
['971', 'Guadeloupe'],
|
||||
['972', 'Martinique'],
|
||||
['973', 'Guyane'],
|
||||
['974', 'La Réunion'],
|
||||
['976', 'Mayotte'],
|
||||
];
|
||||
|
||||
const LABELS = new Map(DEPARTMENTS);
|
||||
|
||||
export const DEPARTMENT_CODES: readonly string[] = DEPARTMENTS.map(
|
||||
([code]) => code,
|
||||
);
|
||||
|
||||
export function departmentLabel(code: string | null): string | null {
|
||||
if (!code) return null;
|
||||
const label = LABELS.get(code);
|
||||
return label ? `${code} — ${label}` : code;
|
||||
}
|
||||
|
||||
export function isDepartmentCode(value: string): boolean {
|
||||
return LABELS.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Départements du régime local d'Alsace-Moselle.
|
||||
*
|
||||
* Deux jours fériés de plus — Vendredi saint et 26 décembre — et un régime de
|
||||
* maintien de salaire distinct. Un établissement de Moselle ne se planifie donc
|
||||
* pas comme un établissement de Meurthe-et-Moselle, à quinze kilomètres de là.
|
||||
*/
|
||||
export const ALSACE_MOSELLE = ['57', '67', '68'] as const;
|
||||
|
||||
export function isAlsaceMoselle(code: string | null): boolean {
|
||||
return code !== null && (ALSACE_MOSELLE as readonly string[]).includes(code);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Registre unique du personnel — articles L1221-13 et D1221-23 du code du
|
||||
* travail.
|
||||
*
|
||||
* Tenu par établissement et non par entreprise : c'est l'inspection d'un
|
||||
* établissement donné qui le réclame, et un registre global ne répondrait pas
|
||||
* à la demande.
|
||||
*
|
||||
* Les mentions sont fixées par le texte. Elles sont énumérées ici plutôt que
|
||||
* dispersées dans le rendu, pour qu'une relecture juridique porte sur un seul
|
||||
* endroit.
|
||||
*/
|
||||
|
||||
export interface RupPerson {
|
||||
lastName: string;
|
||||
firstName: string;
|
||||
/** Mention exigée. Non collectée par PlanFlow à ce jour. */
|
||||
sex: string | null;
|
||||
nationality: string | null;
|
||||
birthDate: Date | null;
|
||||
/** Emploi tenu, tel que porté par le contrat. */
|
||||
jobTitle: string | null;
|
||||
qualification: string | null;
|
||||
contractLabel: string | null;
|
||||
entryDate: Date | null;
|
||||
exitDate: Date | null;
|
||||
}
|
||||
|
||||
export interface RupColumn {
|
||||
key: keyof RupPerson;
|
||||
label: string;
|
||||
/** Part de la largeur utile. La somme vaut 1. */
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'ordre suit celui du texte réglementaire : identité, puis situation, puis
|
||||
* dates. Un registre réordonné se relit mal en regard de l'article.
|
||||
*/
|
||||
export const RUP_COLUMNS: RupColumn[] = [
|
||||
{ key: 'lastName', label: 'Nom', width: 0.13 },
|
||||
{ key: 'firstName', label: 'Prénom', width: 0.11 },
|
||||
{ key: 'sex', label: 'Sexe', width: 0.05 },
|
||||
{ key: 'nationality', label: 'Nationalité', width: 0.1 },
|
||||
{ key: 'birthDate', label: 'Date de naissance', width: 0.1 },
|
||||
{ key: 'jobTitle', label: 'Emploi', width: 0.14 },
|
||||
{ key: 'qualification', label: 'Qualification', width: 0.11 },
|
||||
{ key: 'contractLabel', label: 'Contrat', width: 0.08 },
|
||||
{ key: 'entryDate', label: 'Entrée', width: 0.09 },
|
||||
{ key: 'exitDate', label: 'Sortie', width: 0.09 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Mentions obligatoires manquantes pour une personne.
|
||||
*
|
||||
* La date de sortie est exclue : son absence signifie que le salarié est
|
||||
* toujours présent, ce qui est une information et non un oubli.
|
||||
*/
|
||||
export function missingMentions(person: RupPerson): string[] {
|
||||
return RUP_COLUMNS.filter((column) => {
|
||||
if (column.key === 'exitDate') return false;
|
||||
const value = person[column.key];
|
||||
return value === null || value === '';
|
||||
}).map((column) => column.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Une contravention par salarié concerné, et non une par registre : c'est ce
|
||||
* qui justifie de compter les personnes plutôt que les champs.
|
||||
*/
|
||||
export function peopleWithGaps(people: RupPerson[]): number {
|
||||
return people.filter((person) => missingMentions(person).length > 0).length;
|
||||
}
|
||||
@@ -12,13 +12,26 @@ import { shiftMinutes } from '@/domain/counters/week';
|
||||
import { zonedClock, zonedDate } from '@/domain/planning/week';
|
||||
import type { PosteCode } from '@/lib/design/postes';
|
||||
|
||||
/** Pause telle qu'affichée : le détail suffit à rouvrir le formulaire. */
|
||||
export interface ShiftBreakView {
|
||||
startMinutes: number | null;
|
||||
durationMinutes: number;
|
||||
isPaid: boolean;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface BoardShiftInput {
|
||||
id: string;
|
||||
membershipId: string | null;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
breakMinutes: number;
|
||||
paidBreakMinutes: number;
|
||||
breaks: ShiftBreakView[];
|
||||
mealCount: number;
|
||||
poste: PosteCode;
|
||||
/** Étiquette telle qu'enregistrée, pour rouvrir le créneau sur la sienne. */
|
||||
labelId: string | null;
|
||||
isValidated: boolean;
|
||||
note: string | null;
|
||||
}
|
||||
@@ -29,7 +42,13 @@ export interface BoardShift {
|
||||
/** « 09:00–17:00 », en heure locale de l'établissement. */
|
||||
time: string;
|
||||
minutes: number;
|
||||
/** Pauses non rémunérées, déduites du temps travaillé. */
|
||||
breakMinutes: number;
|
||||
/** Pauses rémunérées : comptées comme repos, pas déduites. */
|
||||
paidBreakMinutes: number;
|
||||
breaks: ShiftBreakView[];
|
||||
mealCount: number;
|
||||
labelId: string | null;
|
||||
state: ShiftState;
|
||||
note: string | null;
|
||||
}
|
||||
@@ -76,6 +95,19 @@ 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.
|
||||
*
|
||||
@@ -91,9 +123,19 @@ 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[]);
|
||||
|
||||
@@ -116,6 +158,10 @@ export function buildRows(
|
||||
time: `${zonedClock(shift.startAt, timeZone)}–${zonedClock(shift.endAt, timeZone)}`,
|
||||
minutes,
|
||||
breakMinutes: shift.breakMinutes,
|
||||
paidBreakMinutes: shift.paidBreakMinutes,
|
||||
breaks: shift.breaks,
|
||||
mealCount: shift.mealCount,
|
||||
labelId: shift.labelId,
|
||||
state: shiftState(shift.isValidated, isPublished, shift.membershipId),
|
||||
note: shift.note,
|
||||
};
|
||||
@@ -199,6 +245,8 @@ export function buildRows(
|
||||
),
|
||||
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
|
||||
restDays: 7 - worked,
|
||||
compensatoryRestMinutes:
|
||||
compensatoryByMember.get(person.membershipId) ?? 0,
|
||||
},
|
||||
} satisfies BoardRow;
|
||||
});
|
||||
@@ -221,6 +269,7 @@ export function buildRows(
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 0,
|
||||
restDays: 0,
|
||||
compensatoryRestMinutes: 0,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user