Compare commits
30
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 | ||
|
|
b49ceb1f29 | ||
|
|
1d64989239 | ||
|
|
d4b655f369 | ||
|
|
b184109e23 |
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
|
||||
|
||||
|
||||
+6
-2
@@ -71,7 +71,11 @@ RUN chmod +x ./docker/entrypoint.sh
|
||||
RUN mkdir -p /data/documents /secrets && chown -R nextjs:nodejs /data /secrets
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000 HOSTNAME=0.0.0.0
|
||||
# Port peu courant jusque **dans** l'image : le reverse-proxy porte alors la
|
||||
# même valeur partout, et rien ne rappelle un 3000 par défaut. Surchargeable
|
||||
# par la pile, qui aligne publication, sonde de santé et serveur sur une
|
||||
# unique variable.
|
||||
EXPOSE 9317
|
||||
ENV PORT=9317 HOSTNAME=0.0.0.0
|
||||
|
||||
CMD ["./docker/entrypoint.sh"]
|
||||
@@ -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.
|
||||
|
||||
+28
-12
@@ -12,6 +12,11 @@ services:
|
||||
# du même réseau. Le changer reste recommandé.
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-planflow}
|
||||
# Port d'écoute **dans** le conteneur. Un port non commun ne protège de
|
||||
# rien — sur un réseau Docker, chaque conteneur a son adresse et deux
|
||||
# services peuvent écouter le même port sans se gêner — mais il lève
|
||||
# l'ambiguïté quand plusieurs bases cohabitent derrière le même proxy.
|
||||
PGPORT: ${POSTGRES_PORT_INTERNAL:-5439}
|
||||
# Transmis au script d'initialisation, qui crée le rôle de connexion de
|
||||
# l'application — celui-ci ne doit surtout pas être le superutilisateur.
|
||||
APP_DB_USER: ${APP_DB_USER:-planflow_app}
|
||||
@@ -53,13 +58,17 @@ services:
|
||||
sleep 1
|
||||
done
|
||||
) &
|
||||
exec docker-entrypoint.sh postgres
|
||||
exec docker-entrypoint.sh postgres -p "$$PGPORT"
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
# Crée le rôle applicatif à la première initialisation du volume.
|
||||
- ./docker/init-app-role.sh:/docker-entrypoint-initdb.d/10-init-app-role.sh:ro
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}']
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'pg_isready -p ${POSTGRES_PORT_INTERNAL:-5439} -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}',
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -78,10 +87,9 @@ services:
|
||||
networks:
|
||||
- interne
|
||||
- nginx_default
|
||||
# Le conteneur reste sur 5432 pour l'application, et le port hôte est publié
|
||||
# sur un port non commun, configurable : plusieurs instances PostgreSQL
|
||||
# coexistent sur le même serveur, et s'en tenir au 5432 par défaut les
|
||||
# ferait entrer en collision.
|
||||
# Ni le port du conteneur ni celui de l'hôte ne sont les ports usuels :
|
||||
# plusieurs instances PostgreSQL coexistent sur un même serveur, et s'en
|
||||
# tenir au 5432 partout invite la confusion.
|
||||
#
|
||||
# **Lié à la boucle locale**, et c'est important : la publication sert à se
|
||||
# connecter depuis le serveur (sauvegarde, psql), pas depuis le réseau. Sans
|
||||
@@ -89,7 +97,7 @@ services:
|
||||
# données RH derrière un mot de passe par défaut, joignable depuis
|
||||
# l'extérieur. Pour un accès distant, passer par un tunnel.
|
||||
ports:
|
||||
- '${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-55432}:5432'
|
||||
- '${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-55432}:${POSTGRES_PORT_INTERNAL:-5439}'
|
||||
|
||||
# Pose le rôle applicatif à **chaque** démarrage de la pile.
|
||||
#
|
||||
@@ -110,6 +118,7 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGPORT: ${POSTGRES_PORT_INTERNAL:-5439}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-planflow}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-planflow}
|
||||
@@ -134,10 +143,14 @@ services:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
# Port d'écoute du serveur **dans** le conteneur. C'est celui que vise
|
||||
# le reverse-proxy ; il doit rester le même que celui du healthcheck et
|
||||
# de la publication, d'où l'unique variable.
|
||||
PORT: ${APP_PORT_INTERNAL:-9317}
|
||||
# Le compte applicatif, **pas** le superutilisateur d'amorçage : un
|
||||
# superutilisateur contourne la row-level security, y compris déclarée en
|
||||
# FORCE, et l'isolation ne reposerait plus que sur la couche applicative.
|
||||
DATABASE_URL: postgresql://${APP_DB_USER:-planflow_app}:${APP_DB_PASSWORD:-planflow-app-interne}@db:5432/${POSTGRES_DB:-planflow}
|
||||
DATABASE_URL: postgresql://${APP_DB_USER:-planflow_app}:${APP_DB_PASSWORD:-planflow-app-interne}@db:${POSTGRES_PORT_INTERNAL:-5439}/${POSTGRES_DB:-planflow}
|
||||
# Laissée vide, elle est **produite au premier démarrage** et conservée
|
||||
# dans le volume `secrets`. La renseigner ici reste possible pour un
|
||||
# déploiement qui gère ses secrets par ailleurs — mais une variable
|
||||
@@ -158,6 +171,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-planflow}
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_PORT_INTERNAL: ${POSTGRES_PORT_INTERNAL:-5439}
|
||||
APP_DB_USER: ${APP_DB_USER:-planflow_app}
|
||||
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne}
|
||||
volumes:
|
||||
@@ -167,21 +181,23 @@ services:
|
||||
- secrets:/secrets
|
||||
networks:
|
||||
# `interne` pour joindre la base, `nginx_default` pour être joignable par
|
||||
# le reverse-proxy — qui atteint le conteneur sur son port 3000, sans
|
||||
# le reverse-proxy — qui atteint le conteneur sur son port interne, sans
|
||||
# passer par le port publié.
|
||||
- interne
|
||||
- nginx_default
|
||||
ports:
|
||||
# Port peu courant : le service est censé passer par le reverse-proxy, et
|
||||
# un 3000 publié sur l'hôte se heurte à tout ce qui traîne.
|
||||
- '${APP_PORT:-9317}:3000'
|
||||
# un 3000 publié sur l'hôte se heurte à tout ce qui traîne. Le port
|
||||
# **interne** l'est aussi, pour que la configuration du reverse-proxy
|
||||
# porte partout la même valeur.
|
||||
- '${APP_PORT:-9317}:${APP_PORT_INTERNAL:-9317}'
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'node',
|
||||
'-e',
|
||||
"fetch('http://127.0.0.1:3000/api/sante').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
"fetch('http://127.0.0.1:${APP_PORT_INTERNAL:-9317}/api/sante').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
|
||||
@@ -22,7 +22,10 @@ const superUser = process.env.POSTGRES_USER ?? 'planflow';
|
||||
const superPassword = process.env.POSTGRES_PASSWORD ?? '';
|
||||
const database = process.env.POSTGRES_DB ?? 'planflow';
|
||||
const host = process.env.POSTGRES_HOST ?? 'db';
|
||||
const port = Number(process.env.POSTGRES_PORT_INTERNAL ?? 5432);
|
||||
// Le port d'écoute **dans** le conteneur de la base, que la pile choisit peu
|
||||
// commun. Le défaut suit celui du compose, pas celui de PostgreSQL : une
|
||||
// valeur qui diverge se solderait par un refus de connexion à l'amorçage.
|
||||
const port = Number(process.env.POSTGRES_PORT_INTERNAL ?? 5439);
|
||||
|
||||
const appRole = process.env.APP_DB_USER ?? 'planflow_app';
|
||||
const appPassword = process.env.APP_DB_PASSWORD ?? 'planflow-app-interne';
|
||||
|
||||
@@ -28,7 +28,9 @@ DB_USER="${POSTGRES_USER:-planflow}"
|
||||
# Dans le conteneur `db`, l'hôte est local ; depuis `db-init`, c'est `db`.
|
||||
# L'un et l'autre doivent aboutir au même SQL.
|
||||
if [ -n "${PGHOST:-}" ]; then
|
||||
set -- -h "$PGHOST" -p "${PGPORT:-5432}" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1
|
||||
# Défaut aligné sur celui de la pile, et non sur celui de PostgreSQL : la
|
||||
# base écoute un port peu commun, et 5432 ne joindrait rien.
|
||||
set -- -h "$PGHOST" -p "${PGPORT:-5439}" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1
|
||||
else
|
||||
set -- -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1
|
||||
fi
|
||||
|
||||
+2
-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"
|
||||
|
||||
@@ -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.
|
||||
@@ -819,7 +858,18 @@ async function seedPlanning(accountId: string): Promise<void> {
|
||||
isoWeek: week.isoWeek,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
// L'état de publication est **réimposé**, pas seulement posé à la
|
||||
// création. Un seed qui laisse en place ce qu'il trouve ne produit un
|
||||
// état de départ connu que la première fois : la semaine précédente,
|
||||
// publiée par définition, reste en brouillon dès lors qu'elle a déjà
|
||||
// été semée quand elle était la semaine courante. Les tests qui
|
||||
// s'appuient dessus échouent alors sur une base de développement et
|
||||
// passent en intégration continue, où le semis est neuf — l'écart le
|
||||
// plus coûteux à diagnostiquer.
|
||||
update: {
|
||||
status: week === previous ? 'PUBLISHED' : 'DRAFT',
|
||||
publishedAt: week === previous ? new Date() : null,
|
||||
},
|
||||
create: {
|
||||
accountId,
|
||||
teamId: team.id,
|
||||
@@ -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('/');
|
||||
}
|
||||
@@ -4,23 +4,28 @@ import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { ContractLocation } from '@/server/employees/queries';
|
||||
import type {
|
||||
ContractLocation,
|
||||
HiringOptions,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
/**
|
||||
* Ajout d'un collaborateur, en modale.
|
||||
* Embauche, en panneau latéral.
|
||||
*
|
||||
* Le formulaire d'embauche compte une quinzaine de champs : posé au bas de la
|
||||
* liste, il occupait plus de place que l'effectif qu'on vient consulter. Il ne
|
||||
* s'ouvre donc qu'à la demande.
|
||||
* 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 : il apporte le piège de focus,
|
||||
* la fermeture par Échap et le fond inerte, trois choses qu'une `<div>` doit
|
||||
* réimplémenter et rate presque toujours.
|
||||
* `<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);
|
||||
@@ -40,29 +45,32 @@ export function AddEmployeeDialog({
|
||||
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-labelledby="ajout-collaborateur"
|
||||
aria-labelledby="titre-embauche"
|
||||
onClose={() => setOpen(false)}
|
||||
className="m-auto w-[min(46rem,calc(100vw-2rem))] rounded-3 border border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]"
|
||||
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)]"
|
||||
>
|
||||
<header className="flex items-center gap-3 border-b border-line-1 px-5 py-4">
|
||||
<h2 id="ajout-collaborateur" className="text-md font-semibold">
|
||||
Ajouter un collaborateur
|
||||
</h2>
|
||||
<span className="flex-1" />
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Fermer
|
||||
</Button>
|
||||
</header>
|
||||
<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="max-h-[70vh] overflow-y-auto p-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}
|
||||
onSaved={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
<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>
|
||||
</>
|
||||
|
||||
@@ -2,48 +2,83 @@
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import {
|
||||
createEmployeeAction,
|
||||
type ActionState,
|
||||
} from '@/server/employees/actions';
|
||||
import type { ContractLocation } from '@/server/employees/queries';
|
||||
import type {
|
||||
ContractLocation,
|
||||
HiringOptions,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
/**
|
||||
* Embauche.
|
||||
*
|
||||
* 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. Le contrat est donc
|
||||
* proposé d'emblée — et reste décochable, parce qu'un remplaçant se saisit
|
||||
* parfois avant que son établissement soit tranché.
|
||||
* 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 = [
|
||||
['CDI', 'CDI'],
|
||||
['CDD', 'CDD'],
|
||||
['APPRENTISSAGE', 'Apprentissage'],
|
||||
['STAGIAIRE', 'Stagiaire'],
|
||||
['SAISONNIER', 'Saisonnier'],
|
||||
['EXTRA', 'Extra'],
|
||||
['INTERIM', 'Intérim'],
|
||||
['CDD', 'CDD'],
|
||||
['CDI', 'CDI'],
|
||||
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
|
||||
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
|
||||
['EXTRA', 'Extra'],
|
||||
['INTERIM', 'Intérim'],
|
||||
['SAISONNIER', 'Saisonnier'],
|
||||
['STAGIAIRE', 'Stagiaire'],
|
||||
] as const;
|
||||
|
||||
/** Marque d'obligation, annoncée aussi aux lecteurs d'écran. */
|
||||
function Required() {
|
||||
return (
|
||||
<span className="text-danger">
|
||||
<span aria-hidden>*</span>
|
||||
<span className="sr-only"> (requis)</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
'h-9 min-w-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
|
||||
|
||||
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[];
|
||||
/** Appelé une fois l'ajout accepté — la modale s'y referme. */
|
||||
options: HiringOptions;
|
||||
/** Appelé une fois l'ajout accepté — le panneau s'y referme. */
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const [state, formAction] = useActionState<ActionState, FormData>(
|
||||
@@ -53,10 +88,11 @@ export function AddEmployeeForm({
|
||||
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 la modale à sa réouverture.
|
||||
// refermerait le panneau à sa réouverture.
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
onSaved?.();
|
||||
@@ -69,26 +105,59 @@ export function AddEmployeeForm({
|
||||
<PersistentForm
|
||||
action={formAction}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-4"
|
||||
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 />
|
||||
|
||||
<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" />
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Adresse électronique"
|
||||
name="email"
|
||||
type="email"
|
||||
hint="Facultative. Sans adresse, le salarié reste planifiable et déclarable ; il n’aura simplement pas d’accès à l’application."
|
||||
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" />
|
||||
</div>
|
||||
|
||||
<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"
|
||||
@@ -101,63 +170,49 @@ export function AddEmployeeForm({
|
||||
</label>
|
||||
|
||||
{withContract ? (
|
||||
<div className="flex flex-col gap-3 rounded-3 border border-line-1 bg-surface-2 p-4">
|
||||
<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"
|
||||
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>
|
||||
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Équipe</span>
|
||||
<select name="teamId" className={selectClass}>
|
||||
<option value="">Aucune pour l’instant</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.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" className={selectClass}>
|
||||
{CONTRACT_TYPES.map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Début du contrat"
|
||||
label="Date de début de contrat"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required={withContract}
|
||||
required
|
||||
defaultValue={today()}
|
||||
/>
|
||||
<Field
|
||||
label="Fin du contrat"
|
||||
name="endDate"
|
||||
type="date"
|
||||
hint="Vide pour un contrat sans terme."
|
||||
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">
|
||||
@@ -187,7 +242,7 @@ export function AddEmployeeForm({
|
||||
/>
|
||||
) : (
|
||||
<Field
|
||||
label="Durée hebdomadaire"
|
||||
label="Temps de travail hebdomadaire"
|
||||
name="weeklyHours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
@@ -214,28 +269,104 @@ export function AddEmployeeForm({
|
||||
/>
|
||||
</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}
|
||||
{withContract ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setWithContract(true)}
|
||||
disabled={locations.length === 0}
|
||||
>
|
||||
Ajouter un contrat
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,19 @@ 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,
|
||||
@@ -21,14 +34,22 @@ import {
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -46,6 +67,42 @@ 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`);
|
||||
@@ -89,21 +146,64 @@ export function PersonalInfoPanel({
|
||||
<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="Lieu de naissance"
|
||||
label="Commune de naissance"
|
||||
name="birthPlace"
|
||||
defaultValue={profile.birthPlace}
|
||||
hint="Commune, et département pour la France."
|
||||
/>
|
||||
<Field label="Nationalité" name="nationality" defaultValue={profile.nationality} />
|
||||
<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>
|
||||
|
||||
@@ -116,12 +216,23 @@ export function PersonalInfoPanel({
|
||||
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>
|
||||
<Field label="Pays" name="country" defaultValue={profile.country} />
|
||||
<Select
|
||||
label="Pays"
|
||||
name="country"
|
||||
defaultValue={profile.country}
|
||||
options={COUNTRY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
@@ -158,20 +269,38 @@ export function PersonalInfoPanel({
|
||||
<>
|
||||
<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="Lieu de naissance" value={profile.birthPlace} />
|
||||
<InfoRow label="Nationalité" value={profile.nationality} />
|
||||
<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={profile.country} />
|
||||
<InfoRow label="Pays" value={countryLabel(profile.country || null)} />
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Contact d’urgence">
|
||||
|
||||
@@ -33,16 +33,27 @@ export default async function PersonalTab({
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
listContractLocations,
|
||||
listEmployees,
|
||||
listHiringOptions,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
export const metadata = { title: 'Équipe · PlanFlow' };
|
||||
@@ -56,6 +57,11 @@ export default async function EquipePage({
|
||||
// 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 (
|
||||
@@ -72,7 +78,10 @@ export default async function EquipePage({
|
||||
{registerLocations.length > 0 ? (
|
||||
<RegisterDialog locations={registerLocations} />
|
||||
) : null}
|
||||
<AddEmployeeDialog locations={hiringLocations} />
|
||||
<AddEmployeeDialog
|
||||
locations={hiringLocations}
|
||||
options={hiringOptions}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Pauses d'un créneau — PLAN.md §4.4.
|
||||
*
|
||||
* Un créneau porte une **liste** de pauses, pas un total. Deux pauses de vingt
|
||||
* minutes et une coupure de deux heures ne se planifient pas de la même façon
|
||||
* et ne se contrôlent pas pareil : la première satisfait la pause minimale au
|
||||
* delà de six heures, la seconde interroge l'amplitude de la journée.
|
||||
*
|
||||
* Le créneau conserve deux totaux dérivés, et c'est délibéré :
|
||||
*
|
||||
* - `breakMinutes` — les pauses **non rémunérées**, déduites du temps de
|
||||
* travail. C'est ce que lisent la paie, les compteurs et l'export, sans
|
||||
* changer d'un caractère.
|
||||
* - `paidBreakMinutes` — les pauses **rémunérées**. Non déduites, mais bien des
|
||||
* pauses : la règle de pause minimale regarde la somme des deux, sans quoi une
|
||||
* pause payée passerait pour une absence de pause et alerterait à tort.
|
||||
*
|
||||
* Les totaux ne sont pas une optimisation : ils sont la forme sous laquelle le
|
||||
* reste du produit lit déjà l'information. Les recalculer à chaque écriture
|
||||
* coûte moins qu'un second chemin de lecture qui finirait par diverger.
|
||||
*/
|
||||
|
||||
export interface BreakInput {
|
||||
/** Minutes depuis le début du créneau. `null` = pause non située. */
|
||||
startMinutes: number | null;
|
||||
durationMinutes: number;
|
||||
isPaid: boolean;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface BreakTotals {
|
||||
/** Déduit du temps de travail. */
|
||||
breakMinutes: number;
|
||||
/** Compté comme repos, pas déduit. */
|
||||
paidBreakMinutes: number;
|
||||
}
|
||||
|
||||
export class BreakError extends Error {}
|
||||
|
||||
const MAX_BREAKS = 6;
|
||||
const MAX_DURATION = 600;
|
||||
|
||||
/**
|
||||
* Valide et ordonne une liste de pauses.
|
||||
*
|
||||
* Refuse plutôt que de corriger. Une pause de durée nulle vient d'une ligne
|
||||
* ajoutée puis laissée vide ; l'écarter en silence donnerait un enregistrement
|
||||
* réussi et une pause disparue, ce qui est le pire des deux mondes — sauf pour
|
||||
* une ligne **entièrement** vide, qui n'est pas une saisie mais un formulaire
|
||||
* en cours.
|
||||
*/
|
||||
export function normaliseBreaks(
|
||||
raw: ReadonlyArray<Partial<BreakInput>>,
|
||||
shiftMinutes: number,
|
||||
): BreakInput[] {
|
||||
const entered = raw.filter(
|
||||
(entry) =>
|
||||
entry.durationMinutes !== undefined &&
|
||||
entry.durationMinutes !== null &&
|
||||
!Number.isNaN(entry.durationMinutes),
|
||||
);
|
||||
|
||||
if (entered.length > MAX_BREAKS) {
|
||||
throw new BreakError(
|
||||
`Six pauses au plus par créneau ; ${entered.length} ont été saisies.`,
|
||||
);
|
||||
}
|
||||
|
||||
const breaks = entered.map((entry, index) => {
|
||||
const duration = Number(entry.durationMinutes);
|
||||
|
||||
if (!Number.isInteger(duration) || duration <= 0) {
|
||||
throw new BreakError(
|
||||
`Pause ${index + 1} : la durée doit être un nombre de minutes positif.`,
|
||||
);
|
||||
}
|
||||
if (duration > MAX_DURATION) {
|
||||
throw new BreakError(
|
||||
`Pause ${index + 1} : ${duration} min dépasse le maximum de ${MAX_DURATION} min.`,
|
||||
);
|
||||
}
|
||||
|
||||
const start =
|
||||
entry.startMinutes === null || entry.startMinutes === undefined
|
||||
? null
|
||||
: Number(entry.startMinutes);
|
||||
|
||||
if (start !== null) {
|
||||
if (!Number.isInteger(start) || start < 0) {
|
||||
throw new BreakError(
|
||||
`Pause ${index + 1} : le début doit être un nombre de minutes positif.`,
|
||||
);
|
||||
}
|
||||
// Une pause qui déborde du créneau n'est pas une pause : c'est une
|
||||
// saisie fausse, et la laisser passer fausserait l'amplitude.
|
||||
if (start + duration > shiftMinutes) {
|
||||
throw new BreakError(
|
||||
`Pause ${index + 1} : elle sort du créneau.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startMinutes: start,
|
||||
durationMinutes: duration,
|
||||
isPaid: Boolean(entry.isPaid),
|
||||
label: entry.label?.trim() ? entry.label.trim() : null,
|
||||
} satisfies BreakInput;
|
||||
});
|
||||
|
||||
assertNoOverlap(breaks);
|
||||
|
||||
// Les pauses situées d'abord, dans l'ordre ; les non situées ensuite. C'est
|
||||
// l'ordre de lecture d'une journée.
|
||||
return [...breaks].sort((a, b) => {
|
||||
if (a.startMinutes === null) return b.startMinutes === null ? 0 : 1;
|
||||
if (b.startMinutes === null) return -1;
|
||||
return a.startMinutes - b.startMinutes;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux pauses situées ne peuvent pas se chevaucher.
|
||||
*
|
||||
* Elles compteraient deux fois dans le total déduit, et le salarié se verrait
|
||||
* retirer un temps qu'il n'a pris qu'une fois.
|
||||
*/
|
||||
function assertNoOverlap(breaks: BreakInput[]): void {
|
||||
const placed = breaks
|
||||
.filter((entry) => entry.startMinutes !== null)
|
||||
.sort((a, b) => (a.startMinutes as number) - (b.startMinutes as number));
|
||||
|
||||
for (let index = 1; index < placed.length; index += 1) {
|
||||
const previous = placed[index - 1] as BreakInput;
|
||||
const current = placed[index] as BreakInput;
|
||||
const previousEnd =
|
||||
(previous.startMinutes as number) + previous.durationMinutes;
|
||||
if ((current.startMinutes as number) < previousEnd) {
|
||||
throw new BreakError(
|
||||
'Deux pauses se chevauchent : le temps serait déduit deux fois.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function breakTotals(breaks: ReadonlyArray<BreakInput>): BreakTotals {
|
||||
return breaks.reduce<BreakTotals>(
|
||||
(totals, entry) => ({
|
||||
breakMinutes:
|
||||
totals.breakMinutes + (entry.isPaid ? 0 : entry.durationMinutes),
|
||||
paidBreakMinutes:
|
||||
totals.paidBreakMinutes + (entry.isPaid ? entry.durationMinutes : 0),
|
||||
}),
|
||||
{ breakMinutes: 0, paidBreakMinutes: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Durée totale de repos, rémunérée ou non — ce que regarde la règle de pause. */
|
||||
export function totalRestMinutes(totals: BreakTotals): number {
|
||||
return totals.breakMinutes + totals.paidBreakMinutes;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { BoardAbsence, BoardRow } from '@/domain/planning/board';
|
||||
|
||||
/**
|
||||
* Lecture « présent / absent / repos » d'une ligne de planning.
|
||||
*
|
||||
* Vit dans le domaine et non dans l'écran : la vue des présences, la feuille
|
||||
* imprimée et, demain, un export en tirent la même conclusion. Deux
|
||||
* implémentations de cette règle finiraient par se contredire sur le seul cas
|
||||
* qui compte — celui du salarié dont le congé a été validé après coup.
|
||||
*/
|
||||
|
||||
export type DayState = 'present' | 'absent' | 'off';
|
||||
|
||||
/**
|
||||
* Absence recouvrant la colonne demandée.
|
||||
*
|
||||
* Les absences d'une ligne sont déjà découpées sur la semaine affichée :
|
||||
* `startDay` est un index de colonne, `span` un nombre de jours. Repartir des
|
||||
* dates ici referait, moins bien, un travail déjà fait — et rouvrirait la
|
||||
* question du fuseau au passage.
|
||||
*/
|
||||
export function absenceOnDay(
|
||||
row: BoardRow,
|
||||
dayIndex: number,
|
||||
): BoardAbsence | undefined {
|
||||
return row.absences.find(
|
||||
(absence) =>
|
||||
dayIndex >= absence.startDay && dayIndex < absence.startDay + absence.span,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* État d'un salarié un jour donné.
|
||||
*
|
||||
* **L'absence l'emporte sur le créneau.** Un congé accepté après la
|
||||
* construction du planning laisse les créneaux en base : afficher « présent »
|
||||
* enverrait un responsable chercher en rayon quelqu'un qui est chez lui. Le
|
||||
* planning a tort, pas l'absence.
|
||||
*/
|
||||
export function dayState(row: BoardRow, dayIndex: number): DayState {
|
||||
if (absenceOnDay(row, dayIndex)) return 'absent';
|
||||
return (row.days[dayIndex]?.length ?? 0) > 0 ? 'present' : 'off';
|
||||
}
|
||||
|
||||
export interface DayHeadcount {
|
||||
present: number;
|
||||
absent: number;
|
||||
off: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectif par jour sur un ensemble de lignes.
|
||||
*
|
||||
* Les lignes de besoins non couverts sont écartées : elles ne portent personne,
|
||||
* et les compter en « repos » gonflerait un effectif qui n'existe pas.
|
||||
*/
|
||||
export function headcountByDay(
|
||||
rows: BoardRow[],
|
||||
dayCount: number,
|
||||
): DayHeadcount[] {
|
||||
const counted = rows.filter((row) => !row.unassigned);
|
||||
|
||||
return Array.from({ length: dayCount }, (_, index) => {
|
||||
const tally: DayHeadcount = { present: 0, absent: 0, off: 0 };
|
||||
for (const row of counted) {
|
||||
tally[dayState(row, index)] += 1;
|
||||
}
|
||||
return tally;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Préférences du compte — PLAN.md §9.
|
||||
*
|
||||
* Le catalogue vit dans le domaine plutôt que dans l'écran : chaque préférence
|
||||
* y porte son libellé, sa conséquence et, le cas échéant, son avertissement.
|
||||
* Un interrupteur qui change un calcul de paie ne doit pas se présenter comme
|
||||
* un réglage d'affichage, et la nuance ne tiendra pas si elle n'existe que dans
|
||||
* le JSX.
|
||||
*/
|
||||
|
||||
export type PreferenceGroup = 'planning' | 'rights' | 'payroll' | 'print';
|
||||
|
||||
/** Densités d'impression. Liste fermée : elle décide d'une mise en page. */
|
||||
export const PRINT_DENSITIES = [
|
||||
{
|
||||
key: 'large',
|
||||
label: 'Grande',
|
||||
hint: 'Moins de salariés par page, lisible à distance sur un mur.',
|
||||
},
|
||||
{
|
||||
key: 'compact',
|
||||
label: 'Compacte',
|
||||
hint: 'Tout l’effectif sur une page, au prix de la lisibilité de loin.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type PrintDensity = (typeof PRINT_DENSITIES)[number]['key'];
|
||||
|
||||
export function isPrintDensity(value: string): value is PrintDensity {
|
||||
return PRINT_DENSITIES.some((density) => density.key === value);
|
||||
}
|
||||
|
||||
export interface PreferenceToggle {
|
||||
key: string;
|
||||
group: PreferenceGroup;
|
||||
label: string;
|
||||
/** Ce que l'interrupteur change réellement. */
|
||||
hint: string;
|
||||
/** Avertissement affiché en évidence — risque juridique ou de calcul. */
|
||||
warning?: string;
|
||||
/** Décalé sous la préférence dont il dépend. */
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
export const PREFERENCE_TOGGLES: PreferenceToggle[] = [
|
||||
// --- Plannings ---------------------------------------------------------
|
||||
{
|
||||
key: 'defaultMealPerShift',
|
||||
group: 'planning',
|
||||
label: 'Compter un repas par créneau',
|
||||
hint: 'Chaque créneau ouvre un repas sans saisie. À décocher si le repas dépend de l’amplitude.',
|
||||
},
|
||||
{
|
||||
key: 'paidBreaks',
|
||||
group: 'planning',
|
||||
label: 'Les pauses sont rémunérées',
|
||||
hint: 'Les pauses cessent d’être déduites du temps de travail effectif — et entrent donc dans les seuils de convention.',
|
||||
warning:
|
||||
'Modifie le décompte des heures : les semaines déjà exportées ne sont pas recalculées.',
|
||||
},
|
||||
{
|
||||
key: 'lockEmployeeMeals',
|
||||
group: 'planning',
|
||||
label: 'Les salariés ne peuvent pas modifier leur repas',
|
||||
hint: 'Le repas devient une donnée de gestion, saisie par l’encadrement.',
|
||||
},
|
||||
{
|
||||
key: 'employeesSeeOwnTotals',
|
||||
group: 'planning',
|
||||
label: 'Le salarié voit son total d’heures',
|
||||
hint: 'Heures planifiées et réelles de sa semaine, sur son propre planning.',
|
||||
},
|
||||
{
|
||||
key: 'employeesSeeTeamPlanning',
|
||||
group: 'planning',
|
||||
label: 'Le salarié voit le planning de son équipe',
|
||||
hint: 'Les créneaux des autres membres de ses équipes, sans leurs compteurs.',
|
||||
},
|
||||
{
|
||||
key: 'hideForfaitCounters',
|
||||
group: 'planning',
|
||||
label: 'Masquer les compteurs au forfait jours',
|
||||
hint: 'Les alertes horaires de ces lignes sont masquées avec eux. Un forfait jours ne se compte pas en heures : un écart hebdomadaire y est un faux positif.',
|
||||
},
|
||||
|
||||
// --- Droits ------------------------------------------------------------
|
||||
{
|
||||
key: 'employeesEditOwnProfile',
|
||||
group: 'rights',
|
||||
label: 'Le salarié modifie ses informations personnelles',
|
||||
hint: 'Coordonnées et état civil. Le contrat et la rémunération restent hors de portée.',
|
||||
},
|
||||
{
|
||||
key: 'employeesSeeOwnTimesheets',
|
||||
group: 'rights',
|
||||
label: 'Le salarié consulte ses feuilles de présence',
|
||||
hint: 'Depuis son profil, en lecture seule.',
|
||||
},
|
||||
{
|
||||
key: 'employeesSeeTeamContacts',
|
||||
group: 'rights',
|
||||
label: 'Le salarié voit les coordonnées de l’équipe',
|
||||
hint: 'Téléphone et adresse e-mail de tous les membres.',
|
||||
warning:
|
||||
'Ouvre des données personnelles à l’ensemble de l’équipe : à n’activer que si la finalité le justifie.',
|
||||
},
|
||||
{
|
||||
key: 'managerCreatedArePlannable',
|
||||
group: 'rights',
|
||||
label: 'Un salarié créé par un manager est planifiable aussitôt',
|
||||
hint: 'Décoché, un administrateur ou un directeur doit le rendre planifiable depuis son profil.',
|
||||
},
|
||||
{
|
||||
key: 'directorsSharePeople',
|
||||
group: 'rights',
|
||||
label: 'Les directeurs partagent leurs salariés',
|
||||
hint: 'Un salarié devient disponible sur les plannings d’un autre établissement.',
|
||||
},
|
||||
{
|
||||
key: 'directorsAccessHrDashboard',
|
||||
group: 'rights',
|
||||
label: 'Les directeurs accèdent au tableau de bord RH',
|
||||
hint: 'Les agrégats restent bornés à leur périmètre d’établissement.',
|
||||
},
|
||||
{
|
||||
key: 'directorsSeePaidLeave',
|
||||
group: 'rights',
|
||||
label: 'Compteurs de congés payés visibles des directeurs',
|
||||
hint: 'Lecture seule. L’ajustement manuel reste une capacité distincte.',
|
||||
},
|
||||
{
|
||||
key: 'managersSeePaidLeave',
|
||||
group: 'rights',
|
||||
label: 'Compteurs de congés payés visibles des managers',
|
||||
hint: 'Lecture seule, même règle que pour les directeurs.',
|
||||
},
|
||||
|
||||
// --- Paie --------------------------------------------------------------
|
||||
{
|
||||
key: 'smoothOvertimeMonthly',
|
||||
group: 'payroll',
|
||||
label: 'Lisser les heures supplémentaires au mois',
|
||||
hint: 'Le décompte se fait sur le mois au lieu de la semaine.',
|
||||
warning:
|
||||
'Le décompte hebdomadaire est le principe (art. L3121-28). Lisser au mois suppose un accord d’aménagement du temps de travail ; sans accord, l’aménagement est inopposable et les heures restent dues à la semaine.',
|
||||
},
|
||||
{
|
||||
key: 'includeRestInNormalHours',
|
||||
group: 'payroll',
|
||||
label: 'Inclure repos et absences dans les heures normales',
|
||||
hint: 'Les heures travaillées au-delà du contractuel du fait d’une absence sont payées au taux normal plutôt qu’en heures supplémentaires.',
|
||||
warning:
|
||||
'Modifie la répartition entre heures normales et heures majorées : à confirmer avec le gestionnaire de paie avant tout export.',
|
||||
},
|
||||
{
|
||||
key: 'autoEmployeeNumber',
|
||||
group: 'payroll',
|
||||
label: 'Générer le matricule automatiquement',
|
||||
hint: 'Décoché, le matricule est saisi à l’embauche — utile quand il doit correspondre à celui du logiciel de paie.',
|
||||
},
|
||||
|
||||
// --- Impression --------------------------------------------------------
|
||||
{
|
||||
key: 'printContractTotals',
|
||||
group: 'print',
|
||||
label: 'Imprimer les totaux contractuels et planifiés',
|
||||
hint: 'Ajoute une colonne de totaux par salarié.',
|
||||
},
|
||||
{
|
||||
key: 'printOtherTeams',
|
||||
group: 'print',
|
||||
label: 'Imprimer les créneaux des autres équipes',
|
||||
hint: 'Situe la journée d’ensemble sur la feuille d’une équipe.',
|
||||
},
|
||||
{
|
||||
key: 'printSunday',
|
||||
group: 'print',
|
||||
label: 'Imprimer le dimanche',
|
||||
hint: 'À décocher pour un établissement qui n’ouvre jamais ce jour-là : la colonne gagnée profite aux six autres.',
|
||||
},
|
||||
{
|
||||
key: 'printSignatureColumn',
|
||||
group: 'print',
|
||||
label: 'Imprimer la colonne « émargements »',
|
||||
hint: 'Colonne vierge pour signature manuscrite.',
|
||||
warning:
|
||||
'Un émargement papier ne vaut pas décompte du temps de travail : le décompte reste celui de PlanFlow, pas celui de la feuille signée.',
|
||||
},
|
||||
];
|
||||
|
||||
export const PREFERENCE_KEYS: string[] = PREFERENCE_TOGGLES.map(
|
||||
(toggle) => toggle.key,
|
||||
);
|
||||
|
||||
export function togglesOf(group: PreferenceGroup): PreferenceToggle[] {
|
||||
return PREFERENCE_TOGGLES.filter((toggle) => toggle.group === group);
|
||||
}
|
||||
|
||||
/** « 20:00 » ↔ minutes depuis minuit, pour l'heure de bascule du soir. */
|
||||
export function minutesToTime(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60) % 24;
|
||||
const rest = minutes % 60;
|
||||
return `${String(hours).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function timeToMinutes(value: string): number | null {
|
||||
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
@@ -229,7 +229,7 @@ export async function requestTimeOffAction(
|
||||
return toState(error, "Vous n'avez pas le droit de poser cette absence.");
|
||||
}
|
||||
|
||||
revalidatePath('/conges');
|
||||
revalidatePath('/absences/calendrier');
|
||||
return warning ? { ok: true, warning } : { ok: true };
|
||||
}
|
||||
|
||||
@@ -328,7 +328,7 @@ export async function decideTimeOffAction(
|
||||
return toState(error, "Vous n'avez pas le droit de décider de cette demande.");
|
||||
}
|
||||
|
||||
revalidatePath('/conges');
|
||||
revalidatePath('/absences/calendrier');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -384,7 +384,7 @@ export async function cancelTimeOffAction(
|
||||
return toState(error, "Vous n'avez pas le droit d'annuler cette absence.");
|
||||
}
|
||||
|
||||
revalidatePath('/conges');
|
||||
revalidatePath('/absences/calendrier');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ export async function adjustCounterAction(
|
||||
return toState(error, "Vous n'avez pas le droit d'ajuster un compteur.");
|
||||
}
|
||||
|
||||
revalidatePath('/conges');
|
||||
revalidatePath('/absences/calendrier');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -322,6 +322,142 @@ export async function getAbsenceBoard(month: Month): Promise<AbsenceBoard> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* File d'absences filtrée par état — PLAN.md §9, écrans `/absences/*`.
|
||||
*
|
||||
* Les trois files partagent une requête plutôt qu'un écran chacune : la règle
|
||||
* de masquage du motif médical vaut pour les trois, et la dupliquer trois fois
|
||||
* garantirait qu'une des copies finisse par diverger. Une donnée de santé qui
|
||||
* ne fuit que sur l'écran « traitées » reste une fuite.
|
||||
*/
|
||||
export type AbsenceQueue = 'pending' | 'treated' | 'expired';
|
||||
|
||||
export interface QueueFilters {
|
||||
/** Restreint à un type d'absence. */
|
||||
typeId?: string;
|
||||
/** `true` : uniquement les absences Sécurité sociale (filtre relevé à l'audit). */
|
||||
socialSecurityOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface AbsenceQueueResult {
|
||||
queue: AbsenceQueue;
|
||||
rows: AbsenceRequest[];
|
||||
absenceTypes: Array<{ id: string; code: string; name: string }>;
|
||||
canDecide: boolean;
|
||||
ownMembershipId: string;
|
||||
/** Décomptes des trois files, pour les onglets. */
|
||||
counts: Record<AbsenceQueue, number>;
|
||||
}
|
||||
|
||||
const QUEUE_STATUSES: Record<
|
||||
AbsenceQueue,
|
||||
Array<AbsenceRequest['status']>
|
||||
> = {
|
||||
pending: ['PENDING'],
|
||||
treated: ['ACCEPTED', 'DECLINED', 'CANCELLED'],
|
||||
expired: ['EXPIRED'],
|
||||
};
|
||||
|
||||
export async function getAbsenceQueue(
|
||||
queue: AbsenceQueue,
|
||||
filters: QueueFilters = {},
|
||||
): Promise<AbsenceQueueResult> {
|
||||
return query('timeoff.view_own', async (db, actor) => {
|
||||
const canSeeOthers = can(actor, 'timeoff.view_others');
|
||||
const canDecide = can(actor, 'timeoff.decide');
|
||||
const canSeeMedical = can(actor, 'members.documents.view');
|
||||
|
||||
const scope = canSeeOthers ? {} : { membershipId: actor.membershipId };
|
||||
|
||||
const rows = await db.timeOff.findMany({
|
||||
where: {
|
||||
...scope,
|
||||
status: { in: QUEUE_STATUSES[queue] },
|
||||
...(filters.typeId ? { absenceTypeId: filters.typeId } : {}),
|
||||
...(filters.socialSecurityOnly
|
||||
? { absenceType: { isSocialSecurity: true } }
|
||||
: {}),
|
||||
},
|
||||
include: { absenceType: true },
|
||||
// La file à traiter se lit par ancienneté — la plus vieille demande est
|
||||
// celle qui coûte le plus au salarié qui attend. Les files closes se
|
||||
// lisent à l'envers : on y cherche ce qui vient d'être décidé.
|
||||
orderBy:
|
||||
queue === 'pending'
|
||||
? { requestedAt: 'asc' }
|
||||
: { startDate: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
|
||||
const memberIds = [...new Set(rows.map((entry) => entry.membershipId))];
|
||||
const members = await db.membership.findMany({
|
||||
where: { id: { in: memberIds } },
|
||||
include: { profile: { select: { firstName: true, lastName: true } } },
|
||||
});
|
||||
const nameById = new Map(
|
||||
members.map((member) => [
|
||||
member.id,
|
||||
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
|
||||
]),
|
||||
);
|
||||
|
||||
const counts = await db.timeOff.groupBy({
|
||||
by: ['status'],
|
||||
where: scope,
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byStatus = new Map(
|
||||
counts.map((row) => [row.status, row._count._all] as const),
|
||||
);
|
||||
const total = (queueKey: AbsenceQueue) =>
|
||||
QUEUE_STATUSES[queueKey].reduce(
|
||||
(sum, status) => sum + (byStatus.get(status) ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const absenceTypes = await db.absenceType.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, code: true, name: true },
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
|
||||
return {
|
||||
queue,
|
||||
rows: rows.map((entry) => ({
|
||||
id: entry.id,
|
||||
membershipId: entry.membershipId,
|
||||
name: nameById.get(entry.membershipId) ?? 'Salarié',
|
||||
typeLabel:
|
||||
entry.absenceType.isSocialSecurity && !canSeeMedical
|
||||
? 'Absence'
|
||||
: entry.absenceType.name,
|
||||
colorKey: entry.absenceType.colorKey,
|
||||
isSocialSecurity: entry.absenceType.isSocialSecurity,
|
||||
startDate: entry.startDate.toISOString().slice(0, 10),
|
||||
endDate: entry.endDate.toISOString().slice(0, 10),
|
||||
startHalfDay: entry.startHalfDay,
|
||||
endHalfDay: entry.endHalfDay,
|
||||
days: Number(entry.countedDays?.toString() ?? '0'),
|
||||
status: entry.status,
|
||||
comment:
|
||||
entry.absenceType.isSocialSecurity && !canSeeMedical
|
||||
? null
|
||||
: entry.comment,
|
||||
decisionComment: entry.decisionComment,
|
||||
requestedAt: entry.requestedAt,
|
||||
})),
|
||||
absenceTypes,
|
||||
canDecide,
|
||||
ownMembershipId: actor.membershipId,
|
||||
counts: {
|
||||
pending: total('pending'),
|
||||
treated: total('treated'),
|
||||
expired: total('expired'),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Absences acceptées d'une période, pour la grille de planning. */
|
||||
export async function acceptedAbsencesBetween(
|
||||
membershipIds: string[],
|
||||
|
||||
@@ -67,6 +67,7 @@ interface LoadedShift {
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
breakMinutes: number;
|
||||
paidBreakMinutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,6 +263,7 @@ async function buildContexts(
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
breakMinutes: true,
|
||||
paidBreakMinutes: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -277,6 +279,7 @@ async function buildContexts(
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
breakMinutes: true,
|
||||
paidBreakMinutes: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -321,6 +324,7 @@ async function buildContexts(
|
||||
startAt: shift.startAt,
|
||||
endAt: shift.endAt,
|
||||
breakMinutes: shift.breakMinutes,
|
||||
paidBreakMinutes: shift.paidBreakMinutes,
|
||||
assigned: shift.membershipId !== null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export type EmailKind =
|
||||
| 'INVITATION'
|
||||
| 'PASSWORD_RESET'
|
||||
| 'PLANNING_PUBLISHED'
|
||||
| 'SHIFT_ASSIGNED'
|
||||
| 'TIMEOFF_DECISION'
|
||||
| 'LEAVE_NOTICE';
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError, can } from '@/domain/access/authorize';
|
||||
import { findOverlaps, validateContract } from '@/domain/contracts/rules';
|
||||
import { nextEmployeeNumber } from '@/domain/hr/civil-status';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
|
||||
@@ -27,11 +28,16 @@ const CONTRACT_TYPES = [
|
||||
|
||||
const employeeInput = z.object({
|
||||
firstName: z.string().trim().min(1, 'Prénom requis').max(80),
|
||||
lastName: z.string().trim().min(1, 'Nom requis').max(80),
|
||||
employeeNumber: z.string().trim().min(1, 'Matricule requis').max(40),
|
||||
/** Le nom d'usage vaut nom de naissance quand il n'est pas distinct. */
|
||||
birthName: z.string().trim().max(80),
|
||||
lastName: z.string().trim().min(1, 'Nom de famille requis').max(80),
|
||||
/** Vide = proposé par l'application, à la suite du dernier attribué. */
|
||||
employeeNumber: z.string().trim().max(40),
|
||||
birthDate: z.string().trim().max(10),
|
||||
/** Vide = salarié géré sans accès applicatif. */
|
||||
email: z.string().trim().email('Adresse invalide').or(z.literal('')),
|
||||
phone: z.string().trim().max(30),
|
||||
landline: z.string().trim().max(30),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -43,15 +49,25 @@ const employeeInput = z.object({
|
||||
*/
|
||||
const hiringContractInput = z.object({
|
||||
locationId: z.string().min(1, 'Établissement requis'),
|
||||
teamId: z.string().trim(),
|
||||
// Requise avec un contrat : le planning s'ordonne par équipe, et un salarié
|
||||
// sans rattachement n'apparaît sur aucune grille. Le formulaire l'impose déjà,
|
||||
// mais le contrôle du navigateur ne protège pas une action serveur.
|
||||
teamId: z.string().trim().min(1, 'Choisissez une équipe.'),
|
||||
contractType: z.enum(CONTRACT_TYPES),
|
||||
startDate: z.coerce.date(),
|
||||
/** Heure de prise de poste : la DPAE la demande, la date seule n'y suffit pas. */
|
||||
startTime: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Heure de début invalide'),
|
||||
endDate: z.string().trim(),
|
||||
workTimeArrangement: z.enum(['HOURLY', 'FORFAIT_JOURS']),
|
||||
weeklyHours: z.coerce.number().min(0).max(60),
|
||||
forfaitDaysPerYear: z.coerce.number().min(0).max(400).optional(),
|
||||
forfaitAgreementRef: z.string().trim(),
|
||||
forfaitAgreedAt: z.string().trim(),
|
||||
lineManagerId: z.string().trim(),
|
||||
rttPolicyId: z.string().trim(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -71,16 +87,28 @@ export async function createEmployeeAction(
|
||||
): Promise<ActionState> {
|
||||
const parsed = employeeInput.safeParse({
|
||||
firstName: formData.get('firstName'),
|
||||
birthName: formData.get('birthName') ?? '',
|
||||
lastName: formData.get('lastName'),
|
||||
employeeNumber: formData.get('employeeNumber'),
|
||||
employeeNumber: formData.get('employeeNumber') ?? '',
|
||||
birthDate: formData.get('birthDate') ?? '',
|
||||
email: formData.get('email') ?? '',
|
||||
phone: formData.get('phone') ?? '',
|
||||
landline: formData.get('landline') ?? '',
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
let birthDate: Date | null = null;
|
||||
if (parsed.data.birthDate) {
|
||||
const candidate = new Date(`${parsed.data.birthDate}T00:00:00Z`);
|
||||
if (Number.isNaN(candidate.getTime()) || candidate > new Date()) {
|
||||
return { error: 'Date de naissance invalide.' };
|
||||
}
|
||||
birthDate = candidate;
|
||||
}
|
||||
|
||||
// Le contrat n'est tenté que si le formulaire l'a ouvert : créer un salarié
|
||||
// sans contrat reste légitime — un remplaçant se saisit avant que son
|
||||
// établissement soit tranché.
|
||||
@@ -95,6 +123,9 @@ export async function createEmployeeAction(
|
||||
teamId: formData.get('teamId') ?? '',
|
||||
contractType: formData.get('contractType') ?? 'CDI',
|
||||
startDate: formData.get('startDate'),
|
||||
startTime: formData.get('startTime') || '09:00',
|
||||
lineManagerId: formData.get('lineManagerId') ?? '',
|
||||
rttPolicyId: formData.get('rttPolicyId') ?? '',
|
||||
endDate: formData.get('endDate') ?? '',
|
||||
workTimeArrangement: formData.get('workTimeArrangement') ?? 'HOURLY',
|
||||
weeklyHours: formData.get('weeklyHours') || 35,
|
||||
@@ -142,18 +173,43 @@ export async function createEmployeeAction(
|
||||
const role = await db.role.findFirst({ where: { key: 'employee' } });
|
||||
if (!role) throw new Error('Rôle « employee » introuvable.');
|
||||
|
||||
// Le matricule est proposé quand il n'est pas donné. Attribué **dans la
|
||||
// transaction**, à la suite du dernier : deux embauches simultanées ne
|
||||
// peuvent pas tomber sur le même rang, la seconde échouant sur l'unicité
|
||||
// plutôt que d'écraser la première.
|
||||
let employeeNumber = parsed.data.employeeNumber;
|
||||
if (!employeeNumber) {
|
||||
const taken = await db.membership.findMany({
|
||||
select: { employeeNumber: true },
|
||||
});
|
||||
employeeNumber = nextEmployeeNumber(
|
||||
taken.map((entry) => entry.employeeNumber),
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await db.membership.findFirst({
|
||||
where: { employeeNumber: parsed.data.employeeNumber },
|
||||
where: { employeeNumber },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ValidationError('Ce matricule est déjà utilisé.');
|
||||
}
|
||||
|
||||
if (contract?.lineManagerId) {
|
||||
const manager = await db.membership.findUnique({
|
||||
where: { id: contract.lineManagerId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!manager) {
|
||||
throw new ValidationError('Responsable hiérarchique introuvable.');
|
||||
}
|
||||
}
|
||||
|
||||
const created = await db.membership.create({
|
||||
data: {
|
||||
roleId: role.id,
|
||||
employeeNumber: parsed.data.employeeNumber,
|
||||
employeeNumber,
|
||||
status: parsed.data.email ? 'INVITED' : 'ACTIVE',
|
||||
lineManagerId: contract?.lineManagerId || null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
@@ -162,8 +218,14 @@ export async function createEmployeeAction(
|
||||
membershipId: created.id,
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
// Sans mention contraire, le nom de naissance est le nom d'usage :
|
||||
// laisser le champ vide obligerait à le ressaisir pour l'immense
|
||||
// majorité des dossiers, où les deux coïncident.
|
||||
birthName: parsed.data.birthName || parsed.data.lastName,
|
||||
birthDate: birthDate,
|
||||
personalEmail: parsed.data.email || null,
|
||||
phone: parsed.data.phone || null,
|
||||
landline: parsed.data.landline || null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
@@ -181,6 +243,7 @@ export async function createEmployeeAction(
|
||||
locationId: location.id,
|
||||
contractType: contract.contractType,
|
||||
startDate: contract.startDate,
|
||||
startTime: contract.startTime,
|
||||
endDate: contractEnd,
|
||||
workTimeArrangement: contract.workTimeArrangement,
|
||||
weeklyHours: contract.weeklyHours,
|
||||
@@ -208,6 +271,22 @@ export async function createEmployeeAction(
|
||||
});
|
||||
}
|
||||
|
||||
if (contract.rttPolicyId) {
|
||||
const policy = await db.rttPolicy.findUnique({
|
||||
where: { id: contract.rttPolicyId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!policy || policy.status !== 'ACTIVE') {
|
||||
throw new ValidationError('Politique RTT introuvable ou archivée.');
|
||||
}
|
||||
await db.rttPolicyAssignment.create({
|
||||
data: {
|
||||
rttPolicyId: policy.id,
|
||||
membershipId: created.id,
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'contract.create',
|
||||
|
||||
@@ -4,6 +4,8 @@ import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError, can } from '@/domain/access/authorize';
|
||||
import { COUNTRIES } from '@/domain/legal/countries';
|
||||
import { DEPARTMENT_CODES } from '@/domain/legal/departments';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
import { encryptOptional } from '@/server/crypto';
|
||||
@@ -33,13 +35,51 @@ const optionalText = (max: number) =>
|
||||
.max(max)
|
||||
.transform((value) => (value === '' ? null : value));
|
||||
|
||||
/** Une valeur vide vaut « non renseigné » ; toute autre doit être connue. */
|
||||
const optionalEnum = <T extends readonly string[]>(values: T) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) => value === '' || (values as readonly string[]).includes(value),
|
||||
'Valeur inconnue',
|
||||
)
|
||||
.transform((value) => (value === '' ? null : value));
|
||||
|
||||
const COUNTRY_CODES = COUNTRIES.map((country) => country.code);
|
||||
|
||||
const profileInput = z.object({
|
||||
membershipId: z.string().min(1),
|
||||
gender: optionalEnum(['FEMALE', 'MALE', 'UNSPECIFIED'] as const),
|
||||
firstName: z.string().trim().min(1, 'Prénom requis').max(80),
|
||||
birthName: optionalText(80),
|
||||
lastName: z.string().trim().min(1, 'Nom requis').max(80),
|
||||
birthDate: optionalText(10),
|
||||
birthPlace: optionalText(120),
|
||||
nationality: optionalText(80),
|
||||
// Codes de liste fermée, pas des libellés. Un dossier saisi avant la liste
|
||||
// porte encore « France » en clair : le sélecteur ne trouve pas d'option
|
||||
// correspondante, affiche « non renseigné », et la première correction du
|
||||
// dossier remplace la valeur par son code. Elle n'est ni perdue en silence
|
||||
// ni opposée à qui vient corriger un champ voisin.
|
||||
birthCountry: optionalEnum(COUNTRY_CODES),
|
||||
birthDepartment: optionalEnum(DEPARTMENT_CODES),
|
||||
nationality: optionalEnum(COUNTRY_CODES),
|
||||
maritalStatus: optionalEnum([
|
||||
'SINGLE',
|
||||
'MARRIED',
|
||||
'PACS',
|
||||
'COHABITING',
|
||||
'DIVORCED',
|
||||
'WIDOWED',
|
||||
] as const),
|
||||
dependents: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) => value === '' || /^\d{1,2}$/.test(value),
|
||||
'Nombre de personnes à charge invalide',
|
||||
)
|
||||
.transform((value) => (value === '' ? null : Number(value))),
|
||||
personalEmail: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -50,10 +90,12 @@ const profileInput = z.object({
|
||||
)
|
||||
.transform((value) => (value === '' ? null : value)),
|
||||
phone: optionalText(30),
|
||||
landline: optionalText(30),
|
||||
addressLine1: optionalText(180),
|
||||
addressLine2: optionalText(180),
|
||||
postalCode: optionalText(12),
|
||||
city: optionalText(120),
|
||||
country: optionalText(80),
|
||||
country: optionalEnum(COUNTRY_CODES),
|
||||
emergencyContactName: optionalText(120),
|
||||
emergencyContactPhone: optionalText(30),
|
||||
});
|
||||
@@ -62,18 +104,26 @@ export async function updateProfileAction(
|
||||
_previous: ProfileActionState,
|
||||
formData: FormData,
|
||||
): Promise<ProfileActionState> {
|
||||
const parsed = profileInput.safeParse(
|
||||
Object.fromEntries(
|
||||
const parsed = profileInput.safeParse({
|
||||
...Object.fromEntries(
|
||||
[
|
||||
'membershipId',
|
||||
'gender',
|
||||
'firstName',
|
||||
'birthName',
|
||||
'lastName',
|
||||
'birthDate',
|
||||
'birthPlace',
|
||||
'birthCountry',
|
||||
'birthDepartment',
|
||||
'nationality',
|
||||
'maritalStatus',
|
||||
'dependents',
|
||||
'personalEmail',
|
||||
'phone',
|
||||
'landline',
|
||||
'addressLine1',
|
||||
'addressLine2',
|
||||
'postalCode',
|
||||
'city',
|
||||
'country',
|
||||
@@ -81,7 +131,7 @@ export async function updateProfileAction(
|
||||
'emergencyContactPhone',
|
||||
].map((key) => [key, formData.get(key) ?? '']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
|
||||
@@ -267,16 +267,24 @@ export async function listEmployees(
|
||||
* qu'il n'avait pas le droit de lire.
|
||||
*/
|
||||
export interface EmployeeProfileDetail {
|
||||
gender: string | null;
|
||||
firstName: string;
|
||||
birthName: string | null;
|
||||
lastName: string;
|
||||
birthDate: Date | null;
|
||||
birthPlace: string | null;
|
||||
birthCountry: string | null;
|
||||
birthDepartment: string | null;
|
||||
nationality: string | null;
|
||||
maritalStatus: string | null;
|
||||
dependents: number | null;
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
postalCode: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
phone: string | null;
|
||||
landline: string | null;
|
||||
personalEmail: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
@@ -460,16 +468,24 @@ export const getEmployee = cache(async function getEmployee(
|
||||
},
|
||||
profile: membership.profile
|
||||
? {
|
||||
gender: membership.profile.gender,
|
||||
firstName: membership.profile.firstName,
|
||||
birthName: membership.profile.birthName,
|
||||
lastName: membership.profile.lastName,
|
||||
birthDate: membership.profile.birthDate,
|
||||
birthPlace: membership.profile.birthPlace,
|
||||
birthCountry: membership.profile.birthCountry,
|
||||
birthDepartment: membership.profile.birthDepartment,
|
||||
nationality: membership.profile.nationality,
|
||||
maritalStatus: membership.profile.maritalStatus,
|
||||
dependents: membership.profile.dependents,
|
||||
addressLine1: membership.profile.addressLine1,
|
||||
addressLine2: membership.profile.addressLine2,
|
||||
postalCode: membership.profile.postalCode,
|
||||
city: membership.profile.city,
|
||||
country: membership.profile.country,
|
||||
phone: membership.profile.phone,
|
||||
landline: membership.profile.landline,
|
||||
personalEmail: membership.profile.personalEmail,
|
||||
emergencyContactName: membership.profile.emergencyContactName,
|
||||
emergencyContactPhone: membership.profile.emergencyContactPhone,
|
||||
@@ -561,6 +577,49 @@ export const listContractLocations = cache(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Ce que le panneau d'embauche propose en plus des établissements.
|
||||
*
|
||||
* Lu avec `members.view` : ce sont des noms déjà affichés par l'annuaire, et
|
||||
* une politique RTT n'est pas une donnée sensible.
|
||||
*/
|
||||
export interface HiringOptions {
|
||||
managers: Array<{ id: string; name: string }>;
|
||||
rttPolicies: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export const listHiringOptions = cache(
|
||||
async function listHiringOptions(): Promise<HiringOptions> {
|
||||
return query('members.view', async (db) => {
|
||||
const [managers, rttPolicies] = await Promise.all([
|
||||
db.membership.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
employeeNumber: true,
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
}),
|
||||
db.rttPolicy.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
managers: managers
|
||||
.map((manager) => ({
|
||||
id: manager.id,
|
||||
name: `${manager.profile?.firstName ?? ''} ${manager.profile?.lastName ?? manager.employeeNumber}`.trim(),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
|
||||
rttPolicies,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** Rattachement et périmètre — onglet « Planification et accès ». */
|
||||
export interface MemberPlacement {
|
||||
/** Établissement porté par le contrat en cours. Il ne se change qu'en avenant. */
|
||||
|
||||
@@ -2,6 +2,9 @@ import 'server-only';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { nationalityLabel } from '@/domain/legal/countries';
|
||||
|
||||
import { genderShort } from '@/domain/hr/civil-status';
|
||||
import type { RupPerson } from '@/domain/legal/rup';
|
||||
import { peopleWithGaps } from '@/domain/legal/rup';
|
||||
import { query } from '@/server/context';
|
||||
@@ -44,6 +47,8 @@ export const getRegisterData = cache(async function getRegisterData(
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
birthName: true,
|
||||
gender: true,
|
||||
nationality: true,
|
||||
birthDate: true,
|
||||
},
|
||||
@@ -71,12 +76,17 @@ export const getRegisterData = cache(async function getRegisterData(
|
||||
// Une ligne par contrat, pas par personne : un salarié réembauché a deux
|
||||
// entrées et deux sorties, et les fondre effacerait l'interruption.
|
||||
const people: RupPerson[] = contracts.map((contract) => ({
|
||||
lastName: contract.membership.profile?.lastName ?? '',
|
||||
// Le registre se tient au nom de naissance : c'est celui de l'état civil,
|
||||
// et le nom d'usage peut changer sans que la personne change.
|
||||
lastName:
|
||||
contract.membership.profile?.birthName ??
|
||||
contract.membership.profile?.lastName ??
|
||||
'',
|
||||
firstName: contract.membership.profile?.firstName ?? '',
|
||||
// PlanFlow ne collecte pas le sexe : la mention est exigée, la colonne
|
||||
// reste donc vide et le décompte des dossiers incomplets le signale.
|
||||
sex: null,
|
||||
nationality: contract.membership.profile?.nationality ?? null,
|
||||
sex: genderShort(contract.membership.profile?.gender ?? null),
|
||||
nationality: nationalityLabel(
|
||||
contract.membership.profile?.nationality ?? null,
|
||||
),
|
||||
birthDate: contract.membership.profile?.birthDate ?? null,
|
||||
jobTitle: contract.jobTitleId
|
||||
? (jobTitleNames.get(contract.jobTitleId) ?? null)
|
||||
@@ -128,6 +138,8 @@ export const listRegisterLocations = cache(
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
birthName: true,
|
||||
gender: true,
|
||||
nationality: true,
|
||||
birthDate: true,
|
||||
},
|
||||
@@ -138,10 +150,15 @@ export const listRegisterLocations = cache(
|
||||
});
|
||||
|
||||
const people: RupPerson[] = contracts.map((contract) => ({
|
||||
lastName: contract.membership.profile?.lastName ?? '',
|
||||
lastName:
|
||||
contract.membership.profile?.birthName ??
|
||||
contract.membership.profile?.lastName ??
|
||||
'',
|
||||
firstName: contract.membership.profile?.firstName ?? '',
|
||||
sex: null,
|
||||
nationality: contract.membership.profile?.nationality ?? null,
|
||||
sex: genderShort(contract.membership.profile?.gender ?? null),
|
||||
nationality: nationalityLabel(
|
||||
contract.membership.profile?.nationality ?? null,
|
||||
),
|
||||
birthDate: contract.membership.profile?.birthDate ?? null,
|
||||
jobTitle: contract.jobTitleId ? 'x' : null,
|
||||
qualification: contract.classification,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { inScope } from '@/domain/access/authorize';
|
||||
import { shiftMinutes } from '@/domain/counters/week';
|
||||
import {
|
||||
absenteeism,
|
||||
headcount,
|
||||
turnoverRate,
|
||||
type AbsenceInput,
|
||||
type HeadcountInput,
|
||||
} from '@/domain/hr/indicators';
|
||||
import {
|
||||
formatMonthParam,
|
||||
monthDates,
|
||||
monthLabel,
|
||||
previousMonth,
|
||||
type Month,
|
||||
} from '@/domain/planning/month';
|
||||
import { query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Analyses RH — PLAN.md §9, `/rh/analyses/{effectifs,heures,absences}`.
|
||||
*
|
||||
* Une série mensuelle, pas trois écrans qui recalculent chacun leur version.
|
||||
* Les trois analyses lisent le même tableau : c'est ce qui garantit qu'un
|
||||
* effectif moyen affiché en « effectifs » soit le même que le dénominateur du
|
||||
* taux d'absentéisme affiché en « absences ». Deux calculs séparés finiraient
|
||||
* par diverger d'un arrondi, et l'écart se plaide mal devant un CSE.
|
||||
*
|
||||
* Les indicateurs eux-mêmes viennent du domaine, testés aux bornes. Ce module
|
||||
* ne fait que rassembler les données et boucler sur les mois.
|
||||
*/
|
||||
|
||||
export interface MonthPoint {
|
||||
month: Month;
|
||||
label: string;
|
||||
param: string;
|
||||
closingHeadcount: number;
|
||||
averageHeadcount: number;
|
||||
entries: number;
|
||||
exits: number;
|
||||
turnoverRate: number | null;
|
||||
absenceDays: number;
|
||||
sickDays: number;
|
||||
absenteeismRate: number | null;
|
||||
/** Minutes planifiées, pauses déduites. */
|
||||
plannedMinutes: number;
|
||||
/** Minutes réellement travaillées quand elles ont été saisies. */
|
||||
actualMinutes: number;
|
||||
theoreticalWorkedDays: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsSeries {
|
||||
location: { id: string; name: string } | null;
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
/** Du plus ancien au plus récent : un graphique se lit dans ce sens. */
|
||||
points: MonthPoint[];
|
||||
}
|
||||
|
||||
/** Jours ouvrables d'un mois — base théorique de l'absentéisme (dimanche exclu). */
|
||||
function workingDaysIn(dates: string[]): number {
|
||||
return dates.filter(
|
||||
(date) => new Date(`${date}T00:00:00Z`).getUTCDay() !== 0,
|
||||
).length;
|
||||
}
|
||||
|
||||
function monthsEndingWith(last: Month, count: number): Month[] {
|
||||
const months: Month[] = [last];
|
||||
for (let index = 1; index < count; index += 1) {
|
||||
months.unshift(previousMonth(months[0] as Month));
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
export async function getAnalyticsSeries(
|
||||
lastMonth: Month,
|
||||
monthCount = 12,
|
||||
locationId?: string,
|
||||
): Promise<AnalyticsSeries> {
|
||||
return query('members.view', async (db, actor) => {
|
||||
const allLocations = await db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
// Le périmètre s'applique avant l'agrégation : un total consolidé ne doit
|
||||
// pas laisser deviner l'effectif d'un établissement qu'on n'a pas le droit
|
||||
// de lire.
|
||||
const visible = allLocations.filter((location) =>
|
||||
inScope(actor, { locationId: location.id }),
|
||||
);
|
||||
const location =
|
||||
visible.find((candidate) => candidate.id === locationId) ??
|
||||
visible[0] ??
|
||||
null;
|
||||
|
||||
const months = monthsEndingWith(lastMonth, monthCount);
|
||||
const firstDate = monthDates(months[0] as Month)[0] as string;
|
||||
const lastDates = monthDates(months[months.length - 1] as Month);
|
||||
const lastDate = lastDates[lastDates.length - 1] as string;
|
||||
|
||||
const locationFilter = location ? { locationId: location.id } : {};
|
||||
|
||||
// Une seule lecture par nature, puis un découpage en mémoire. Douze
|
||||
// requêtes par indicateur donneraient le même résultat en douze fois plus
|
||||
// de temps, et rendraient l'écran inutilisable sur une année glissante.
|
||||
const contracts = await db.userContract.findMany({
|
||||
where: locationFilter,
|
||||
select: {
|
||||
membershipId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
const contractInputs: HeadcountInput[] = contracts.map((contract) => ({
|
||||
membershipId: contract.membershipId,
|
||||
startDate: contract.startDate.toISOString().slice(0, 10),
|
||||
endDate: contract.endDate?.toISOString().slice(0, 10) ?? null,
|
||||
}));
|
||||
|
||||
const timeOffs = await db.timeOff.findMany({
|
||||
where: {
|
||||
status: 'ACCEPTED',
|
||||
startDate: { lte: new Date(`${lastDate}T00:00:00Z`) },
|
||||
endDate: { gte: new Date(`${firstDate}T00:00:00Z`) },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
countedDays: true,
|
||||
absenceType: { select: { isSocialSecurity: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const shifts = await db.shift.findMany({
|
||||
where: {
|
||||
localDate: {
|
||||
gte: new Date(`${firstDate}T00:00:00Z`),
|
||||
lte: new Date(`${lastDate}T00:00:00Z`),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
localDate: true,
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
breakMinutes: true,
|
||||
actualStartAt: true,
|
||||
actualEndAt: true,
|
||||
actualBreakMinutes: true,
|
||||
},
|
||||
});
|
||||
|
||||
const points = months.map((month) => {
|
||||
const dates = monthDates(month);
|
||||
const from = dates[0] as string;
|
||||
const to = dates[dates.length - 1] as string;
|
||||
|
||||
const counts = headcount(contractInputs, from, to);
|
||||
|
||||
// Une absence à cheval sur deux mois est rattachée au mois de son début :
|
||||
// `countedDays` a été figé à la décision et ne se redécoupe pas sans
|
||||
// refaire le décompte des fériés, ce qui produirait un autre chiffre que
|
||||
// celui écrit au registre des compteurs.
|
||||
const monthAbsences: AbsenceInput[] = timeOffs
|
||||
.filter((entry) => {
|
||||
const start = entry.startDate.toISOString().slice(0, 10);
|
||||
return start >= from && start <= to;
|
||||
})
|
||||
.map((entry) => ({
|
||||
membershipId: entry.id,
|
||||
timeOffId: entry.id,
|
||||
days: Number(entry.countedDays?.toString() ?? '0'),
|
||||
isSocialSecurity: entry.absenceType.isSocialSecurity,
|
||||
}));
|
||||
|
||||
const theoreticalWorkedDays =
|
||||
workingDaysIn(dates) * Math.round(counts.average);
|
||||
const absence = absenteeism(monthAbsences, theoreticalWorkedDays);
|
||||
|
||||
const monthShifts = shifts.filter((shift) => {
|
||||
const day = shift.localDate.toISOString().slice(0, 10);
|
||||
return day >= from && day <= to;
|
||||
});
|
||||
|
||||
const plannedMinutes = monthShifts.reduce(
|
||||
(sum, shift) =>
|
||||
sum + shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes),
|
||||
0,
|
||||
);
|
||||
|
||||
const actualMinutes = monthShifts.reduce((sum, shift) => {
|
||||
if (!shift.actualStartAt || !shift.actualEndAt) {
|
||||
// Sans heures réelles saisies, le prévu fait foi (PLAN.md §7.3) :
|
||||
// laisser un zéro creuserait un écart qui n'existe pas.
|
||||
return sum + shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes);
|
||||
}
|
||||
return (
|
||||
sum +
|
||||
shiftMinutes(
|
||||
shift.actualStartAt,
|
||||
shift.actualEndAt,
|
||||
shift.actualBreakMinutes ?? shift.breakMinutes,
|
||||
)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
month,
|
||||
label: monthLabel(month),
|
||||
param: formatMonthParam(month),
|
||||
closingHeadcount: counts.closing,
|
||||
averageHeadcount: counts.average,
|
||||
entries: counts.entries.length,
|
||||
exits: counts.exits.length,
|
||||
turnoverRate: turnoverRate(counts),
|
||||
absenceDays: absence.totalDays,
|
||||
sickDays: absence.sickDays,
|
||||
absenteeismRate: absence.rate,
|
||||
plannedMinutes,
|
||||
actualMinutes,
|
||||
theoreticalWorkedDays,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
location: location ? { id: location.id, name: location.name } : null,
|
||||
locations: visible,
|
||||
points,
|
||||
};
|
||||
}, locationId ? { locationId } : undefined);
|
||||
}
|
||||
@@ -13,19 +13,21 @@ import {
|
||||
type InstallationForm,
|
||||
} from '@/domain/install/rules';
|
||||
import { hashPassword } from '@/server/auth/session';
|
||||
import { installReferentials } from '@/server/install/referentials';
|
||||
|
||||
/**
|
||||
* Contenu d'une instance neuve — PLAN.md §5.
|
||||
*
|
||||
* Le seed, lui, installe une démonstration : deux établissements, des salariés
|
||||
* fictifs, quatre semaines de planning. Il refuse de tourner en production, et
|
||||
* c'est bien ainsi. Il restait donc un trou : après les migrations, une
|
||||
* instance de production a le schéma et rien d'autre — pas de compte, pas
|
||||
* d'utilisateur, personne pour se connecter.
|
||||
* Le seed, lui, installe une démonstration : des salariés fictifs, des
|
||||
* plannings, des absences. Il refuse de tourner en production, et c'est bien
|
||||
* ainsi. Il restait donc un trou : après les migrations, une instance de
|
||||
* production a le schéma et rien d'autre — pas de compte, pas d'utilisateur,
|
||||
* personne pour se connecter.
|
||||
*
|
||||
* Ce module comble ce trou, et **rien de plus** : le catalogue des capacités,
|
||||
* les rôles fournis, un compte, un établissement, un propriétaire. Aucune
|
||||
* donnée d'exemple.
|
||||
* Ce module comble ce trou : catalogue des capacités, rôles fournis, compte,
|
||||
* établissement, propriétaire, puis les **référentiels** sans lesquels rien ne
|
||||
* s'accroche (voir `referentials.ts`). Aucune donnée d'exemple : pas un
|
||||
* salarié, pas un créneau, pas une absence.
|
||||
*/
|
||||
|
||||
export interface InstalledInstance {
|
||||
@@ -105,7 +107,7 @@ export async function installAccount(
|
||||
});
|
||||
}
|
||||
|
||||
await tx.location.create({
|
||||
const location = await tx.location.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
name: data.locationName,
|
||||
@@ -113,6 +115,19 @@ export async function installAccount(
|
||||
},
|
||||
});
|
||||
|
||||
// Une première équipe. Le planning s'ordonne par équipe : sans elle,
|
||||
// l'établissement neuf n'a aucune ligne où poser un créneau, et l'écran de
|
||||
// planning annonce « pas encore d'équipe » à qui vient de tout installer.
|
||||
await tx.team.create({
|
||||
data: { accountId: account.id, locationId: location.id, name: 'Équipe' },
|
||||
});
|
||||
|
||||
// Étiquettes, types d'absence, convention d'amorce, conservation et jours
|
||||
// fériés. Ce ne sont pas des exemples : sans eux, l'instance a le schéma et
|
||||
// rien à quoi l'accrocher — pas de demande d'absence saisissable, pas de
|
||||
// créneau nommé, et un moteur de règles muet.
|
||||
await installReferentials(tx, account.id, location.id);
|
||||
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { frenchHolidays } from '@/domain/absences/holidays';
|
||||
import { IDCC_1517_PARAMETERS, IDCC_1517_PROVENANCE } from '@/domain/compliance/idcc1517';
|
||||
import { POSTE_CODES, POSTE_LABELS } from '@/lib/design/postes';
|
||||
|
||||
/**
|
||||
* Référentiels d'une instance neuve — PLAN.md §5 et WP-02.
|
||||
*
|
||||
* Sans eux, une instance installée a le schéma et rien à quoi l'accrocher :
|
||||
* aucun type d'absence, donc aucune demande saisissable ; aucune étiquette,
|
||||
* donc aucun créneau nommé ; aucune convention, donc un moteur de règles muet
|
||||
* qui laisse passer une semaine de 60 heures sans rien dire. Le trou se
|
||||
* comblait jusqu'ici avec le seed de démonstration, qui refuse de tourner en
|
||||
* production — l'instance de production restait donc inutilisable.
|
||||
*
|
||||
* **Ce ne sont pas des données d'exemple.** Chaque ligne posée ici est soit un
|
||||
* minimum légal, soit une valeur d'amorce que le client remplace depuis les
|
||||
* réglages. Rien de fictif : ni salarié, ni planning, ni absence.
|
||||
*/
|
||||
|
||||
const AMORCE_DATE = new Date('2026-01-01');
|
||||
|
||||
/** Politiques de conservation — durées et justifications de §12.5. */
|
||||
const RETENTION: ReadonlyArray<
|
||||
readonly [string, number, string, string]
|
||||
> = [
|
||||
['Shift', 12, 'creation', 'Décompte des horaires : 1 an minimum (matrice n° 21).'],
|
||||
['ForfaitDayEntry', 36, 'creation', 'Décompte des jours de forfait : 3 ans minimum.'],
|
||||
['UserContract', 60, 'contract_end', 'Pièces contractuelles : 5 ans.'],
|
||||
[
|
||||
'PersonnelRegister',
|
||||
60,
|
||||
'employee_departure',
|
||||
'Registre du personnel : 5 ans après le départ.',
|
||||
],
|
||||
[
|
||||
'PayrollVariable',
|
||||
72,
|
||||
'period_end',
|
||||
"Éléments d'assiette transmis au logiciel de paie : 6 ans.",
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Types d'absence d'amorce.
|
||||
*
|
||||
* Liste relevée sur le sélecteur du produit audité, recoupée avec les rubriques
|
||||
* du dossier de paie. Elle couvre ce qu'un commerce de détail rencontre
|
||||
* réellement — un « congé sans solde » et un « congé payé » ne suffisent pas à
|
||||
* tenir un registre.
|
||||
*
|
||||
* **Les codes sont internes et stables.** Ils ne sont montrés nulle part et ne
|
||||
* correspondent à aucune nomenclature externe : ils servent à référencer un
|
||||
* type sans dépendre de son libellé, qui se renomme.
|
||||
*
|
||||
* `silaeCode` reste **nul** : la correspondance entre un type et une rubrique
|
||||
* de paie appartient au dossier du client. La renseigner ici imputerait des
|
||||
* congés à la rubrique d'un autre cabinet.
|
||||
*
|
||||
* `À VALIDER` — les drapeaux sont des valeurs d'amorce. `isSocialSecurity` suit
|
||||
* la nature de l'arrêt et ne se discute pas ; l'acquisition de congés pendant
|
||||
* une absence, en revanche, dépend de la convention et se règle depuis
|
||||
* `/reglages/types-absence`.
|
||||
*
|
||||
* « Repos hebdomadaire » figure au sélecteur du produit audité et **pas ici** :
|
||||
* c'est un repos légal, porté par le modèle `Rest`, pas une absence qui se
|
||||
* demande. En faire un type d'absence laisserait un manager refuser une
|
||||
* obligation.
|
||||
*/
|
||||
const ABSENCE_TYPES = [
|
||||
// --- Congés et repos ----------------------------------------------------
|
||||
{ code: 'CP', name: 'Congé payé', colorKey: 'cp', isPaid: true, social: false, notice: 30, accrual: true, justify: false },
|
||||
{ code: 'RTT', name: 'RTT', colorKey: 'rtt', isPaid: true, social: false, notice: 7, accrual: true, justify: false },
|
||||
{ code: 'RC', name: 'Repos compensateur', colorKey: 'rtt', isPaid: true, social: false, notice: 7, accrual: true, justify: false },
|
||||
{ code: 'RC_NUIT', name: 'Repos compensateur de nuit', colorKey: 'rtt', isPaid: true, social: false, notice: 7, accrual: true, justify: false },
|
||||
{ code: 'RC_HAB', name: "Repos compensateur d'habillement", colorKey: 'rtt', isPaid: true, social: false, notice: 7, accrual: true, justify: false },
|
||||
{ code: 'RECUP_JF', name: 'Récupération jour férié', colorKey: 'rtt', isPaid: true, social: false, notice: 7, accrual: true, justify: false },
|
||||
{ code: 'JF', name: 'Jour férié', colorKey: 'ferie', isPaid: true, social: false, notice: null, accrual: true, justify: false },
|
||||
{ code: 'CSS', name: 'Congé sans solde', colorKey: 'sans-solde', isPaid: false, social: false, notice: 15, accrual: false, justify: false },
|
||||
|
||||
// --- Santé et accidents — catégories particulières -----------------------
|
||||
{ code: 'MAL', name: 'Arrêt maladie', colorKey: 'maladie', isPaid: false, social: true, notice: null, accrual: false, justify: true },
|
||||
{ code: 'MP', name: 'Maladie professionnelle', colorKey: 'maladie', isPaid: false, social: true, notice: null, accrual: true, justify: true },
|
||||
{ code: 'AT', name: 'Accident du travail', colorKey: 'maladie', isPaid: false, social: true, notice: null, accrual: true, justify: true },
|
||||
{ code: 'AT_TRAJET', name: 'Accident de trajet', colorKey: 'maladie', isPaid: false, social: true, notice: null, accrual: true, justify: true },
|
||||
{ code: 'VM', name: 'Visite médicale', colorKey: 'maladie', isPaid: true, social: false, notice: null, accrual: true, justify: true },
|
||||
|
||||
// --- Famille -------------------------------------------------------------
|
||||
{ code: 'MAT', name: 'Congé maternité', colorKey: 'famille', isPaid: false, social: true, notice: null, accrual: true, justify: true },
|
||||
{ code: 'PAT', name: 'Congé paternité', colorKey: 'famille', isPaid: false, social: true, notice: null, accrual: true, justify: true },
|
||||
{ code: 'PAR', name: 'Congé parental', colorKey: 'famille', isPaid: false, social: false, notice: 30, accrual: false, justify: true },
|
||||
{ code: 'NAISS', name: 'Congé supplémentaire de naissance', colorKey: 'famille', isPaid: true, social: false, notice: null, accrual: true, justify: true },
|
||||
{ code: 'EVT_FAM', name: 'Évènement familial', colorKey: 'famille', isPaid: true, social: false, notice: null, accrual: true, justify: true },
|
||||
|
||||
// --- Formation et obligations -------------------------------------------
|
||||
{ code: 'FORM', name: 'Formation', colorKey: 'formation', isPaid: true, social: false, notice: 15, accrual: true, justify: false },
|
||||
{ code: 'EXAM', name: 'Congé pour examen', colorKey: 'formation', isPaid: true, social: false, notice: 15, accrual: true, justify: true },
|
||||
|
||||
// --- Absences non planifiées et sanctions -------------------------------
|
||||
{ code: 'ABS_JUS', name: 'Absence justifiée', colorKey: 'sans-solde', isPaid: false, social: false, notice: null, accrual: false, justify: true },
|
||||
{ code: 'ABS_INJ', name: 'Absence injustifiée', colorKey: 'sans-solde', isPaid: false, social: false, notice: null, accrual: false, justify: false },
|
||||
{ code: 'INDISPO', name: 'Indisponibilité ponctuelle', colorKey: 'sans-solde', isPaid: false, social: false, notice: null, accrual: false, justify: false },
|
||||
{ code: 'MAP_D', name: 'Mise à pied disciplinaire', colorKey: 'sanction', isPaid: false, social: false, notice: null, accrual: false, justify: true },
|
||||
{ code: 'MAP_C', name: 'Mise à pied conservatoire', colorKey: 'sanction', isPaid: false, social: false, notice: null, accrual: false, justify: true },
|
||||
] as const;
|
||||
|
||||
export async function installReferentials(
|
||||
tx: Prisma.TransactionClient,
|
||||
accountId: string,
|
||||
locationId: string,
|
||||
): Promise<void> {
|
||||
// --- Étiquettes de planning --------------------------------------------
|
||||
// La palette est celle du produit, calculée pour rester distinguable en
|
||||
// vision daltonienne. Les libellés se renomment depuis les réglages.
|
||||
for (const [index, code] of POSTE_CODES.entries()) {
|
||||
await tx.label.create({
|
||||
data: {
|
||||
accountId,
|
||||
code: code.toUpperCase(),
|
||||
name: POSTE_LABELS[code],
|
||||
paletteKey: code,
|
||||
position: index,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Types d'absence ----------------------------------------------------
|
||||
for (const type of ABSENCE_TYPES) {
|
||||
await tx.absenceType.create({
|
||||
data: {
|
||||
accountId,
|
||||
code: type.code,
|
||||
name: type.name,
|
||||
colorKey: type.colorKey,
|
||||
isPaid: type.isPaid,
|
||||
countsAsWorkTime: false,
|
||||
affectsPaidLeaveAccrual: type.accrual,
|
||||
isSocialSecurity: type.social,
|
||||
requiresJustification: type.justify,
|
||||
minNoticeDays: type.notice,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Convention d'amorce ------------------------------------------------
|
||||
// IDCC 1517 par défaut (PLAN.md §2), remplaçable depuis les réglages : la
|
||||
// convention se réédite en version datée, elle ne se modifie pas.
|
||||
await tx.collectiveAgreement.create({
|
||||
data: {
|
||||
accountId,
|
||||
idcc: '1517',
|
||||
name: 'Commerces de détail non alimentaires',
|
||||
parameters: IDCC_1517_PARAMETERS as never,
|
||||
version: 1,
|
||||
effectiveFrom: AMORCE_DATE,
|
||||
source:
|
||||
'Sources secondaires publiques — À VALIDER contre le texte consolidé Legifrance',
|
||||
},
|
||||
});
|
||||
|
||||
// --- Registre de paramétrage juridique ----------------------------------
|
||||
// L'origine de chaque valeur — ordre public, convention, accord d'entreprise
|
||||
// — décide de ce qui s'impose et de ce qui se négocie. Elle est enregistrée
|
||||
// avec sa source, pas seulement commentée dans le code (§12.7).
|
||||
for (const entry of IDCC_1517_PROVENANCE) {
|
||||
await tx.legalConfigEntry.create({
|
||||
data: {
|
||||
accountId,
|
||||
domain: 'temps',
|
||||
key: entry.key,
|
||||
value: `${entry.label} : ${entry.value}`,
|
||||
source: `[${entry.origin}] ${entry.source}`,
|
||||
population: 'Tous salariés',
|
||||
effectiveFrom: AMORCE_DATE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Durées de conservation ---------------------------------------------
|
||||
for (const [objectType, durationMonths, startPoint, justification] of RETENTION) {
|
||||
await tx.retentionPolicy.create({
|
||||
data: {
|
||||
accountId,
|
||||
objectType,
|
||||
durationMonths,
|
||||
startPoint,
|
||||
justification,
|
||||
effectiveFrom: AMORCE_DATE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Jours fériés --------------------------------------------------------
|
||||
// Calculés, pas listés : une table écrite à la main n'est juste que l'année
|
||||
// où on l'écrit. Un férié manquant se décompte comme un jour de congé, et le
|
||||
// salarié perd un jour sans que personne ne le voie.
|
||||
//
|
||||
// Seul le 1er mai est chômé de droit. Les jours garantis par la convention
|
||||
// sont choisis par l'employeur : ils ne se devinent pas ici.
|
||||
const currentYear = new Date().getUTCFullYear();
|
||||
for (const year of [currentYear, currentYear + 1]) {
|
||||
for (const holiday of frenchHolidays(year)) {
|
||||
await tx.holiday.create({
|
||||
data: {
|
||||
accountId,
|
||||
locationId,
|
||||
localDate: new Date(`${holiday.isoDate}T00:00:00Z`),
|
||||
name: holiday.name,
|
||||
isPaidOff: holiday.isoDate.slice(5) === '05-01',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Aucun dimanche du maire n'est posé : la liste est arrêtée par arrêté
|
||||
// municipal, établissement par établissement. En inventer rendrait opposable
|
||||
// un quota qui n'a été autorisé nulle part.
|
||||
}
|
||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user