Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f656d05af | ||
|
|
d8060eb4a9 | ||
|
|
f0ef6f12a1 | ||
|
|
8c12b08961 | ||
|
|
db03768025 | ||
|
|
5b60e7ddb6 | ||
|
|
1519b6bf9d | ||
|
|
e4be93a208 | ||
|
|
98b289784a | ||
|
|
527ea2e78a | ||
|
|
9e57467138 | ||
|
|
b608aa87cf | ||
|
|
06198ddbce | ||
|
|
e27d61dd4c | ||
|
|
b49ceb1f29 | ||
|
|
1d64989239 | ||
|
|
d4b655f369 | ||
|
|
b184109e23 |
No files matched your search
+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"]
|
||||
+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
|
||||
|
||||
@@ -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);
|
||||
+171
-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,16 +572,41 @@ 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?
|
||||
|
||||
/// Envoi des plannings par SMS. Un consentement, donc explicitement faux
|
||||
/// par défaut : la charge de la preuve pèse sur l'employeur.
|
||||
smsSchedules Boolean @default(false)
|
||||
|
||||
socialSecurityNumberEnc Bytes?
|
||||
ibanEnc Bytes?
|
||||
bicEnc Bytes?
|
||||
@@ -505,6 +621,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 +666,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
|
||||
|
||||
@@ -744,6 +883,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 +929,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 +940,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])
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
+55
-14
@@ -22,6 +22,11 @@ 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';
|
||||
|
||||
@@ -586,9 +591,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 +606,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 +720,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 +741,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 +849,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,22 +2,25 @@
|
||||
|
||||
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 = {};
|
||||
@@ -37,13 +40,28 @@ const CONTRACT_TYPES = [
|
||||
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 +71,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 +88,73 @@ 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>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="smsSchedules"
|
||||
className="mt-0.5 size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Envoyer les plannings par SMS</span>
|
||||
<span className="block text-micro text-ink-3">
|
||||
Consentement du salarié : à recueillir avant de l’activer.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Field label="Téléphone fixe" name="landline" type="tel" />
|
||||
</Section>
|
||||
|
||||
{locations.length > 0 ? (
|
||||
<>
|
||||
<Section title="Contrat">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -101,63 +167,36 @@ 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>
|
||||
|
||||
<label className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Type de contrat</span>
|
||||
<select name="contractType" className={selectClass}>
|
||||
{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 +226,7 @@ export function AddEmployeeForm({
|
||||
/>
|
||||
) : (
|
||||
<Field
|
||||
label="Durée hebdomadaire"
|
||||
label="Temps de travail hebdomadaire"
|
||||
name="weeklyHours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
@@ -214,28 +253,85 @@ 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>
|
||||
|
||||
<label className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Équipe</span>
|
||||
<select name="teamId" className={selectClass}>
|
||||
<option value="">Aucune pour l’instant</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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,12 @@ 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 {
|
||||
updateProfileAction,
|
||||
updateSensitiveAction,
|
||||
@@ -21,14 +27,23 @@ 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;
|
||||
smsSchedules: boolean;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
country: string;
|
||||
@@ -46,6 +61,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,8 +140,20 @@ 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} />
|
||||
<Field label="Nationalité" name="nationality" defaultValue={profile.nationality} />
|
||||
<Field
|
||||
label="Date de naissance"
|
||||
name="birthDate"
|
||||
@@ -98,12 +161,34 @@ export function PersonalInfoPanel({
|
||||
defaultValue={profile.birthDate}
|
||||
/>
|
||||
<Field
|
||||
label="Lieu de naissance"
|
||||
label="Pays de naissance"
|
||||
name="birthCountry"
|
||||
defaultValue={profile.birthCountry}
|
||||
/>
|
||||
<Field
|
||||
label="Département de naissance"
|
||||
name="birthDepartment"
|
||||
defaultValue={profile.birthDepartment}
|
||||
/>
|
||||
<Field
|
||||
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,7 +201,27 @@ export function PersonalInfoPanel({
|
||||
defaultValue={profile.personalEmail}
|
||||
/>
|
||||
<Field label="Téléphone mobile" name="phone" type="tel" defaultValue={profile.phone} />
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="smsSchedules"
|
||||
defaultChecked={profile.smsSchedules}
|
||||
className="mt-0.5 size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Notifications SMS</span>
|
||||
<span className="block text-micro text-ink-3">
|
||||
Consentement du salarié : à recueillir avant de l’activer.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<Field label="Téléphone fixe" name="landline" type="tel" defaultValue={profile.landline} />
|
||||
<Field label="Adresse" name="addressLine1" defaultValue={profile.addressLine1} />
|
||||
<Field
|
||||
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} />
|
||||
@@ -158,17 +263,36 @@ 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="Date de naissance" value={readableDate(profile.birthDate)} tnum />
|
||||
<InfoRow label="Lieu de naissance" value={profile.birthPlace} />
|
||||
<InfoRow label="Nationalité" value={profile.nationality} />
|
||||
<InfoRow label="Date de naissance" value={readableDate(profile.birthDate)} tnum />
|
||||
<InfoRow label="Pays de naissance" value={profile.birthCountry} />
|
||||
<InfoRow label="Département de naissance" value={profile.birthDepartment} />
|
||||
<InfoRow label="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="Notifications SMS"
|
||||
value={profile.smsSchedules ? 'Oui' : 'Non'}
|
||||
/>
|
||||
<InfoRow label="Téléphone fixe" value={profile.landline} tnum />
|
||||
<InfoRow label="Adresse" value={profile.addressLine1} />
|
||||
<InfoRow label="Complément d’adresse" value={profile.addressLine2} />
|
||||
<InfoRow label="Code postal" value={profile.postalCode} tnum />
|
||||
<InfoRow label="Ville" value={profile.city} />
|
||||
<InfoRow label="Pays" value={profile.country} />
|
||||
|
||||
@@ -33,16 +33,28 @@ 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),
|
||||
smsSchedules: employee.profile.smsSchedules,
|
||||
addressLine1: text(employee.profile.addressLine1),
|
||||
addressLine2: text(employee.profile.addressLine2),
|
||||
postalCode: text(employee.profile.postalCode),
|
||||
city: text(employee.profile.city),
|
||||
country: text(employee.profile.country),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,156 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
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é"
|
||||
/>
|
||||
<Field
|
||||
label="Clé de couleur"
|
||||
name="colorKey"
|
||||
required
|
||||
maxLength={40}
|
||||
defaultValue={row?.colorKey ?? 'neutral'}
|
||||
/>
|
||||
<Field
|
||||
label="Délai de prévenance"
|
||||
name="minNoticeDays"
|
||||
type="number"
|
||||
min={0}
|
||||
max={365}
|
||||
defaultValue={row?.minNoticeDays ?? ''}
|
||||
hint="En jours. Vide : aucun délai."
|
||||
/>
|
||||
<Field
|
||||
label="Code Silae"
|
||||
name="silaeCode"
|
||||
maxLength={20}
|
||||
defaultValue={row?.silaeCode ?? ''}
|
||||
hint="Partie « code » de AB-<code>. Sans lui, l’export de la période échoue."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddAbsenceTypeForm() {
|
||||
const [state, action] = useActionState(createAbsenceTypeAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={action}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Code"
|
||||
name="code"
|
||||
required
|
||||
maxLength={20}
|
||||
placeholder="CP"
|
||||
hint="Identifiant stable du type. Il ne se renomme pas."
|
||||
/>
|
||||
</div>
|
||||
<SharedFields />
|
||||
<Flags />
|
||||
<FormError>{state.error}</FormError>
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Ajouter le type</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Type enregistré.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditAbsenceTypeForm({ row }: { row: AbsenceTypeRow }) {
|
||||
const [state, action] = useActionState(updateAbsenceTypeAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="id" value={row.id} />
|
||||
<SharedFields row={row} />
|
||||
<Flags row={row} />
|
||||
<FormError>{state.error}</FormError>
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton size="sm">Enregistrer</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Modifications prises.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
AddAbsenceTypeForm,
|
||||
EditAbsenceTypeForm,
|
||||
} from '@/app/(app)/reglages/types-absence/AbsenceTypeForms';
|
||||
import {
|
||||
archiveAbsenceTypeAction,
|
||||
listAbsenceTypes,
|
||||
type AbsenceTypeRow,
|
||||
} from '@/server/settings/absence-types';
|
||||
|
||||
export const metadata = { title: 'Types d’absence · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function Summary({ row }: { row: AbsenceTypeRow }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge tone={row.isPaid ? 'ok' : 'neutral'}>
|
||||
{row.isPaid ? 'Rémunérée' : 'Non rémunérée'}
|
||||
</Badge>
|
||||
{row.isSocialSecurity ? <Badge tone="info">Sécurité sociale</Badge> : null}
|
||||
{row.countsAsWorkTime ? <Badge tone="neutral">Temps de travail</Badge> : null}
|
||||
{row.affectsPaidLeaveAccrual ? (
|
||||
<Badge tone="neutral">Acquisition CP</Badge>
|
||||
) : null}
|
||||
{row.requiresJustification ? (
|
||||
<Badge tone="warn">Justificatif</Badge>
|
||||
) : null}
|
||||
{row.silaeCode ? (
|
||||
<Badge tone="neutral">AB-{row.silaeCode}</Badge>
|
||||
) : (
|
||||
<Badge tone="danger">Code Silae manquant</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function TypesAbsencePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ archives?: string }>;
|
||||
}) {
|
||||
const { archives } = await searchParams;
|
||||
const showArchived = archives === '1';
|
||||
const types = await listAbsenceTypes(showArchived);
|
||||
const active = types.filter((type) => type.archivedAt === null);
|
||||
const missingSilae = active.filter((type) => !type.silaeCode).length;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Types d’absence"
|
||||
subtitle={`${active.length} type${active.length > 1 ? 's' : ''} en vigueur`}
|
||||
/>
|
||||
|
||||
{missingSilae > 0 ? (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs text-warn-soft-ink"
|
||||
>
|
||||
{missingSilae} type{missingSilae > 1 ? 's' : ''} sans code Silae.
|
||||
L’export d’une période contenant ces absences échouera, avec la liste
|
||||
des manques — aucun fichier partiel n’est produit.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Référentiel"
|
||||
action={
|
||||
<a
|
||||
href={
|
||||
showArchived
|
||||
? '/reglages/types-absence'
|
||||
: '/reglages/types-absence?archives=1'
|
||||
}
|
||||
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
|
||||
>
|
||||
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
{types.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun type d’absence"
|
||||
description="Sans type, aucune demande n’est saisissable. Commencez par le congé payé."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{types.map((type) => (
|
||||
<li key={type.id} className="px-4 py-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Badge tone="accent">{type.code}</Badge>
|
||||
<span className="text-sm font-semibold">{type.name}</span>
|
||||
<Badge tone="neutral">
|
||||
{type.timeOffCount} absence
|
||||
{type.timeOffCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{type.archivedAt ? <Badge tone="warn">Archivé</Badge> : null}
|
||||
<span className="flex-1" />
|
||||
<form action={archiveAbsenceTypeAction}>
|
||||
<input type="hidden" name="id" value={type.id} />
|
||||
{type.archivedAt ? (
|
||||
<input type="hidden" name="restore" value="1" />
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" type="submit">
|
||||
{type.archivedAt ? 'Rétablir' : 'Archiver'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<Summary row={type} />
|
||||
</div>
|
||||
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
|
||||
Modifier
|
||||
</summary>
|
||||
<div className="pt-3">
|
||||
<EditAbsenceTypeForm row={type} />
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Nouveau type" />
|
||||
<div className="p-4">
|
||||
<AddAbsenceTypeForm />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Le code d’un type ne se renomme pas : il est référencé par les absences
|
||||
déjà saisies et par la correspondance de paie. Un type devenu inutile
|
||||
s’archive — les absences passées gardent alors un libellé lisible et les
|
||||
écritures de compteur restent justifiables.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import type { AnalyticsSeries } from '@/server/hr/analytics';
|
||||
|
||||
/**
|
||||
* Cadre commun aux trois analyses.
|
||||
*
|
||||
* Même en-tête, même sélecteur d'établissement, mêmes onglets : trois écrans
|
||||
* qui répondent à trois questions sur le même jeu de données n'ont pas de
|
||||
* raison de se présenter différemment.
|
||||
*/
|
||||
|
||||
export const ANALYSES = [
|
||||
{ key: 'effectifs', href: '/rh/analyses/effectifs', label: 'Effectifs' },
|
||||
{ key: 'heures', href: '/rh/analyses/heures', label: 'Heures' },
|
||||
{ key: 'absences', href: '/rh/analyses/absences', label: 'Absences' },
|
||||
] as const;
|
||||
|
||||
export type AnalysisKey = (typeof ANALYSES)[number]['key'];
|
||||
|
||||
export function AnalysisShell({
|
||||
current,
|
||||
title,
|
||||
subtitle,
|
||||
series,
|
||||
children,
|
||||
footnote,
|
||||
}: {
|
||||
current: AnalysisKey;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
series: AnalyticsSeries;
|
||||
children: React.ReactNode;
|
||||
footnote: string;
|
||||
}) {
|
||||
const withLocation = (href: string) =>
|
||||
series.location ? `${href}?etablissement=${series.location.id}` : href;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title={title}
|
||||
subtitle={`${series.location?.name ?? 'Tous établissements'} · ${subtitle}`}
|
||||
actions={
|
||||
<nav aria-label="Analyses" className="flex gap-1">
|
||||
{ANALYSES.map((analysis) => (
|
||||
<Link
|
||||
key={analysis.key}
|
||||
href={withLocation(analysis.href)}
|
||||
aria-current={analysis.key === current ? 'page' : undefined}
|
||||
className={
|
||||
analysis.key === current
|
||||
? 'flex h-8 items-center rounded-2 border border-line-3 bg-surface-2 px-3 text-sm font-medium text-ink-1'
|
||||
: 'flex h-8 items-center rounded-2 px-3 text-sm text-ink-2 hover:bg-surface-2'
|
||||
}
|
||||
>
|
||||
{analysis.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
}
|
||||
/>
|
||||
|
||||
{series.locations.length > 1 ? (
|
||||
<Card>
|
||||
<CardHeader title="Établissement" />
|
||||
<div className="flex flex-wrap gap-1 px-4 py-3">
|
||||
{series.locations.map((location) => (
|
||||
<Link
|
||||
key={location.id}
|
||||
href={`${ANALYSES.find((a) => a.key === current)?.href}?etablissement=${location.id}`}
|
||||
aria-current={
|
||||
location.id === series.location?.id ? 'page' : undefined
|
||||
}
|
||||
className={
|
||||
location.id === series.location?.id
|
||||
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
|
||||
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
|
||||
}
|
||||
>
|
||||
{location.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
{footnote}
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mois abrégé, pour l'axe du graphique. « septembre 2026 » → « sept. 26 ». */
|
||||
export function shortMonth(label: string): string {
|
||||
const [name, year] = label.split(' ');
|
||||
if (!name || !year) return label;
|
||||
const abbreviated = name.length > 4 ? `${name.slice(0, 4)}.` : name;
|
||||
return `${abbreviated} ${year.slice(2)}`;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
AnalysisShell,
|
||||
shortMonth,
|
||||
} from '@/app/(app)/rh/analyses/AnalysisShell';
|
||||
import { SeriesChart } from '@/components/hr/SeriesChart';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { monthOf } from '@/domain/planning/month';
|
||||
import { getAnalyticsSeries } from '@/server/hr/analytics';
|
||||
|
||||
export const metadata = { title: 'Analyse des absences · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ etablissement?: string }>;
|
||||
}
|
||||
|
||||
export default async function AbsencesAnalysePage({ searchParams }: PageProps) {
|
||||
const { etablissement } = await searchParams;
|
||||
const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
|
||||
const last = series.points[series.points.length - 1];
|
||||
|
||||
return (
|
||||
<AnalysisShell
|
||||
current="absences"
|
||||
title="Absences"
|
||||
subtitle={
|
||||
last?.absenteeismRate === null || last === undefined
|
||||
? 'Taux non calculable'
|
||||
: `${last.absenteeismRate} % d’absentéisme ce mois`
|
||||
}
|
||||
series={series}
|
||||
footnote="Le taux rapporte les jours d’absence aux jours théoriquement travaillés, pas aux jours calendaires : rapporter à trente donnerait un taux artificiellement bas et ferait passer un problème réel pour du bruit. La colonne « dont arrêts » agrège les absences relevant de la Sécurité sociale sans jamais nommer de salarié — un taux ne doit pas devenir un moyen d’identifier qui est malade."
|
||||
>
|
||||
<Card>
|
||||
<CardHeader title="Taux d’absentéisme mensuel" />
|
||||
<SeriesChart
|
||||
tone="warn"
|
||||
points={series.points.map((point) => ({
|
||||
label: point.label,
|
||||
short: shortMonth(point.label),
|
||||
value: point.absenteeismRate,
|
||||
}))}
|
||||
format={(value) => `${value} %`}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Détail mensuel" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left">
|
||||
{[
|
||||
'Mois',
|
||||
'Jours d’absence',
|
||||
'dont arrêts',
|
||||
'Jours théoriques',
|
||||
'Taux',
|
||||
].map((heading) => (
|
||||
<th
|
||||
key={heading}
|
||||
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
{heading}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line-1">
|
||||
{[...series.points].reverse().map((point) => (
|
||||
<tr key={point.param}>
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
href={`/?mois=${point.param}&liste=absences${series.location ? `&etablissement=${series.location.id}` : ''}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{point.label}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 tnum">{point.absenceDays}</td>
|
||||
<td className="px-4 py-2 tnum">{point.sickDays}</td>
|
||||
<td className="px-4 py-2 tnum">
|
||||
{point.theoreticalWorkedDays}
|
||||
</td>
|
||||
<td className="px-4 py-2 tnum">
|
||||
{point.absenteeismRate === null
|
||||
? '—'
|
||||
: `${point.absenteeismRate} %`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</AnalysisShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
AnalysisShell,
|
||||
shortMonth,
|
||||
} from '@/app/(app)/rh/analyses/AnalysisShell';
|
||||
import { SeriesChart } from '@/components/hr/SeriesChart';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { monthOf } from '@/domain/planning/month';
|
||||
import { getAnalyticsSeries } from '@/server/hr/analytics';
|
||||
|
||||
export const metadata = { title: 'Analyse des effectifs · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ etablissement?: string }>;
|
||||
}
|
||||
|
||||
export default async function EffectifsPage({ searchParams }: PageProps) {
|
||||
const { etablissement } = await searchParams;
|
||||
const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
|
||||
const last = series.points[series.points.length - 1];
|
||||
|
||||
return (
|
||||
<AnalysisShell
|
||||
current="effectifs"
|
||||
title="Effectifs"
|
||||
subtitle={`${last?.closingHeadcount ?? 0} salariés en fin de mois`}
|
||||
series={series}
|
||||
footnote="Le taux de rotation rapporte la moyenne des entrées et des sorties à l’effectif moyen : ne compter que les départs sous-estimerait une équipe qui recrute autant qu’elle perd. Sur un effectif nul il n’est pas calculé — « 0 % » y serait une affirmation fausse, pas une absence de mouvement."
|
||||
>
|
||||
<Card>
|
||||
<CardHeader title="Effectif en fin de mois" />
|
||||
<SeriesChart
|
||||
points={series.points.map((point) => ({
|
||||
label: point.label,
|
||||
short: shortMonth(point.label),
|
||||
value: point.closingHeadcount,
|
||||
}))}
|
||||
format={(value) => String(value)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Détail mensuel" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left">
|
||||
{['Mois', 'Fin de mois', 'Moyen', 'Entrées', 'Sorties', 'Rotation'].map(
|
||||
(heading) => (
|
||||
<th
|
||||
key={heading}
|
||||
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
{heading}
|
||||
</th>
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line-1">
|
||||
{[...series.points].reverse().map((point) => (
|
||||
<tr key={point.param}>
|
||||
<td className="px-4 py-2">
|
||||
{/* Chaque chiffre mène à ses lignes sources : un indicateur
|
||||
qu'on ne peut pas ouvrir ne se corrige pas, il se
|
||||
conteste. */}
|
||||
<Link
|
||||
href={`/?mois=${point.param}&liste=entrees${series.location ? `&etablissement=${series.location.id}` : ''}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{point.label}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 tnum">{point.closingHeadcount}</td>
|
||||
<td className="px-4 py-2 tnum">{point.averageHeadcount}</td>
|
||||
<td className="px-4 py-2 tnum">{point.entries}</td>
|
||||
<td className="px-4 py-2 tnum">{point.exits}</td>
|
||||
<td className="px-4 py-2 tnum">
|
||||
{point.turnoverRate === null
|
||||
? '—'
|
||||
: `${point.turnoverRate} %`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</AnalysisShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
AnalysisShell,
|
||||
shortMonth,
|
||||
} from '@/app/(app)/rh/analyses/AnalysisShell';
|
||||
import { SeriesChart } from '@/components/hr/SeriesChart';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { monthOf } from '@/domain/planning/month';
|
||||
import { getAnalyticsSeries } from '@/server/hr/analytics';
|
||||
|
||||
export const metadata = { title: 'Analyse des heures · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ etablissement?: string }>;
|
||||
}
|
||||
|
||||
export default async function HeuresPage({ searchParams }: PageProps) {
|
||||
const { etablissement } = await searchParams;
|
||||
const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
|
||||
const last = series.points[series.points.length - 1];
|
||||
|
||||
return (
|
||||
<AnalysisShell
|
||||
current="heures"
|
||||
title="Heures"
|
||||
subtitle={
|
||||
last ? `${formatMinutes(last.plannedMinutes)} planifiées ce mois` : '—'
|
||||
}
|
||||
series={series}
|
||||
footnote="Le prévu et le réalisé sont deux états distincts du temps, et l’écart entre les deux est ce qu’un contrôle demande à justifier. Quand aucune heure réelle n’a été saisie sur un créneau, le prévu fait foi : laisser un zéro creuserait un écart qui n’existe pas."
|
||||
>
|
||||
<Card>
|
||||
<CardHeader title="Heures planifiées par mois" />
|
||||
<SeriesChart
|
||||
points={series.points.map((point) => ({
|
||||
label: point.label,
|
||||
short: shortMonth(point.label),
|
||||
value: Math.round(point.plannedMinutes / 60),
|
||||
}))}
|
||||
format={(value) => `${value} h`}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Prévu et réalisé"
|
||||
badge={<Badge tone="neutral">12 mois</Badge>}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left">
|
||||
{['Mois', 'Planifié', 'Réalisé', 'Écart', 'Effectif moyen'].map(
|
||||
(heading) => (
|
||||
<th
|
||||
key={heading}
|
||||
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
{heading}
|
||||
</th>
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line-1">
|
||||
{[...series.points].reverse().map((point) => {
|
||||
const delta = point.actualMinutes - point.plannedMinutes;
|
||||
return (
|
||||
<tr key={point.param}>
|
||||
<td className="px-4 py-2">{point.label}</td>
|
||||
<td className="px-4 py-2 tnum">
|
||||
{formatMinutes(point.plannedMinutes)}
|
||||
</td>
|
||||
<td className="px-4 py-2 tnum">
|
||||
{formatMinutes(point.actualMinutes)}
|
||||
</td>
|
||||
<td className="px-4 py-2 tnum">
|
||||
{delta === 0 ? (
|
||||
'—'
|
||||
) : (
|
||||
<Badge tone={delta > 0 ? 'warn' : 'info'}>
|
||||
{delta > 0 ? '+' : '−'}
|
||||
{formatMinutes(Math.abs(delta))}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 tnum">{point.averageHeadcount}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</AnalysisShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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])),
|
||||
);
|
||||
}
|
||||
@@ -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,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,
|
||||
);
|
||||
@@ -76,6 +76,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 +104,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[]);
|
||||
|
||||
@@ -199,6 +222,8 @@ export function buildRows(
|
||||
),
|
||||
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
|
||||
restDays: 7 - worked,
|
||||
compensatoryRestMinutes:
|
||||
compensatoryByMember.get(person.membershipId) ?? 0,
|
||||
},
|
||||
} satisfies BoardRow;
|
||||
});
|
||||
@@ -221,6 +246,7 @@ export function buildRows(
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 0,
|
||||
restDays: 0,
|
||||
compensatoryRestMinutes: 0,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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,17 @@ 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),
|
||||
smsSchedules: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -46,12 +53,19 @@ const hiringContractInput = z.object({
|
||||
teamId: z.string().trim(),
|
||||
contractType: z.enum(CONTRACT_TYPES),
|
||||
startDate: z.coerce.date(),
|
||||
/** Heure de prise de poste : la DPAE la demande, la date seule n'y suffit pas. */
|
||||
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 +85,29 @@ 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') ?? '',
|
||||
smsSchedules: formData.get('smsSchedules') === 'on',
|
||||
});
|
||||
|
||||
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 +122,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 +172,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 +217,15 @@ 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,
|
||||
smsSchedules: parsed.data.smsSchedules,
|
||||
} 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',
|
||||
|
||||
@@ -33,13 +33,44 @@ 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 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),
|
||||
birthCountry: optionalText(80),
|
||||
birthDepartment: optionalText(80),
|
||||
nationality: optionalText(80),
|
||||
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,7 +81,10 @@ const profileInput = z.object({
|
||||
)
|
||||
.transform((value) => (value === '' ? null : value)),
|
||||
phone: optionalText(30),
|
||||
landline: optionalText(30),
|
||||
smsSchedules: z.boolean(),
|
||||
addressLine1: optionalText(180),
|
||||
addressLine2: optionalText(180),
|
||||
postalCode: optionalText(12),
|
||||
city: optionalText(120),
|
||||
country: optionalText(80),
|
||||
@@ -62,18 +96,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 +123,10 @@ export async function updateProfileAction(
|
||||
'emergencyContactPhone',
|
||||
].map((key) => [key, formData.get(key) ?? '']),
|
||||
),
|
||||
);
|
||||
// Une case décochée n'est pas envoyée : son absence vaut « non », et la
|
||||
// lire comme une chaîne vide en ferait une valeur inconnue.
|
||||
smsSchedules: formData.get('smsSchedules') === 'on',
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
|
||||
@@ -267,16 +267,25 @@ 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;
|
||||
smsSchedules: boolean;
|
||||
personalEmail: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
@@ -460,16 +469,25 @@ 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,
|
||||
smsSchedules: membership.profile.smsSchedules,
|
||||
personalEmail: membership.profile.personalEmail,
|
||||
emergencyContactName: membership.profile.emergencyContactName,
|
||||
emergencyContactPhone: membership.profile.emergencyContactPhone,
|
||||
@@ -561,6 +579,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,7 @@ import 'server-only';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
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 +45,8 @@ export const getRegisterData = cache(async function getRegisterData(
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
birthName: true,
|
||||
gender: true,
|
||||
nationality: true,
|
||||
birthDate: true,
|
||||
},
|
||||
@@ -71,11 +74,14 @@ 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,
|
||||
sex: genderShort(contract.membership.profile?.gender ?? null),
|
||||
nationality: contract.membership.profile?.nationality ?? null,
|
||||
birthDate: contract.membership.profile?.birthDate ?? null,
|
||||
jobTitle: contract.jobTitleId
|
||||
@@ -128,6 +134,8 @@ export const listRegisterLocations = cache(
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
birthName: true,
|
||||
gender: true,
|
||||
nationality: true,
|
||||
birthDate: true,
|
||||
},
|
||||
@@ -138,9 +146,12 @@ 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,
|
||||
sex: genderShort(contract.membership.profile?.gender ?? null),
|
||||
nationality: contract.membership.profile?.nationality ?? null,
|
||||
birthDate: contract.membership.profile?.birthDate ?? null,
|
||||
jobTitle: contract.jobTitleId ? 'x' : null,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -147,6 +147,22 @@ export async function getWeekBoard(
|
||||
schedules.map((schedule) => [schedule.teamId, schedule]),
|
||||
);
|
||||
|
||||
// Repos posés sur la semaine. Seul le compensateur alimente le compteur
|
||||
// RC du bandeau ; le repos hebdomadaire est chargé avec, parce que les
|
||||
// deux se posent sur la même table et que filtrer ici coûterait une
|
||||
// seconde requête pour une distinction que le domaine fait déjà.
|
||||
const rests = await db.rest.findMany({
|
||||
where: {
|
||||
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
|
||||
},
|
||||
select: {
|
||||
weeklyScheduleId: true,
|
||||
membershipId: true,
|
||||
restType: true,
|
||||
minutes: true,
|
||||
},
|
||||
});
|
||||
|
||||
const shifts = await db.shift.findMany({
|
||||
where: {
|
||||
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
|
||||
@@ -236,6 +252,13 @@ export async function getWeekBoard(
|
||||
location.timezone,
|
||||
isPublished,
|
||||
absenceInputs,
|
||||
rests
|
||||
.filter((rest) => rest.weeklyScheduleId === schedule?.id)
|
||||
.map((rest) => ({
|
||||
membershipId: rest.membershipId,
|
||||
restType: rest.restType,
|
||||
minutes: rest.minutes,
|
||||
})),
|
||||
);
|
||||
|
||||
const teamAlerts = alerts
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Journal d'activité — PLAN.md §9, `/rapports/activite`.
|
||||
*
|
||||
* Le journal d'audit est append-only, imposé par un trigger. Cet écran ne fait
|
||||
* que le lire : aucune action ne s'y trouve, et c'est le point. Une piste que
|
||||
* l'on peut trier, filtrer et exporter, mais jamais corriger, est la seule qui
|
||||
* prouve quelque chose.
|
||||
*
|
||||
* L'accès passe par `audit.view`, capacité distincte de l'administration : on
|
||||
* confie la lecture de la piste à un contrôleur sans lui donner le droit de
|
||||
* modifier ce qu'elle enregistre.
|
||||
*/
|
||||
|
||||
export interface ActivityEntry {
|
||||
id: string;
|
||||
occurredAt: Date;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
actorName: string;
|
||||
reason: string | null;
|
||||
ip: string | null;
|
||||
/** Champs dont la valeur a changé, sans leur contenu. */
|
||||
changedFields: string[];
|
||||
}
|
||||
|
||||
export interface ActivityFilters {
|
||||
/** Préfixe d'action, par exemple `planning` ou `payroll.period`. */
|
||||
domain?: string;
|
||||
entityType?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface ActivityPage {
|
||||
entries: ActivityEntry[];
|
||||
/** Domaines présents dans le journal, pour le filtre. */
|
||||
domains: string[];
|
||||
entityTypes: string[];
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
/**
|
||||
* Noms des champs modifiés, sans leur valeur.
|
||||
*
|
||||
* La piste enregistre l'avant et l'après en clair — c'est ce qui la rend
|
||||
* opposable. Les afficher dans une liste, en revanche, exposerait un salaire ou
|
||||
* une donnée de santé à quiconque ouvre l'écran. Le journal dit **ce qui** a
|
||||
* changé ; le détail se lit sur la fiche, sous ses propres autorisations.
|
||||
*/
|
||||
function changedFields(before: unknown, after: unknown): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const value of [before, after]) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
for (const key of Object.keys(value)) keys.add(key);
|
||||
}
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
export async function getActivity(
|
||||
filters: ActivityFilters = {},
|
||||
): Promise<ActivityPage> {
|
||||
return query('audit.view', async (db) => {
|
||||
const where = {
|
||||
...(filters.domain ? { action: { startsWith: filters.domain } } : {}),
|
||||
...(filters.entityType ? { entityType: filters.entityType } : {}),
|
||||
...(filters.from || filters.to
|
||||
? {
|
||||
occurredAt: {
|
||||
...(filters.from
|
||||
? { gte: new Date(`${filters.from}T00:00:00Z`) }
|
||||
: {}),
|
||||
// Borne haute inclusive : filtrer « jusqu'au 31 » doit contenir
|
||||
// le 31, pas s'arrêter à son premier instant.
|
||||
...(filters.to
|
||||
? { lte: new Date(`${filters.to}T23:59:59.999Z`) }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
db.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
take: PAGE_SIZE,
|
||||
include: {
|
||||
actor: {
|
||||
include: {
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
// Les listes de filtres viennent du journal lui-même : une action ajoutée
|
||||
// par un lot futur y apparaît sans qu'on ait à tenir une énumération à
|
||||
// jour, et une énumération figée finirait par masquer ce qu'elle omet.
|
||||
const [actions, types] = await Promise.all([
|
||||
db.auditLog.findMany({
|
||||
distinct: ['action'],
|
||||
select: { action: true },
|
||||
orderBy: { action: 'asc' },
|
||||
take: 500,
|
||||
}),
|
||||
db.auditLog.findMany({
|
||||
distinct: ['entityType'],
|
||||
select: { entityType: true },
|
||||
orderBy: { entityType: 'asc' },
|
||||
take: 200,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
entries: rows.map((row) => ({
|
||||
id: row.id,
|
||||
occurredAt: row.occurredAt,
|
||||
action: row.action,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
actorName: row.actor
|
||||
? `${row.actor.profile?.firstName ?? ''} ${row.actor.profile?.lastName ?? row.actor.employeeNumber}`.trim()
|
||||
: 'Système',
|
||||
reason: row.reason,
|
||||
ip: row.ip,
|
||||
changedFields: changedFields(row.before, row.after),
|
||||
})),
|
||||
domains: [
|
||||
...new Set(
|
||||
actions.map((row) => row.action.split('.')[0] ?? row.action),
|
||||
),
|
||||
].sort(),
|
||||
entityTypes: types.map((row) => row.entityType),
|
||||
total,
|
||||
truncated: total > rows.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate, query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Types d'absence — PLAN.md §9, réglage « /reglages/types-absence ».
|
||||
*
|
||||
* Ce référentiel n'est pas décoratif : chaque drapeau porte une conséquence de
|
||||
* paie ou de droit.
|
||||
*
|
||||
* - `isSocialSecurity` isole maladie, maternité et accident du travail. Le
|
||||
* journal des absences les filtre séparément, et ce sont des **données de
|
||||
* santé** : la vue manager ne doit pas en montrer le motif (matrice n° 9).
|
||||
* - `affectsPaidLeaveAccrual` décide si la période acquiert des congés payés.
|
||||
* - `countsAsWorkTime` décide si la durée entre dans le décompte horaire, donc
|
||||
* dans les seuils de convention.
|
||||
* - `silaeCode` est la partie `<code>` de `AB-<code>` à l'export (§8.2). Sans
|
||||
* lui, l'export de la période **échoue** plutôt que de produire un fichier
|
||||
* partiel — c'est voulu.
|
||||
*
|
||||
* **Autorisation.** Les mutations passent par `settings.agreement.manage` et
|
||||
* non par un code dédié : le catalogue de §5 n'en déclare pas, et ces drapeaux
|
||||
* relèvent du paramétrage conventionnel et légal, exactement ce que cette
|
||||
* capacité gouverne. Créer un code sans mandat aurait été inventer une règle.
|
||||
*/
|
||||
|
||||
export interface AbsenceTypeRow {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
colorKey: string;
|
||||
isPaid: boolean;
|
||||
countsAsWorkTime: boolean;
|
||||
affectsPaidLeaveAccrual: boolean;
|
||||
isSocialSecurity: boolean;
|
||||
requiresJustification: boolean;
|
||||
minNoticeDays: number | null;
|
||||
silaeCode: string | null;
|
||||
archivedAt: Date | null;
|
||||
/** Absences saisies avec ce type — un type porté ne se supprime pas. */
|
||||
timeOffCount: number;
|
||||
}
|
||||
|
||||
export async function listAbsenceTypes(
|
||||
includeArchived = false,
|
||||
): Promise<AbsenceTypeRow[]> {
|
||||
return query('settings.access', async (db) => {
|
||||
const types = await db.absenceType.findMany({
|
||||
where: includeArchived ? {} : { archivedAt: null },
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
|
||||
const counts = await db.timeOff.groupBy({
|
||||
by: ['absenceTypeId'],
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byId = new Map(
|
||||
counts.map((row) => [row.absenceTypeId, row._count._all] as const),
|
||||
);
|
||||
|
||||
return types.map((type) => ({
|
||||
id: type.id,
|
||||
code: type.code,
|
||||
name: type.name,
|
||||
colorKey: type.colorKey,
|
||||
isPaid: type.isPaid,
|
||||
countsAsWorkTime: type.countsAsWorkTime,
|
||||
affectsPaidLeaveAccrual: type.affectsPaidLeaveAccrual,
|
||||
isSocialSecurity: type.isSocialSecurity,
|
||||
requiresJustification: type.requiresJustification,
|
||||
minNoticeDays: type.minNoticeDays,
|
||||
silaeCode: type.silaeCode,
|
||||
archivedAt: type.archivedAt,
|
||||
timeOffCount: byId.get(type.id) ?? 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export interface ActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
class DuplicateAbsenceType extends Error {}
|
||||
|
||||
const flags = {
|
||||
isPaid: z.coerce.boolean(),
|
||||
countsAsWorkTime: z.coerce.boolean(),
|
||||
affectsPaidLeaveAccrual: z.coerce.boolean(),
|
||||
isSocialSecurity: z.coerce.boolean(),
|
||||
requiresJustification: z.coerce.boolean(),
|
||||
minNoticeDays: z
|
||||
.union([z.literal(''), z.coerce.number().int().min(0).max(365)])
|
||||
.transform((value) => (value === '' ? null : value)),
|
||||
silaeCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(20)
|
||||
.transform((value) => (value === '' ? null : value)),
|
||||
};
|
||||
|
||||
const createInput = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Code requis')
|
||||
.max(20)
|
||||
.regex(/^[A-Za-z0-9_-]+$/, 'Lettres, chiffres, tirets et soulignés'),
|
||||
name: z.string().trim().min(1, 'Nom requis').max(120),
|
||||
colorKey: z.string().trim().min(1).max(40),
|
||||
...flags,
|
||||
});
|
||||
|
||||
function readForm(formData: FormData) {
|
||||
return {
|
||||
code: formData.get('code'),
|
||||
name: formData.get('name'),
|
||||
colorKey: formData.get('colorKey') || 'neutral',
|
||||
isPaid: formData.get('isPaid') === 'on',
|
||||
countsAsWorkTime: formData.get('countsAsWorkTime') === 'on',
|
||||
affectsPaidLeaveAccrual: formData.get('affectsPaidLeaveAccrual') === 'on',
|
||||
isSocialSecurity: formData.get('isSocialSecurity') === 'on',
|
||||
requiresJustification: formData.get('requiresJustification') === 'on',
|
||||
minNoticeDays: (formData.get('minNoticeDays') ?? '') as string,
|
||||
silaeCode: (formData.get('silaeCode') ?? '') as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAbsenceTypeAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = createInput.safeParse(readForm(formData));
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const code = parsed.data.code.toUpperCase();
|
||||
|
||||
try {
|
||||
await mutate('settings.agreement.manage', async (db, actor) => {
|
||||
const existing = await db.absenceType.findFirst({ where: { code } });
|
||||
if (existing) throw new DuplicateAbsenceType();
|
||||
|
||||
const { code: _ignored, ...rest } = parsed.data;
|
||||
const created = await db.absenceType.create({
|
||||
data: { ...rest, code } as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'absence_type.create',
|
||||
entityType: 'AbsenceType',
|
||||
entityId: created.id,
|
||||
after: { code: created.code, name: created.name },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateAbsenceType) {
|
||||
return { error: 'Ce code de type d’absence est déjà pris.' };
|
||||
}
|
||||
if (error instanceof AuthorizationError) {
|
||||
return {
|
||||
error: "Vous n'avez pas le droit de gérer les types d'absence.",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/types-absence');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const updateInput = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().trim().min(1, 'Nom requis').max(120),
|
||||
colorKey: z.string().trim().min(1).max(40),
|
||||
...flags,
|
||||
});
|
||||
|
||||
export async function updateAbsenceTypeAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = updateInput.safeParse({
|
||||
...readForm(formData),
|
||||
id: formData.get('id'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.agreement.manage', async (db, actor) => {
|
||||
const { id, ...data } = parsed.data;
|
||||
const before = await db.absenceType.findUnique({ where: { id } });
|
||||
if (!before) throw new AuthorizationError('settings.agreement.manage');
|
||||
|
||||
await db.absenceType.update({ where: { id }, data });
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'absence_type.update',
|
||||
entityType: 'AbsenceType',
|
||||
entityId: id,
|
||||
before: {
|
||||
name: before.name,
|
||||
isPaid: before.isPaid,
|
||||
countsAsWorkTime: before.countsAsWorkTime,
|
||||
affectsPaidLeaveAccrual: before.affectsPaidLeaveAccrual,
|
||||
isSocialSecurity: before.isSocialSecurity,
|
||||
requiresJustification: before.requiresJustification,
|
||||
minNoticeDays: before.minNoticeDays,
|
||||
silaeCode: before.silaeCode,
|
||||
},
|
||||
after: data,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
return {
|
||||
error: "Vous n'avez pas le droit de gérer les types d'absence.",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/types-absence');
|
||||
revalidatePath('/absences/calendrier');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function archiveAbsenceTypeAction(
|
||||
formData: FormData,
|
||||
): Promise<void> {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
const restore = formData.get('restore') === '1';
|
||||
if (!id) return;
|
||||
|
||||
await mutate('settings.agreement.manage', async (db, actor) => {
|
||||
const before = await db.absenceType.findUnique({ where: { id } });
|
||||
if (!before) return;
|
||||
|
||||
// Archiver, jamais supprimer : les absences passées gardent leur type, et
|
||||
// les compteurs de congés s'appuient dessus pour justifier une écriture.
|
||||
const archivedAt = restore ? null : new Date();
|
||||
await db.absenceType.update({ where: { id }, data: { archivedAt } });
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: restore ? 'absence_type.restore' : 'absence_type.archive',
|
||||
entityType: 'AbsenceType',
|
||||
entityId: id,
|
||||
before: { archivedAt: before.archivedAt },
|
||||
after: { archivedAt: archivedAt?.toISOString() ?? null },
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath('/reglages/types-absence');
|
||||
revalidatePath('/absences/calendrier');
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import {
|
||||
isPrintDensity,
|
||||
PREFERENCE_KEYS,
|
||||
timeToMinutes,
|
||||
} from '@/domain/settings/preferences';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate, query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Compte et préférences — PLAN.md §9, `/reglages/compte` et
|
||||
* `/reglages/preferences`.
|
||||
*
|
||||
* Les préférences sont une ligne créée à la demande : l'absence de ligne vaut
|
||||
* « tous les défauts ». Semer une ligne à l'installation obligerait à migrer
|
||||
* chaque compte au premier réglage ajouté ; la lire avec un repli sur les
|
||||
* défauts du modèle ne coûte rien et ne se périme pas.
|
||||
*/
|
||||
|
||||
export interface AccountIdentity {
|
||||
id: string;
|
||||
name: string;
|
||||
siren: string | null;
|
||||
apeCode: string | null;
|
||||
addressLine: string | null;
|
||||
postalCode: string | null;
|
||||
city: string | null;
|
||||
country: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface Preferences {
|
||||
defaultMealPerShift: boolean;
|
||||
paidBreaks: boolean;
|
||||
lockEmployeeMeals: boolean;
|
||||
employeesSeeOwnTotals: boolean;
|
||||
employeesSeeTeamPlanning: boolean;
|
||||
hideForfaitCounters: boolean;
|
||||
eveningShiftStartMinutes: number;
|
||||
employeesEditOwnProfile: boolean;
|
||||
employeesSeeOwnTimesheets: boolean;
|
||||
employeesSeeTeamContacts: boolean;
|
||||
managerCreatedArePlannable: boolean;
|
||||
directorsSharePeople: boolean;
|
||||
directorsAccessHrDashboard: boolean;
|
||||
directorsSeePaidLeave: boolean;
|
||||
managersSeePaidLeave: boolean;
|
||||
smoothOvertimeMonthly: boolean;
|
||||
includeRestInNormalHours: boolean;
|
||||
autoEmployeeNumber: boolean;
|
||||
printLandscape: boolean;
|
||||
printDensity: string;
|
||||
printContractTotals: boolean;
|
||||
printOtherTeams: boolean;
|
||||
printSunday: boolean;
|
||||
printSignatureColumn: boolean;
|
||||
/** Chaîne décimale, ou `null` quand aucun objectif n'est fixé. */
|
||||
productivityTargetPerHour: string | null;
|
||||
}
|
||||
|
||||
const DEFAULTS: Preferences = {
|
||||
defaultMealPerShift: false,
|
||||
paidBreaks: false,
|
||||
lockEmployeeMeals: false,
|
||||
employeesSeeOwnTotals: true,
|
||||
employeesSeeTeamPlanning: false,
|
||||
hideForfaitCounters: true,
|
||||
eveningShiftStartMinutes: 1200,
|
||||
employeesEditOwnProfile: true,
|
||||
employeesSeeOwnTimesheets: true,
|
||||
employeesSeeTeamContacts: false,
|
||||
managerCreatedArePlannable: false,
|
||||
directorsSharePeople: false,
|
||||
directorsAccessHrDashboard: true,
|
||||
directorsSeePaidLeave: true,
|
||||
managersSeePaidLeave: false,
|
||||
smoothOvertimeMonthly: false,
|
||||
includeRestInNormalHours: false,
|
||||
autoEmployeeNumber: false,
|
||||
printLandscape: true,
|
||||
printDensity: 'large',
|
||||
printContractTotals: true,
|
||||
printOtherTeams: false,
|
||||
printSunday: true,
|
||||
printSignatureColumn: true,
|
||||
productivityTargetPerHour: null,
|
||||
};
|
||||
|
||||
export async function getAccountIdentity(): Promise<AccountIdentity | null> {
|
||||
return query('settings.access', async (db, actor) => {
|
||||
const account = await db.account.findUnique({
|
||||
where: { id: actor.accountId },
|
||||
});
|
||||
if (!account) return null;
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
siren: account.siren,
|
||||
apeCode: account.apeCode,
|
||||
addressLine: account.addressLine,
|
||||
postalCode: account.postalCode,
|
||||
city: account.city,
|
||||
country: account.country,
|
||||
timezone: account.timezone,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPreferences(): Promise<Preferences> {
|
||||
return query('settings.access', async (db, actor) => {
|
||||
const row = await db.accountPreferences.findUnique({
|
||||
where: { accountId: actor.accountId },
|
||||
});
|
||||
if (!row) return DEFAULTS;
|
||||
|
||||
const {
|
||||
accountId: _ignored,
|
||||
updatedAt: _stamp,
|
||||
productivityTargetPerHour,
|
||||
...values
|
||||
} = row;
|
||||
|
||||
return {
|
||||
...values,
|
||||
// Un Decimal Prisma ne traverse pas la frontière serveur/client. Une
|
||||
// chaîne le fait sans perdre de décimale, ce qu'un `number` ne garantit
|
||||
// pas sur un montant.
|
||||
productivityTargetPerHour: productivityTargetPerHour?.toString() ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface ActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
const identityInput = z.object({
|
||||
name: z.string().trim().min(1, 'Raison sociale requise').max(200),
|
||||
siren: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{9}$/, 'Le SIREN compte 9 chiffres')
|
||||
.or(z.literal('')),
|
||||
apeCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{4}[A-Z]$/, 'Le code APE s’écrit « 4759B »')
|
||||
.or(z.literal('')),
|
||||
addressLine: z.string().trim().max(200),
|
||||
postalCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{5}$/, 'Le code postal compte 5 chiffres')
|
||||
.or(z.literal('')),
|
||||
city: z.string().trim().max(120),
|
||||
timezone: z.string().trim().min(1).max(64),
|
||||
});
|
||||
|
||||
export async function saveAccountIdentityAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = identityInput.safeParse({
|
||||
name: formData.get('name'),
|
||||
siren: formData.get('siren') ?? '',
|
||||
apeCode: formData.get('apeCode') ?? '',
|
||||
addressLine: formData.get('addressLine') ?? '',
|
||||
postalCode: formData.get('postalCode') ?? '',
|
||||
city: formData.get('city') ?? '',
|
||||
timezone: formData.get('timezone') || 'Europe/Paris',
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.locations.manage', async (db, actor) => {
|
||||
const before = await db.account.findUnique({
|
||||
where: { id: actor.accountId },
|
||||
});
|
||||
if (!before) throw new AuthorizationError('settings.locations.manage');
|
||||
|
||||
const data = {
|
||||
name: parsed.data.name,
|
||||
siren: parsed.data.siren || null,
|
||||
apeCode: parsed.data.apeCode || null,
|
||||
addressLine: parsed.data.addressLine || null,
|
||||
postalCode: parsed.data.postalCode || null,
|
||||
city: parsed.data.city || null,
|
||||
timezone: parsed.data.timezone,
|
||||
};
|
||||
|
||||
await db.account.update({ where: { id: actor.accountId }, data });
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'account.update',
|
||||
entityType: 'Account',
|
||||
entityId: actor.accountId,
|
||||
before: {
|
||||
name: before.name,
|
||||
siren: before.siren,
|
||||
apeCode: before.apeCode,
|
||||
city: before.city,
|
||||
timezone: before.timezone,
|
||||
},
|
||||
after: data,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de modifier le compte." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/compte');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un bloc de préférences.
|
||||
*
|
||||
* Les cases non cochées n'arrivent pas dans le `FormData` : le formulaire porte
|
||||
* donc la liste des clés qu'il gouverne, et seules celles-là sont réécrites.
|
||||
* Sans cette liste, enregistrer le bloc « Plannings » remettrait à faux tout le
|
||||
* bloc « Droits » — un formulaire partiel effacerait ce qu'il n'affiche pas.
|
||||
*/
|
||||
export async function savePreferencesAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const scope = formData
|
||||
.getAll('scope')
|
||||
.map(String)
|
||||
.filter((key) => PREFERENCE_KEYS.includes(key));
|
||||
|
||||
if (scope.length === 0) {
|
||||
return { error: 'Formulaire incomplet : aucun réglage à enregistrer.' };
|
||||
}
|
||||
|
||||
const values: Record<string, boolean | number | string> = {};
|
||||
for (const key of scope) {
|
||||
values[key] = formData.get(key) === 'on';
|
||||
}
|
||||
|
||||
// L'orientation et la densité vivent dans le même formulaire que les
|
||||
// interrupteurs d'impression : elles ne sont lues que si le formulaire les
|
||||
// porte, sinon un autre bloc les remettrait à leur défaut.
|
||||
const orientation = formData.get('printOrientation');
|
||||
if (typeof orientation === 'string' && orientation !== '') {
|
||||
values.printLandscape = orientation === 'landscape';
|
||||
}
|
||||
|
||||
const density = formData.get('printDensity');
|
||||
if (typeof density === 'string' && density !== '') {
|
||||
if (!isPrintDensity(density)) {
|
||||
return { error: 'Densité d’impression inconnue.' };
|
||||
}
|
||||
values.printDensity = density;
|
||||
}
|
||||
|
||||
const eveningRaw = formData.get('eveningShiftStart');
|
||||
if (typeof eveningRaw === 'string' && eveningRaw !== '') {
|
||||
const minutes = timeToMinutes(eveningRaw);
|
||||
if (minutes === null) {
|
||||
return { error: 'Heure de bascule du soir invalide (format HH:MM).' };
|
||||
}
|
||||
values.eveningShiftStartMinutes = minutes;
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.access', async (db, actor) => {
|
||||
const before = await db.accountPreferences.findUnique({
|
||||
where: { accountId: actor.accountId },
|
||||
});
|
||||
|
||||
await db.accountPreferences.upsert({
|
||||
where: { accountId: actor.accountId },
|
||||
create: values as never,
|
||||
update: values as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'account.preferences.update',
|
||||
entityType: 'AccountPreferences',
|
||||
entityId: actor.accountId,
|
||||
before: before
|
||||
? Object.fromEntries(
|
||||
Object.keys(values).map((key) => [
|
||||
key,
|
||||
(before as Record<string, unknown>)[key],
|
||||
]),
|
||||
)
|
||||
: null,
|
||||
after: values,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de modifier les réglages." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/preferences');
|
||||
revalidatePath('/reglages/paie');
|
||||
revalidatePath('/reglages/impression');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const productivityInput = z.object({
|
||||
target: z
|
||||
.union([
|
||||
z.literal(''),
|
||||
z.coerce
|
||||
.number()
|
||||
.min(0, 'Un objectif négatif n’a pas de sens')
|
||||
.max(100_000),
|
||||
])
|
||||
.transform((value) => (value === '' ? null : value)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Objectif de productivité, en euros de chiffre d'affaires par heure.
|
||||
*
|
||||
* Vider le champ **efface** l'objectif au lieu de le mettre à zéro : zéro est
|
||||
* un objectif inatteignable, l'absence de valeur dit qu'aucun objectif n'a été
|
||||
* fixé. Un écran qui afficherait « 0 €/h attendu » pousserait à corriger un
|
||||
* réglage qui n'existe pas.
|
||||
*/
|
||||
export async function saveProductivityTargetAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = productivityInput.safeParse({
|
||||
target: formData.get('target') ?? '',
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.access', async (db, actor) => {
|
||||
const before = await db.accountPreferences.findUnique({
|
||||
where: { accountId: actor.accountId },
|
||||
});
|
||||
|
||||
await db.accountPreferences.upsert({
|
||||
where: { accountId: actor.accountId },
|
||||
create: { productivityTargetPerHour: parsed.data.target } as never,
|
||||
update: { productivityTargetPerHour: parsed.data.target },
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'account.productivity.update',
|
||||
entityType: 'AccountPreferences',
|
||||
entityId: actor.accountId,
|
||||
before: {
|
||||
productivityTargetPerHour:
|
||||
before?.productivityTargetPerHour?.toString() ?? null,
|
||||
},
|
||||
after: { productivityTargetPerHour: parsed.data.target },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de modifier les réglages." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/productivite');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import {
|
||||
agreementParametersSchema,
|
||||
parseAgreementParameters,
|
||||
type AgreementParameters,
|
||||
} from '@/domain/compliance/parameters';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate, query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Convention collective — PLAN.md §9, réglage « /reglages/convention ».
|
||||
*
|
||||
* **Une convention ne se modifie pas : elle se réédite.** Chaque enregistrement
|
||||
* crée une version datée, laissant intacte la précédente. C'est l'exigence
|
||||
* effectif-datée de §12.2 : une paie de mars doit rester reproductible après un
|
||||
* changement de règle en juin, ce qu'une mise à jour en place rendrait
|
||||
* impossible — le moteur relirait les nouveaux seuils et produirait un autre
|
||||
* résultat sur les mêmes données.
|
||||
*
|
||||
* L'origine de chaque valeur est portée par le registre de paramétrage
|
||||
* (`LegalConfigEntry`, §12.7) : ordre public, disposition conventionnelle ou
|
||||
* accord d'entreprise ne se corrigent pas de la même main.
|
||||
*/
|
||||
|
||||
export interface AgreementVersion {
|
||||
id: string;
|
||||
idcc: string;
|
||||
name: string;
|
||||
version: number;
|
||||
effectiveFrom: Date;
|
||||
source: string | null;
|
||||
approvedBy: string | null;
|
||||
approvedAt: Date | null;
|
||||
createdAt: Date;
|
||||
parameters: AgreementParameters;
|
||||
/** Vraie pour la version applicable aujourd'hui. */
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export async function listAgreementVersions(): Promise<AgreementVersion[]> {
|
||||
return query('settings.access', async (db) => {
|
||||
const rows = await db.collectiveAgreement.findMany({
|
||||
orderBy: [{ effectiveFrom: 'desc' }, { version: 'desc' }],
|
||||
});
|
||||
|
||||
const today = new Date();
|
||||
// La version courante est la plus récente **déjà en vigueur** : une version
|
||||
// datée du mois prochain existe en base sans gouverner le calcul du jour.
|
||||
const currentId = rows.find((row) => row.effectiveFrom <= today)?.id ?? null;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
idcc: row.idcc,
|
||||
name: row.name,
|
||||
version: row.version,
|
||||
effectiveFrom: row.effectiveFrom,
|
||||
source: row.source,
|
||||
approvedBy: row.approvedBy,
|
||||
approvedAt: row.approvedAt,
|
||||
createdAt: row.createdAt,
|
||||
parameters: parseAgreementParameters(row.parameters),
|
||||
isCurrent: row.id === currentId,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export interface ActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tranches d'heures supplémentaires, saisies une par ligne : `début;fin;taux`.
|
||||
*
|
||||
* Un tableau de longueur variable ne se saisit pas en champs fixes, et un JSON
|
||||
* brut exposerait la forme interne. Le format compact reste vérifiable à l'œil,
|
||||
* ce qui compte pour une valeur que le gestionnaire de paie doit relire.
|
||||
* `fin` vide signifie « jusqu'à l'infini ».
|
||||
*/
|
||||
function parseTiers(raw: string) {
|
||||
const lines = raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return lines.map((line, index) => {
|
||||
const parts = line.split(';').map((part) => part.trim());
|
||||
if (parts.length !== 3) {
|
||||
throw new ParameterFormatError(
|
||||
`Tranche ${index + 1} : trois valeurs attendues « début;fin;taux ».`,
|
||||
);
|
||||
}
|
||||
const [from, to, rate] = parts as [string, string, string];
|
||||
const fromMinutes = Number(from);
|
||||
const toMinutes = to === '' ? null : Number(to);
|
||||
const ratePercent = Number(rate);
|
||||
|
||||
if (
|
||||
!Number.isFinite(fromMinutes) ||
|
||||
(toMinutes !== null && !Number.isFinite(toMinutes)) ||
|
||||
!Number.isFinite(ratePercent)
|
||||
) {
|
||||
throw new ParameterFormatError(
|
||||
`Tranche ${index + 1} : valeurs numériques attendues.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { fromMinutes, toMinutes, ratePercent };
|
||||
});
|
||||
}
|
||||
|
||||
/** Dérogations de temps partiel : `code;libellé;minutes` par ligne. */
|
||||
function parseDerogations(raw: string) {
|
||||
return raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line, index) => {
|
||||
const parts = line.split(';').map((part) => part.trim());
|
||||
if (parts.length !== 3) {
|
||||
throw new ParameterFormatError(
|
||||
`Dérogation ${index + 1} : « code;libellé;minutes » attendu.`,
|
||||
);
|
||||
}
|
||||
const [code, label, min] = parts as [string, string, string];
|
||||
const minWeeklyMinutes = Number(min);
|
||||
if (!code || !label || !Number.isFinite(minWeeklyMinutes)) {
|
||||
throw new ParameterFormatError(
|
||||
`Dérogation ${index + 1} : code, libellé et minutes requis.`,
|
||||
);
|
||||
}
|
||||
return { code, label, minWeeklyMinutes };
|
||||
});
|
||||
}
|
||||
|
||||
class ParameterFormatError extends Error {}
|
||||
|
||||
const metaInput = z.object({
|
||||
idcc: z.string().trim().min(1, 'IDCC requis').max(10),
|
||||
name: z.string().trim().min(1, 'Intitulé requis').max(160),
|
||||
effectiveFrom: z.coerce.date(),
|
||||
source: z.string().trim().max(400),
|
||||
approvedBy: z.string().trim().max(160),
|
||||
});
|
||||
|
||||
function num(formData: FormData, key: string): number {
|
||||
return Number(formData.get(key) ?? Number.NaN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruit le jeu complet depuis le formulaire, puis le valide.
|
||||
*
|
||||
* La validation passe par le **même** schéma que le chargement du moteur : un
|
||||
* jeu accepté ici est donc, par construction, un jeu que le moteur saura lire.
|
||||
*/
|
||||
function readParameters(formData: FormData): AgreementParameters {
|
||||
const candidate = {
|
||||
weeklyReferenceMinutes: num(formData, 'weeklyReferenceMinutes'),
|
||||
maxDailyWorkMinutes: num(formData, 'maxDailyWorkMinutes'),
|
||||
maxDailyAmplitudeMinutes:
|
||||
String(formData.get('maxDailyAmplitudeMinutes') ?? '') === ''
|
||||
? null
|
||||
: num(formData, 'maxDailyAmplitudeMinutes'),
|
||||
minDailyRestMinutes: num(formData, 'minDailyRestMinutes'),
|
||||
minWeeklyRestMinutes: num(formData, 'minWeeklyRestMinutes'),
|
||||
maxWeeklyWorkAbsoluteMinutes: num(formData, 'maxWeeklyWorkAbsoluteMinutes'),
|
||||
averagedWeeklyWork: {
|
||||
windowWeeks: num(formData, 'averagedWindowWeeks'),
|
||||
maxAverageMinutes: num(formData, 'averagedMaxMinutes'),
|
||||
},
|
||||
maxConsecutiveWorkDays: num(formData, 'maxConsecutiveWorkDays'),
|
||||
breakAfterThreshold: {
|
||||
thresholdMinutes: num(formData, 'breakThresholdMinutes'),
|
||||
minBreakMinutes: num(formData, 'breakMinMinutes'),
|
||||
},
|
||||
partTime: {
|
||||
minWeeklyMinutes: num(formData, 'partTimeMinWeeklyMinutes'),
|
||||
derogations: parseDerogations(String(formData.get('derogations') ?? '')),
|
||||
},
|
||||
contractDeviationToleranceMinutes: num(formData, 'deviationToleranceMinutes'),
|
||||
overtime: {
|
||||
tiers: parseTiers(String(formData.get('overtimeTiers') ?? '')),
|
||||
annualQuotaMinutes: num(formData, 'overtimeQuotaMinutes'),
|
||||
beyondQuotaRestPercent: num(formData, 'overtimeBeyondRestPercent'),
|
||||
},
|
||||
complementaryHours: {
|
||||
firstTierRatePercent: num(formData, 'complementaryFirstRate'),
|
||||
beyondRatePercent: num(formData, 'complementaryBeyondRate'),
|
||||
firstTierFraction: num(formData, 'complementaryFirstFraction'),
|
||||
capFraction: num(formData, 'complementaryCapFraction'),
|
||||
},
|
||||
sunday: {
|
||||
premiumPercent: num(formData, 'sundayPremiumPercent'),
|
||||
compensatoryRestPercent: num(formData, 'sundayRestPercent'),
|
||||
mayorQuotaPerYear: num(formData, 'sundayMayorQuota'),
|
||||
},
|
||||
holiday: {
|
||||
workedPremiumPercent: num(formData, 'holidayWorkedPremium'),
|
||||
labourDayPremiumPercent: num(formData, 'holidayLabourDayPremium'),
|
||||
paidOffDaysPerYear: num(formData, 'holidayPaidOffDays'),
|
||||
substitutionRestPercent: num(formData, 'holidaySubstitutionRest'),
|
||||
},
|
||||
night: {
|
||||
startMinutes: num(formData, 'nightStartMinutes'),
|
||||
endMinutes: num(formData, 'nightEndMinutes'),
|
||||
notEnforceableFromAge: num(formData, 'nightNotEnforceableFromAge'),
|
||||
},
|
||||
forfaitJours: {
|
||||
maxDaysPerYear: num(formData, 'forfaitMaxDaysPerYear'),
|
||||
workloadReviewIntervalMonths: num(formData, 'forfaitReviewMonths'),
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = agreementParametersSchema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
throw new ParameterFormatError(
|
||||
`${issue?.path.join('.') ?? 'paramètre'} : ${issue?.message ?? 'valeur refusée'}`,
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export async function publishAgreementVersionAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const meta = metaInput.safeParse({
|
||||
idcc: formData.get('idcc'),
|
||||
name: formData.get('name'),
|
||||
effectiveFrom: formData.get('effectiveFrom'),
|
||||
source: formData.get('source') ?? '',
|
||||
approvedBy: formData.get('approvedBy') ?? '',
|
||||
});
|
||||
|
||||
if (!meta.success) {
|
||||
return { error: meta.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
let parameters: AgreementParameters;
|
||||
try {
|
||||
parameters = readParameters(formData);
|
||||
} catch (error) {
|
||||
if (error instanceof ParameterFormatError) return { error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!meta.data.source) {
|
||||
// Une valeur sans source n'est pas opposable : le registre de paramétrage
|
||||
// exige de dire d'où elle vient avant de l'appliquer (§12.7).
|
||||
return {
|
||||
error:
|
||||
'Indiquez la source du texte : sans elle, le paramètre n’est pas opposable.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.agreement.manage', async (db, actor) => {
|
||||
const last = await db.collectiveAgreement.findFirst({
|
||||
where: { idcc: meta.data.idcc },
|
||||
orderBy: { version: 'desc' },
|
||||
});
|
||||
|
||||
const created = await db.collectiveAgreement.create({
|
||||
data: {
|
||||
idcc: meta.data.idcc,
|
||||
name: meta.data.name,
|
||||
parameters: parameters as never,
|
||||
version: (last?.version ?? 0) + 1,
|
||||
effectiveFrom: meta.data.effectiveFrom,
|
||||
source: meta.data.source,
|
||||
approvedBy: meta.data.approvedBy || null,
|
||||
approvedAt: meta.data.approvedBy ? new Date() : null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'agreement.publish_version',
|
||||
entityType: 'CollectiveAgreement',
|
||||
entityId: created.id,
|
||||
before: last
|
||||
? { version: last.version, effectiveFrom: last.effectiveFrom }
|
||||
: null,
|
||||
after: {
|
||||
version: created.version,
|
||||
effectiveFrom: created.effectiveFrom,
|
||||
source: created.source,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer la convention." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/convention');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate, query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Emplois — PLAN.md §9, réglage « /reglages/emplois ».
|
||||
*
|
||||
* L'emploi est la qualification portée par le contrat, et il figure au registre
|
||||
* unique du personnel. Il est donc **archivé, jamais supprimé** : un emploi
|
||||
* effacé rendrait illisible un contrat clos dont la conservation court encore.
|
||||
*/
|
||||
|
||||
export interface JobTitleRow {
|
||||
id: string;
|
||||
name: string;
|
||||
archivedAt: Date | null;
|
||||
/** Contrats qui s'y rattachent — un emploi porté n'est pas un doublon à purger. */
|
||||
contractCount: number;
|
||||
}
|
||||
|
||||
export async function listJobTitles(
|
||||
includeArchived = false,
|
||||
): Promise<JobTitleRow[]> {
|
||||
return query('settings.access', async (db) => {
|
||||
const titles = await db.jobTitle.findMany({
|
||||
where: includeArchived ? {} : { archivedAt: null },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
const counts = await db.userContract.groupBy({
|
||||
by: ['jobTitleId'],
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const byId = new Map(
|
||||
counts.map((row) => [row.jobTitleId, row._count._all] as const),
|
||||
);
|
||||
|
||||
return titles.map((title) => ({
|
||||
id: title.id,
|
||||
name: title.name,
|
||||
archivedAt: title.archivedAt,
|
||||
contractCount: byId.get(title.id) ?? 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export interface ActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
const nameInput = z.object({
|
||||
name: z.string().trim().min(1, 'Nom requis').max(120),
|
||||
});
|
||||
|
||||
export async function createJobTitleAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = nameInput.safeParse({ name: formData.get('name') });
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.jobtitles.manage', async (db, actor) => {
|
||||
// Un emploi archivé porte le même nom qu'un emploi à recréer : on le
|
||||
// réveille plutôt que d'échouer sur la contrainte d'unicité, sans quoi le
|
||||
// nom deviendrait définitivement inutilisable.
|
||||
const existing = await db.jobTitle.findFirst({
|
||||
where: { name: parsed.data.name },
|
||||
});
|
||||
|
||||
if (existing && existing.archivedAt === null) {
|
||||
throw new DuplicateJobTitle();
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await db.jobTitle.update({
|
||||
where: { id: existing.id },
|
||||
data: { archivedAt: null },
|
||||
});
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'job_title.restore',
|
||||
entityType: 'JobTitle',
|
||||
entityId: existing.id,
|
||||
before: { archivedAt: existing.archivedAt },
|
||||
after: { archivedAt: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const created = await db.jobTitle.create({
|
||||
data: { name: parsed.data.name } as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'job_title.create',
|
||||
entityType: 'JobTitle',
|
||||
entityId: created.id,
|
||||
after: { name: created.name },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateJobTitle) {
|
||||
return { error: 'Cet emploi existe déjà.' };
|
||||
}
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer les emplois." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/emplois');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
class DuplicateJobTitle extends Error {}
|
||||
|
||||
const renameInput = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().trim().min(1, 'Nom requis').max(120),
|
||||
});
|
||||
|
||||
export async function renameJobTitleAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = renameInput.safeParse({
|
||||
id: formData.get('id'),
|
||||
name: formData.get('name'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.jobtitles.manage', async (db, actor) => {
|
||||
const before = await db.jobTitle.findUnique({
|
||||
where: { id: parsed.data.id },
|
||||
});
|
||||
if (!before) throw new AuthorizationError('settings.jobtitles.manage');
|
||||
|
||||
const clash = await db.jobTitle.findFirst({
|
||||
where: { name: parsed.data.name, id: { not: before.id } },
|
||||
});
|
||||
if (clash) throw new DuplicateJobTitle();
|
||||
|
||||
await db.jobTitle.update({
|
||||
where: { id: before.id },
|
||||
data: { name: parsed.data.name },
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'job_title.rename',
|
||||
entityType: 'JobTitle',
|
||||
entityId: before.id,
|
||||
before: { name: before.name },
|
||||
after: { name: parsed.data.name },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateJobTitle) {
|
||||
return { error: 'Un autre emploi porte déjà ce nom.' };
|
||||
}
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer les emplois." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/emplois');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function archiveJobTitleAction(formData: FormData): Promise<void> {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
const restore = formData.get('restore') === '1';
|
||||
if (!id) return;
|
||||
|
||||
await mutate('settings.jobtitles.manage', async (db, actor) => {
|
||||
const before = await db.jobTitle.findUnique({ where: { id } });
|
||||
if (!before) return;
|
||||
|
||||
const archivedAt = restore ? null : new Date();
|
||||
await db.jobTitle.update({ where: { id }, data: { archivedAt } });
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: restore ? 'job_title.restore' : 'job_title.archive',
|
||||
entityType: 'JobTitle',
|
||||
entityId: id,
|
||||
before: { archivedAt: before.archivedAt },
|
||||
after: { archivedAt: archivedAt?.toISOString() ?? null },
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath('/reglages/emplois');
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { isPosteCode } from '@/lib/design/postes';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate, query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Étiquettes de planning — PLAN.md §9, réglage « /reglages/etiquettes ».
|
||||
*
|
||||
* L'étiquette est le poste coloré de la grille. Sa teinte vient de la palette
|
||||
* catégorielle, jamais d'une couleur libre : douze teintes calculées pour
|
||||
* rester distinguables en vision daltonienne, et un code affiché avec le bloc
|
||||
* pour que la couleur ne porte jamais l'information seule.
|
||||
*/
|
||||
|
||||
export interface LabelRow {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
paletteKey: string;
|
||||
position: number;
|
||||
archivedAt: Date | null;
|
||||
/** Créneaux qui la portent — une étiquette utilisée ne se supprime pas. */
|
||||
shiftCount: number;
|
||||
}
|
||||
|
||||
export async function listLabels(includeArchived = false): Promise<LabelRow[]> {
|
||||
return query('settings.access', async (db) => {
|
||||
const labels = await db.label.findMany({
|
||||
where: includeArchived ? {} : { archivedAt: null },
|
||||
orderBy: [{ position: 'asc' }, { code: 'asc' }],
|
||||
});
|
||||
|
||||
const counts = await db.shift.groupBy({
|
||||
by: ['labelId'],
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byId = new Map(
|
||||
counts.map((row) => [row.labelId, row._count._all] as const),
|
||||
);
|
||||
|
||||
return labels.map((label) => ({
|
||||
id: label.id,
|
||||
code: label.code,
|
||||
name: label.name,
|
||||
paletteKey: label.paletteKey,
|
||||
position: label.position,
|
||||
archivedAt: label.archivedAt,
|
||||
shiftCount: byId.get(label.id) ?? 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export interface ActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
class DuplicateLabel extends Error {}
|
||||
|
||||
const labelInput = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Code requis')
|
||||
.max(12)
|
||||
.regex(/^[A-Za-z0-9-]+$/, 'Lettres, chiffres et tirets seulement'),
|
||||
name: z.string().trim().min(1, 'Nom requis').max(120),
|
||||
paletteKey: z.string().refine(isPosteCode, 'Teinte inconnue'),
|
||||
});
|
||||
|
||||
export async function createLabelAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = labelInput.safeParse({
|
||||
code: formData.get('code'),
|
||||
name: formData.get('name'),
|
||||
paletteKey: formData.get('paletteKey'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const code = parsed.data.code.toLowerCase();
|
||||
|
||||
try {
|
||||
await mutate('planning.labels.manage', async (db, actor) => {
|
||||
const existing = await db.label.findFirst({ where: { code } });
|
||||
if (existing && existing.archivedAt === null) throw new DuplicateLabel();
|
||||
|
||||
if (existing) {
|
||||
await db.label.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
archivedAt: null,
|
||||
name: parsed.data.name,
|
||||
paletteKey: parsed.data.paletteKey,
|
||||
},
|
||||
});
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'label.restore',
|
||||
entityType: 'Label',
|
||||
entityId: existing.id,
|
||||
before: { archivedAt: existing.archivedAt, name: existing.name },
|
||||
after: { archivedAt: null, name: parsed.data.name },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const position = await db.label.count();
|
||||
const created = await db.label.create({
|
||||
data: {
|
||||
code,
|
||||
name: parsed.data.name,
|
||||
paletteKey: parsed.data.paletteKey,
|
||||
position,
|
||||
} as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'label.create',
|
||||
entityType: 'Label',
|
||||
entityId: created.id,
|
||||
after: {
|
||||
code: created.code,
|
||||
name: created.name,
|
||||
paletteKey: created.paletteKey,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateLabel) {
|
||||
return { error: 'Ce code d’étiquette est déjà pris.' };
|
||||
}
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer les étiquettes." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/etiquettes');
|
||||
revalidatePath('/planning/etiquettes');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const updateInput = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().trim().min(1, 'Nom requis').max(120),
|
||||
paletteKey: z.string().refine(isPosteCode, 'Teinte inconnue'),
|
||||
});
|
||||
|
||||
export async function updateLabelAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = updateInput.safeParse({
|
||||
id: formData.get('id'),
|
||||
name: formData.get('name'),
|
||||
paletteKey: formData.get('paletteKey'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('planning.labels.manage', async (db, actor) => {
|
||||
const before = await db.label.findUnique({ where: { id: parsed.data.id } });
|
||||
if (!before) throw new AuthorizationError('planning.labels.manage');
|
||||
|
||||
await db.label.update({
|
||||
where: { id: before.id },
|
||||
data: { name: parsed.data.name, paletteKey: parsed.data.paletteKey },
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'label.update',
|
||||
entityType: 'Label',
|
||||
entityId: before.id,
|
||||
before: { name: before.name, paletteKey: before.paletteKey },
|
||||
after: { name: parsed.data.name, paletteKey: parsed.data.paletteKey },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer les étiquettes." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/etiquettes');
|
||||
revalidatePath('/planning/etiquettes');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function archiveLabelAction(formData: FormData): Promise<void> {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
const restore = formData.get('restore') === '1';
|
||||
if (!id) return;
|
||||
|
||||
await mutate('planning.labels.manage', async (db, actor) => {
|
||||
const before = await db.label.findUnique({ where: { id } });
|
||||
if (!before) return;
|
||||
|
||||
// Archiver, pas supprimer : les créneaux passés portent cette étiquette et
|
||||
// les rapports d'heures s'y adossent. Une suppression laisserait des
|
||||
// semaines closes sans poste lisible.
|
||||
const archivedAt = restore ? null : new Date();
|
||||
await db.label.update({ where: { id }, data: { archivedAt } });
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: restore ? 'label.restore' : 'label.archive',
|
||||
entityType: 'Label',
|
||||
entityId: id,
|
||||
before: { archivedAt: before.archivedAt },
|
||||
after: { archivedAt: archivedAt?.toISOString() ?? null },
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath('/reglages/etiquettes');
|
||||
revalidatePath('/planning/etiquettes');
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate, query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Politiques de RTT — PLAN.md §9, réglage « /reglages/politiques-rtt ».
|
||||
*
|
||||
* Une politique porte un nombre de jours par an et une date de début de
|
||||
* période. Elle **se reconduit automatiquement** tant qu'elle est active ;
|
||||
* archivée, elle cesse de le faire — c'est le critère d'acceptation de WP-06.
|
||||
*
|
||||
* Les deux actions de ligne relevées à l'audit sont « Assigner des employés » et
|
||||
* « Archiver ». Pas de suppression : une politique a produit des acquisitions au
|
||||
* registre des compteurs, et l'effacer rendrait ces écritures inexplicables.
|
||||
*
|
||||
* **Autorisation.** `settings.agreement.manage`, comme les types d'absence : le
|
||||
* catalogue §5 ne déclare pas de code dédié, et un droit à RTT relève du
|
||||
* paramétrage conventionnel. Inventer une capacité aurait créé un droit sans
|
||||
* rien protéger de neuf.
|
||||
*/
|
||||
|
||||
export interface RttPolicyRow {
|
||||
id: string;
|
||||
name: string;
|
||||
daysPerYear: string;
|
||||
/** « MM-JJ ». */
|
||||
periodStart: string;
|
||||
autoRenew: boolean;
|
||||
status: 'ACTIVE' | 'ARCHIVED';
|
||||
assignedCount: number;
|
||||
assignees: Array<{ membershipId: string; name: string }>;
|
||||
}
|
||||
|
||||
export interface PolicyBoard {
|
||||
policies: RttPolicyRow[];
|
||||
/** Salariés assignables, pour le formulaire d'affectation. */
|
||||
candidates: Array<{ membershipId: string; name: string }>;
|
||||
}
|
||||
|
||||
export async function getPolicyBoard(
|
||||
includeArchived = false,
|
||||
): Promise<PolicyBoard> {
|
||||
return query('settings.access', async (db) => {
|
||||
const policies = await db.rttPolicy.findMany({
|
||||
where: includeArchived ? {} : { status: 'ACTIVE' },
|
||||
orderBy: { name: 'asc' },
|
||||
include: { assignments: true },
|
||||
});
|
||||
|
||||
const members = await db.membership.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
employeeNumber: true,
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const nameById = new Map(
|
||||
members.map((member) => [
|
||||
member.id,
|
||||
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
policies: policies.map((policy) => ({
|
||||
id: policy.id,
|
||||
name: policy.name,
|
||||
daysPerYear: policy.daysPerYear.toString(),
|
||||
periodStart: policy.periodStart,
|
||||
autoRenew: policy.autoRenew,
|
||||
status: policy.status,
|
||||
assignedCount: policy.assignments.length,
|
||||
assignees: policy.assignments
|
||||
.map((assignment) => ({
|
||||
membershipId: assignment.membershipId,
|
||||
name: nameById.get(assignment.membershipId) ?? 'Salarié',
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
|
||||
})),
|
||||
candidates: [...nameById.entries()]
|
||||
.map(([membershipId, name]) => ({ membershipId, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface ActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
class DuplicatePolicy extends Error {}
|
||||
|
||||
const policyInput = z.object({
|
||||
name: z.string().trim().min(1, 'Nom requis').max(160),
|
||||
daysPerYear: z.coerce
|
||||
.number()
|
||||
.min(0, 'Un droit négatif n’existe pas')
|
||||
.max(365, 'Un droit annuel ne dépasse pas 365 jours'),
|
||||
// « MM-JJ » plutôt qu'une date complète : la période se reconduit d'une année
|
||||
// sur l'autre, et porter un millésime obligerait à réécrire la politique
|
||||
// chaque janvier.
|
||||
periodStart: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/, 'Format attendu : MM-JJ'),
|
||||
autoRenew: z.boolean(),
|
||||
});
|
||||
|
||||
function readForm(formData: FormData) {
|
||||
return {
|
||||
name: formData.get('name'),
|
||||
daysPerYear: formData.get('daysPerYear') ?? 0,
|
||||
periodStart: formData.get('periodStart') ?? '',
|
||||
autoRenew: formData.get('autoRenew') === 'on',
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRttPolicyAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = policyInput.safeParse(readForm(formData));
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.agreement.manage', async (db, actor) => {
|
||||
const existing = await db.rttPolicy.findFirst({
|
||||
where: { name: parsed.data.name },
|
||||
});
|
||||
if (existing) throw new DuplicatePolicy();
|
||||
|
||||
const created = await db.rttPolicy.create({
|
||||
data: parsed.data as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'rtt_policy.create',
|
||||
entityType: 'RttPolicy',
|
||||
entityId: created.id,
|
||||
after: {
|
||||
name: created.name,
|
||||
daysPerYear: created.daysPerYear.toString(),
|
||||
periodStart: created.periodStart,
|
||||
autoRenew: created.autoRenew,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicatePolicy) {
|
||||
return { error: 'Une politique porte déjà ce nom.' };
|
||||
}
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer les politiques." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/politiques-rtt');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function archiveRttPolicyAction(
|
||||
formData: FormData,
|
||||
): Promise<void> {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
const restore = formData.get('restore') === '1';
|
||||
if (!id) return;
|
||||
|
||||
await mutate('settings.agreement.manage', async (db, actor) => {
|
||||
const before = await db.rttPolicy.findUnique({ where: { id } });
|
||||
if (!before) return;
|
||||
|
||||
// Archiver arrête la reconduction ; les affectations restent en place, et
|
||||
// les jours déjà acquis restent au registre. Une politique n'efface pas ce
|
||||
// qu'elle a produit.
|
||||
const status = restore ? 'ACTIVE' : 'ARCHIVED';
|
||||
await db.rttPolicy.update({ where: { id }, data: { status } });
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: restore ? 'rtt_policy.restore' : 'rtt_policy.archive',
|
||||
entityType: 'RttPolicy',
|
||||
entityId: id,
|
||||
before: { status: before.status },
|
||||
after: { status },
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath('/reglages/politiques-rtt');
|
||||
}
|
||||
|
||||
const assignInput = z.object({
|
||||
policyId: z.string().min(1),
|
||||
membershipIds: z.array(z.string().min(1)).min(1, 'Sélectionnez un salarié'),
|
||||
});
|
||||
|
||||
export async function assignRttPolicyAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = assignInput.safeParse({
|
||||
policyId: formData.get('policyId'),
|
||||
membershipIds: formData.getAll('membershipIds').map(String),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.agreement.manage', async (db, actor) => {
|
||||
const policy = await db.rttPolicy.findUnique({
|
||||
where: { id: parsed.data.policyId },
|
||||
});
|
||||
if (!policy) throw new AuthorizationError('settings.agreement.manage');
|
||||
if (policy.status === 'ARCHIVED') throw new ArchivedPolicy();
|
||||
|
||||
// Le périmètre est revérifié en base : un identifiant soumis depuis le
|
||||
// formulaire pourrait désigner un salarié d'un autre compte.
|
||||
const members = await db.membership.findMany({
|
||||
where: { id: { in: parsed.data.membershipIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const member of members) {
|
||||
// `upsert` sur la clé composée : réassigner un salarié déjà couvert ne
|
||||
// doit ni échouer ni créer un doublon d'acquisition.
|
||||
await db.rttPolicyAssignment.upsert({
|
||||
where: {
|
||||
rttPolicyId_membershipId: {
|
||||
rttPolicyId: policy.id,
|
||||
membershipId: member.id,
|
||||
},
|
||||
},
|
||||
create: { rttPolicyId: policy.id, membershipId: member.id } as never,
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'rtt_policy.assign',
|
||||
entityType: 'RttPolicy',
|
||||
entityId: policy.id,
|
||||
after: { membershipIds: members.map((member) => member.id) },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ArchivedPolicy) {
|
||||
return {
|
||||
error:
|
||||
'Cette politique est archivée : elle ne produit plus d’acquisition.',
|
||||
};
|
||||
}
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer les politiques." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/politiques-rtt');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
class ArchivedPolicy extends Error {}
|
||||
|
||||
export async function unassignRttPolicyAction(
|
||||
formData: FormData,
|
||||
): Promise<void> {
|
||||
const policyId = String(formData.get('policyId') ?? '');
|
||||
const membershipId = String(formData.get('membershipId') ?? '');
|
||||
if (!policyId || !membershipId) return;
|
||||
|
||||
await mutate('settings.agreement.manage', async (db, actor) => {
|
||||
const removed = await db.rttPolicyAssignment.deleteMany({
|
||||
where: { rttPolicyId: policyId, membershipId },
|
||||
});
|
||||
if (removed.count === 0) return;
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'rtt_policy.unassign',
|
||||
entityType: 'RttPolicy',
|
||||
entityId: policyId,
|
||||
before: { membershipId },
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath('/reglages/politiques-rtt');
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { referencedFields, unknownFields } from '@/domain/documents/template';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate, query } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Modèles de documents — PLAN.md §9, réglage « /reglages/modeles-documents ».
|
||||
*
|
||||
* L'écran valide les variables **à l'enregistrement**, pas à la génération. Un
|
||||
* modèle qui cite `{{salarie.salire}}` doit être refusé par l'administrateur
|
||||
* qui l'écrit, pas découvert par le gestionnaire qui tente d'éditer une
|
||||
* attestation devant le salarié qui attend.
|
||||
*/
|
||||
|
||||
export interface TemplateRow {
|
||||
id: string;
|
||||
name: string;
|
||||
bodyHtml: string;
|
||||
fields: string[];
|
||||
archivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
/** Pièces déjà générées depuis ce modèle. */
|
||||
documentCount: number;
|
||||
}
|
||||
|
||||
export async function listTemplates(
|
||||
includeArchived = false,
|
||||
): Promise<TemplateRow[]> {
|
||||
return query('settings.access', async (db) => {
|
||||
const templates = await db.documentTemplate.findMany({
|
||||
where: includeArchived ? {} : { archivedAt: null },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
const counts = await db.document.groupBy({
|
||||
by: ['templateId'],
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byId = new Map(
|
||||
counts.map((row) => [row.templateId, row._count._all] as const),
|
||||
);
|
||||
|
||||
return templates.map((template) => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
bodyHtml: template.bodyHtml,
|
||||
fields: Array.isArray(template.availableFields)
|
||||
? (template.availableFields as string[])
|
||||
: [],
|
||||
archivedAt: template.archivedAt,
|
||||
createdAt: template.createdAt,
|
||||
documentCount: byId.get(template.id) ?? 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export interface ActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
class DuplicateTemplate extends Error {}
|
||||
|
||||
const templateInput = z.object({
|
||||
name: z.string().trim().min(1, 'Nom requis').max(160),
|
||||
bodyHtml: z.string().trim().min(1, 'Corps requis').max(50_000),
|
||||
});
|
||||
|
||||
/** Refuse un corps citant une variable que l'application ne sait pas résoudre. */
|
||||
function rejectUnknownFields(bodyHtml: string): string | null {
|
||||
const unknown = unknownFields(bodyHtml);
|
||||
if (unknown.length === 0) return null;
|
||||
return `Variables inconnues : ${unknown.join(', ')}. Utilisez la liste ci-contre.`;
|
||||
}
|
||||
|
||||
export async function createTemplateAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = templateInput.safeParse({
|
||||
name: formData.get('name'),
|
||||
bodyHtml: formData.get('bodyHtml'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const rejected = rejectUnknownFields(parsed.data.bodyHtml);
|
||||
if (rejected) return { error: rejected };
|
||||
|
||||
try {
|
||||
await mutate('settings.templates.manage', async (db, actor) => {
|
||||
const existing = await db.documentTemplate.findFirst({
|
||||
where: { name: parsed.data.name },
|
||||
});
|
||||
if (existing) throw new DuplicateTemplate();
|
||||
|
||||
const created = await db.documentTemplate.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
bodyHtml: parsed.data.bodyHtml,
|
||||
// Les variables citées sont figées à l'enregistrement : elles
|
||||
// documentent ce que le modèle exige du dossier, et servent à
|
||||
// prévenir avant génération plutôt qu'à échouer pendant.
|
||||
availableFields: referencedFields(parsed.data.bodyHtml) as never,
|
||||
} as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'document_template.create',
|
||||
entityType: 'DocumentTemplate',
|
||||
entityId: created.id,
|
||||
after: { name: created.name },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateTemplate) {
|
||||
return { error: 'Un modèle porte déjà ce nom.' };
|
||||
}
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer les modèles." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/modeles-documents');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const updateInput = templateInput.extend({ id: z.string().min(1) });
|
||||
|
||||
export async function updateTemplateAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = updateInput.safeParse({
|
||||
id: formData.get('id'),
|
||||
name: formData.get('name'),
|
||||
bodyHtml: formData.get('bodyHtml'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const rejected = rejectUnknownFields(parsed.data.bodyHtml);
|
||||
if (rejected) return { error: rejected };
|
||||
|
||||
try {
|
||||
await mutate('settings.templates.manage', async (db, actor) => {
|
||||
const before = await db.documentTemplate.findUnique({
|
||||
where: { id: parsed.data.id },
|
||||
});
|
||||
if (!before) throw new AuthorizationError('settings.templates.manage');
|
||||
|
||||
await db.documentTemplate.update({
|
||||
where: { id: before.id },
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
bodyHtml: parsed.data.bodyHtml,
|
||||
availableFields: referencedFields(parsed.data.bodyHtml) as never,
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'document_template.update',
|
||||
entityType: 'DocumentTemplate',
|
||||
entityId: before.id,
|
||||
before: { name: before.name },
|
||||
after: { name: parsed.data.name },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de gérer les modèles." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/modeles-documents');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function archiveTemplateAction(formData: FormData): Promise<void> {
|
||||
const id = String(formData.get('id') ?? '');
|
||||
const restore = formData.get('restore') === '1';
|
||||
if (!id) return;
|
||||
|
||||
await mutate('settings.templates.manage', async (db, actor) => {
|
||||
const before = await db.documentTemplate.findUnique({ where: { id } });
|
||||
if (!before) return;
|
||||
|
||||
// Archiver, pas supprimer : une pièce déjà remise garde son `templateId`,
|
||||
// et perdre le modèle rendrait inexplicable la forme d'un acte signé.
|
||||
const archivedAt = restore ? null : new Date();
|
||||
await db.documentTemplate.update({ where: { id }, data: { archivedAt } });
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: restore
|
||||
? 'document_template.restore'
|
||||
: 'document_template.archive',
|
||||
entityType: 'DocumentTemplate',
|
||||
entityId: id,
|
||||
before: { archivedAt: before.archivedAt },
|
||||
after: { archivedAt: archivedAt?.toISOString() ?? null },
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath('/reglages/modeles-documents');
|
||||
}
|
||||
@@ -55,7 +55,7 @@ function nextWeekday(weekday: number): string {
|
||||
* attente » et « acceptée » bloquent.
|
||||
*/
|
||||
async function release(page: Page, marker: string, month?: string) {
|
||||
await page.goto(month ? `/conges?mois=${month}` : '/conges');
|
||||
await page.goto(month ? `/absences/calendrier?mois=${month}` : '/absences/calendrier');
|
||||
|
||||
const pending = page
|
||||
.locator('section')
|
||||
@@ -117,7 +117,7 @@ async function request(
|
||||
test('le formulaire nomme le dernier jour d’absence, pas la date de fin', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/conges');
|
||||
await page.goto('/absences/calendrier');
|
||||
// Le libellé exact coûte moins cher qu'une régularisation de solde.
|
||||
await expect(
|
||||
page.getByText('Dernier jour d’absence', { exact: true }),
|
||||
@@ -128,7 +128,7 @@ test('le formulaire nomme le dernier jour d’absence, pas la date de fin', asyn
|
||||
});
|
||||
|
||||
test('une demande apparaît dans la file, puis se décide', async ({ page }) => {
|
||||
await page.goto('/conges');
|
||||
await page.goto('/absences/calendrier');
|
||||
|
||||
const from = isoDate(0);
|
||||
const to = isoDate(4);
|
||||
@@ -160,7 +160,7 @@ test('une demande apparaît dans la file, puis se décide', async ({ page }) =>
|
||||
// La ligne est visée par **ses** dates, pas par « la première de ce
|
||||
// salarié » : les passages précédents en laissent d'autres dans le même
|
||||
// mois, et certaines n'offrent plus de bouton d'annulation.
|
||||
await page.goto(`/conges?mois=${from.slice(0, 7)}`);
|
||||
await page.goto(`/absences/calendrier?mois=${from.slice(0, 7)}`);
|
||||
const accepted = page
|
||||
.locator('section')
|
||||
.filter({ hasText: 'Absences du mois' })
|
||||
@@ -173,7 +173,7 @@ test('une demande apparaît dans la file, puis se décide', async ({ page }) =>
|
||||
});
|
||||
|
||||
test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
|
||||
await page.goto('/conges');
|
||||
await page.goto('/absences/calendrier');
|
||||
|
||||
await release(page, 'Chevauchement ');
|
||||
|
||||
@@ -208,7 +208,7 @@ test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('une période sans jour décomptable est refusée', async ({ page }) => {
|
||||
await page.goto('/conges');
|
||||
await page.goto('/absences/calendrier');
|
||||
|
||||
// Un dimanche seul : jamais ouvrable, donc rien à décompter.
|
||||
const sunday = nextWeekday(0);
|
||||
@@ -222,7 +222,7 @@ test('une période sans jour décomptable est refusée', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('un jour férié dans la période n’est pas décompté', async ({ page }) => {
|
||||
await page.goto('/conges');
|
||||
await page.goto('/absences/calendrier');
|
||||
|
||||
// Un jour férié pris au hasard parmi ceux de l'année : la période qui
|
||||
// l'englobe ne doit pas le décompter, et l'utilisateur n'a pas eu à scinder
|
||||
@@ -263,7 +263,7 @@ test('un jour férié dans la période n’est pas décompté', async ({ page })
|
||||
test('une date de fin antérieure au début rappelle la règle', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/conges');
|
||||
await page.goto('/absences/calendrier');
|
||||
|
||||
const form = await request(page, {
|
||||
from: isoDate(22),
|
||||
@@ -277,7 +277,7 @@ test('une date de fin antérieure au début rappelle la règle', async ({
|
||||
});
|
||||
|
||||
test('le solde revient à son niveau après un aller-retour', async ({ page }) => {
|
||||
await page.goto('/conges');
|
||||
await page.goto('/absences/calendrier');
|
||||
|
||||
const counters = page.locator('section').filter({ hasText: 'Compteurs' });
|
||||
await expect(counters).toBeVisible();
|
||||
|
||||
@@ -116,14 +116,15 @@ test('une pièce sans politique n’est jamais purgée', async ({ page }) => {
|
||||
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
|
||||
// en modale, à la demande.
|
||||
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
|
||||
const create = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
const create = page.locator('form').filter({ hasText: 'Informations salarié' });
|
||||
await create.getByLabel('Prénom').fill('Garde');
|
||||
await create.getByLabel('Nom', { exact: true }).fill(`Garde${suffix}`);
|
||||
await create.getByLabel('Nom de famille').fill(`Garde${suffix}`);
|
||||
await create.getByRole('button', { name: 'Saisir un matricule' }).click();
|
||||
await create.getByLabel('Matricule').fill(`RET${suffix}`);
|
||||
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
|
||||
// pas, et un salarié sans contrat doit rester créable.
|
||||
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
|
||||
await create.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await create.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(`Garde${suffix}`) }).click();
|
||||
@@ -173,14 +174,15 @@ test('une pièce échue est effectivement effacée', async ({ page }) => {
|
||||
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
|
||||
// en modale, à la demande.
|
||||
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
|
||||
const create = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
const create = page.locator('form').filter({ hasText: 'Informations salarié' });
|
||||
await create.getByLabel('Prénom').fill('Purge');
|
||||
await create.getByLabel('Nom', { exact: true }).fill(`Purge${suffix}`);
|
||||
await create.getByLabel('Nom de famille').fill(`Purge${suffix}`);
|
||||
await create.getByRole('button', { name: 'Saisir un matricule' }).click();
|
||||
await create.getByLabel('Matricule').fill(`PUR${suffix}`);
|
||||
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
|
||||
// pas, et un salarié sans contrat doit rester créable.
|
||||
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
|
||||
await create.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await create.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(`Purge${suffix}`) }).click();
|
||||
|
||||
@@ -30,14 +30,15 @@ async function createEmployee(page: Page, tag: string) {
|
||||
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
|
||||
// en modale, à la demande.
|
||||
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
|
||||
const form = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
|
||||
await form.getByLabel('Prénom').fill('Inès');
|
||||
await form.getByLabel('Nom', { exact: true }).fill(lastName);
|
||||
await form.getByLabel('Nom de famille').fill(lastName);
|
||||
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
|
||||
await form.getByLabel('Matricule').fill(`DOC${suffix}`);
|
||||
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
|
||||
// pas, et un salarié sans contrat doit rester créable.
|
||||
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
|
||||
await form.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await form.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(lastName) }).click();
|
||||
|
||||
@@ -17,14 +17,15 @@ async function createEmployee(page: Page) {
|
||||
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
|
||||
// en modale, à la demande.
|
||||
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
|
||||
const form = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
|
||||
await form.getByLabel('Prénom').fill('Awa');
|
||||
await form.getByLabel('Nom', { exact: true }).fill(lastName);
|
||||
await form.getByLabel('Nom de famille').fill(lastName);
|
||||
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
|
||||
await form.getByLabel('Matricule').fill(`FIC${suffix}`);
|
||||
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
|
||||
// pas, et un salarié sans contrat doit rester créable.
|
||||
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
|
||||
await form.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await form.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(lastName) }).click();
|
||||
@@ -105,9 +106,10 @@ test('une embauche pose le contrat en même temps que le dossier', async ({
|
||||
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
|
||||
// en modale, à la demande.
|
||||
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
|
||||
const form = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
|
||||
await form.getByLabel('Prénom').fill('Sofia');
|
||||
await form.getByLabel('Nom', { exact: true }).fill(lastName);
|
||||
await form.getByLabel('Nom de famille').fill(lastName);
|
||||
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
|
||||
await form.getByLabel('Matricule').fill(`EMB${suffix}`);
|
||||
|
||||
// Le contrat est proposé coché : un dossier créé sans lui n'apparaît sur
|
||||
@@ -116,7 +118,7 @@ test('une embauche pose le contrat en même temps que le dossier', async ({
|
||||
form.getByLabel('Ouvrir un contrat maintenant'),
|
||||
).toBeChecked();
|
||||
await form.getByLabel('Début du contrat').fill('2026-01-05');
|
||||
await form.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await form.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
// Le rattachement paraît dans l'annuaire : un dossier sans contrat y porte
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { formatMonthParam, monthOf, previousMonth } from '../../src/domain/planning/month';
|
||||
import { formatMonthParam, monthOf } from '../../src/domain/planning/month';
|
||||
|
||||
/**
|
||||
* Heures et périodes de paie.
|
||||
@@ -10,7 +10,22 @@ import { formatMonthParam, monthOf, previousMonth } from '../../src/domain/plann
|
||||
* plutôt qu'un bouton.
|
||||
*/
|
||||
|
||||
const MONTH = formatMonthParam(previousMonth(monthOf(new Date())));
|
||||
/**
|
||||
* Mois **courant**, et non le précédent.
|
||||
*
|
||||
* Le seed ne pose que deux semaines de planning : la courante et la
|
||||
* précédente. Le mois précédent n'en contient donc aucune, sauf quand on joue
|
||||
* la suite dans les premiers jours d'un mois — le tableau est vide le reste du
|
||||
* temps, et l'assertion échoue pour une raison sans rapport avec ce qu'elle
|
||||
* vérifie. Le défaut ne se voyait pas sur une base de développement, qui garde
|
||||
* les créneaux des exécutions précédentes ; l'intégration continue, elle,
|
||||
* repart d'un semis neuf.
|
||||
*
|
||||
* Le mois courant, lui, contient toujours aujourd'hui — donc toujours des
|
||||
* créneaux, et aucun n'a d'heures réelles puisque rien dans le semis ni dans la
|
||||
* suite n'en saisit.
|
||||
*/
|
||||
const MONTH = formatMonthParam(monthOf(new Date()));
|
||||
|
||||
/**
|
||||
* Mois propre à cette exécution.
|
||||
|
||||
@@ -24,15 +24,16 @@ async function createEmployee(page: Page, tag: string) {
|
||||
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
|
||||
// en modale, à la demande.
|
||||
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
|
||||
const form = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
|
||||
await form.getByLabel('Prénom').fill(firstName);
|
||||
await form.getByLabel('Nom', { exact: true }).fill(lastName);
|
||||
await form.getByLabel('Nom de famille').fill(lastName);
|
||||
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
|
||||
await form.getByLabel('Matricule').fill(`E2E${suffix}`);
|
||||
await form.getByLabel('Adresse électronique').fill(email);
|
||||
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
|
||||
// pas, et un salarié sans contrat doit rester créable.
|
||||
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
|
||||
await form.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await form.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
const row = page.getByRole('link', { name: new RegExp(lastName) });
|
||||
|
||||
@@ -52,7 +52,7 @@ test('une entrée de navigation non construite mène à un écran explicite', as
|
||||
page,
|
||||
}) => {
|
||||
// La barre latérale n'affiche que la section courante : on l'ouvre d'abord.
|
||||
await page.goto('/conges');
|
||||
await page.goto('/absences/calendrier');
|
||||
// « Politiques de congés » fait partie du périmètre visé mais n'est pas
|
||||
// encore construit : l'entrée reste, et mène à un écran qui le dit.
|
||||
await page.getByRole('link', { name: 'Politiques de congés' }).click();
|
||||
|
||||
@@ -58,14 +58,17 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
|
||||
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
|
||||
// en modale, à la demande.
|
||||
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
|
||||
const form = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
|
||||
await form.locator('input[name="firstName"]').fill('Sans');
|
||||
await form.locator('input[name="lastName"]').fill(nom);
|
||||
// Le matricule est proposé par l'application : ce test en impose un, parce
|
||||
// qu'il vérifie ensuite qu'on retrouve ce salarié par ce numéro.
|
||||
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
|
||||
await form.locator('input[name="employeeNumber"]').fill(matricule);
|
||||
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
|
||||
// pas, et un salarié sans contrat doit rester créable.
|
||||
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
|
||||
await page.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
// Rechargement explicite : ce qui est vérifié ici est la persistance et la
|
||||
|
||||
@@ -161,15 +161,16 @@ async function inviteWithRole(page: Page, roleLabel: string) {
|
||||
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
|
||||
// en modale, à la demande.
|
||||
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
|
||||
const create = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
const create = page.locator('form').filter({ hasText: 'Informations salarié' });
|
||||
await create.getByLabel('Prénom').fill('Alix');
|
||||
await create.getByLabel('Nom', { exact: true }).fill(lastName);
|
||||
await create.getByLabel('Nom de famille').fill(lastName);
|
||||
await create.getByRole('button', { name: 'Saisir un matricule' }).click();
|
||||
await create.getByLabel('Matricule').fill(`ROL${suffix}`);
|
||||
await create.getByLabel('Adresse électronique').fill(email);
|
||||
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
|
||||
// pas, et un salarié sans contrat doit rester créable.
|
||||
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
|
||||
await create.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await create.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(lastName) }).click();
|
||||
|
||||
@@ -154,15 +154,16 @@ async function createAccessibleEmployee(page: Page) {
|
||||
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
|
||||
// en modale, à la demande.
|
||||
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
|
||||
const form = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
|
||||
await form.getByLabel('Prénom').fill('Noé');
|
||||
await form.getByLabel('Nom', { exact: true }).fill(lastName);
|
||||
await form.getByLabel('Nom de famille').fill(lastName);
|
||||
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
|
||||
await form.getByLabel('Matricule').fill(`MFA${suffix}`);
|
||||
await form.getByLabel('Adresse électronique').fill(email);
|
||||
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
|
||||
// pas, et un salarié sans contrat doit rester créable.
|
||||
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
|
||||
await form.getByRole('button', { name: 'Ajouter' }).click();
|
||||
await form.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(lastName) }).click();
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('computeWeekCounters', () => {
|
||||
absenceMinutes: h(18),
|
||||
sundaysWorked: 0,
|
||||
restDays: 5,
|
||||
compensatoryRestMinutes: 0,
|
||||
});
|
||||
|
||||
expect(counters.deltaMinutes).toBe(0);
|
||||
@@ -64,6 +65,7 @@ describe('computeWeekCounters', () => {
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 2,
|
||||
restDays: 2,
|
||||
compensatoryRestMinutes: 0,
|
||||
});
|
||||
|
||||
expect(counters.deltaLabel).toBe('+1 h 30');
|
||||
@@ -77,6 +79,7 @@ describe('computeWeekCounters', () => {
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 0,
|
||||
restDays: 2,
|
||||
compensatoryRestMinutes: 0,
|
||||
});
|
||||
|
||||
expect(counters.deltaLabel).toBe('−1 h 45');
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
MissingTemplateValue,
|
||||
referencedFields,
|
||||
renderTemplate,
|
||||
TEMPLATE_FIELD_KEYS,
|
||||
unknownFields,
|
||||
} from '@/domain/documents/template';
|
||||
|
||||
describe('referencedFields', () => {
|
||||
it('relève les variables citées, sans doublon', () => {
|
||||
const body = '<p>{{salarie.nom}} — {{salarie.nom}} {{contrat.debut}}</p>';
|
||||
expect(referencedFields(body)).toEqual(['salarie.nom', 'contrat.debut']);
|
||||
});
|
||||
|
||||
it('tolère les espaces dans les accolades', () => {
|
||||
expect(referencedFields('{{ salarie.nom }}')).toEqual(['salarie.nom']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknownFields', () => {
|
||||
it('signale une variable que l’application ne sait pas résoudre', () => {
|
||||
// Le cas qui compte : la faute de frappe. Sans ce contrôle, le modèle
|
||||
// s'enregistre et échoue au moment de générer la pièce, devant le salarié.
|
||||
expect(unknownFields('{{salarie.salire}}')).toEqual(['salarie.salire']);
|
||||
});
|
||||
|
||||
it('accepte toutes les variables du catalogue', () => {
|
||||
const body = TEMPLATE_FIELD_KEYS.map((key) => `{{${key}}}`).join(' ');
|
||||
expect(unknownFields(body)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTemplate', () => {
|
||||
it('remplace les variables par leur valeur', () => {
|
||||
const out = renderTemplate('<p>{{salarie.nom}}</p>', {
|
||||
'salarie.nom': 'Ferrand',
|
||||
});
|
||||
expect(out).toBe('<p>Ferrand</p>');
|
||||
});
|
||||
|
||||
it('échappe la valeur, jamais le gabarit', () => {
|
||||
// Le corps est rédigé par un administrateur et reste de l'HTML ; la valeur
|
||||
// vient du dossier et pourrait porter un chevron.
|
||||
const out = renderTemplate('<p><strong>{{salarie.nom}}</strong></p>', {
|
||||
'salarie.nom': '<script>alert(1)</script>',
|
||||
});
|
||||
expect(out).toBe(
|
||||
'<p><strong><script>alert(1)</script></strong></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuse de rendre une variable sans valeur', () => {
|
||||
// Une attestation trouée est un document faux, pas un document incomplet :
|
||||
// le blanc se remarque une fois sur deux, et la pièce part signée.
|
||||
expect(() =>
|
||||
renderTemplate('{{salarie.nom}} {{contrat.debut}}', {
|
||||
'salarie.nom': 'Ferrand',
|
||||
}),
|
||||
).toThrow(MissingTemplateValue);
|
||||
});
|
||||
|
||||
it('traite la chaîne vide comme une valeur manquante', () => {
|
||||
expect(() =>
|
||||
renderTemplate('{{salarie.nom}}', { 'salarie.nom': '' }),
|
||||
).toThrow(MissingTemplateValue);
|
||||
});
|
||||
|
||||
it('nomme les variables manquantes dans l’erreur', () => {
|
||||
try {
|
||||
renderTemplate('{{salarie.nom}} {{contrat.debut}}', {});
|
||||
expect.unreachable('le rendu aurait dû échouer');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(MissingTemplateValue);
|
||||
expect((error as MissingTemplateValue).keys).toEqual([
|
||||
'salarie.nom',
|
||||
'contrat.debut',
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,9 @@ describe('matches', () => {
|
||||
});
|
||||
|
||||
it('écarte les liens d’ancre', () => {
|
||||
expect(matches('/conges#attente', '/conges')).toBe(false);
|
||||
expect(
|
||||
matches('/absences/calendrier#compteurs', '/absences/calendrier'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +63,8 @@ describe('sectionForPath', () => {
|
||||
it('trouve la section portant la route courante', () => {
|
||||
expect(sectionForPath('/planning/semaine').id).toBe('plannings');
|
||||
expect(sectionForPath('/equipe/camille-ferrand').id).toBe('equipe');
|
||||
expect(sectionForPath('/conges').id).toBe('conges');
|
||||
expect(sectionForPath('/absences/calendrier').id).toBe('conges');
|
||||
expect(sectionForPath('/absences/a-traiter').id).toBe('conges');
|
||||
});
|
||||
|
||||
it('retombe sur la première section pour une route inconnue', () => {
|
||||
|
||||
@@ -194,6 +194,28 @@ describe('buildRows', () => {
|
||||
'14:00–19:00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ne compte au RC que le repos compensateur', () => {
|
||||
// Le repos hebdomadaire est un droit déjà pris ; le compensateur est une
|
||||
// contrepartie due. Les additionner ferait disparaître la dette dans un
|
||||
// total qui ne veut rien dire.
|
||||
const { rows } = buildRows([person()], [], DATES, TZ, false, [], [
|
||||
{ membershipId: 'm1', restType: 'COMPENSATORY_REST', minutes: 420 },
|
||||
{ membershipId: 'm1', restType: 'WEEKLY_REST', minutes: 2100 },
|
||||
]);
|
||||
|
||||
expect(rows[0]?.counters.compensatoryRestMinutes).toBe(420);
|
||||
});
|
||||
|
||||
it('tolère un repos sans durée', () => {
|
||||
// `Rest.minutes` est nullable : un repos posé sans durée vaut zéro minute,
|
||||
// pas NaN — un NaN se propagerait au bandeau et à l'export.
|
||||
const { rows } = buildRows([person()], [], DATES, TZ, false, [], [
|
||||
{ membershipId: 'm1', restType: 'COMPENSATORY_REST', minutes: null },
|
||||
]);
|
||||
|
||||
expect(rows[0]?.counters.compensatoryRestMinutes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shiftState', () => {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { BoardRow } from '@/domain/planning/board';
|
||||
import {
|
||||
absenceOnDay,
|
||||
dayState,
|
||||
headcountByDay,
|
||||
} from '@/domain/planning/presence';
|
||||
|
||||
function row(overrides: Partial<BoardRow> = {}): BoardRow {
|
||||
return {
|
||||
membershipId: 'm1',
|
||||
firstName: 'Camille',
|
||||
lastName: 'Ferrand',
|
||||
job: 'Vendeuse',
|
||||
forfaitJours: false,
|
||||
counters: {
|
||||
contractMinutes: 0,
|
||||
plannedMinutes: 0,
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 0,
|
||||
restDays: 0,
|
||||
compensatoryRestMinutes: 0,
|
||||
},
|
||||
days: [[], [], [], [], [], [], []],
|
||||
absences: [],
|
||||
unassigned: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const shift: BoardRow['days'][number][number] = {
|
||||
id: 's1',
|
||||
poste: 'cai',
|
||||
time: '09:00–17:00',
|
||||
minutes: 480,
|
||||
breakMinutes: 30,
|
||||
state: 'published',
|
||||
note: null,
|
||||
};
|
||||
|
||||
describe('dayState', () => {
|
||||
it('dit « repos » sur une case vide', () => {
|
||||
expect(dayState(row(), 0)).toBe('off');
|
||||
});
|
||||
|
||||
it('dit « présent » quand la case porte un créneau', () => {
|
||||
const days: BoardRow['days'] = [[shift], [], [], [], [], [], []];
|
||||
expect(dayState(row({ days }), 0)).toBe('present');
|
||||
});
|
||||
|
||||
it('fait primer l’absence sur le créneau resté au planning', () => {
|
||||
// Le cas qui compte : le congé est validé après la construction de la
|
||||
// semaine, les créneaux n'ont pas été retirés. Afficher « présent »
|
||||
// enverrait un responsable chercher quelqu'un qui est chez lui.
|
||||
const days: BoardRow['days'] = [[shift], [], [], [], [], [], []];
|
||||
const absences = [
|
||||
{ label: 'Congé payé', colorKey: 'neutral', startDay: 0, span: 2 },
|
||||
];
|
||||
expect(dayState(row({ days, absences }), 0)).toBe('absent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('absenceOnDay', () => {
|
||||
const absences = [
|
||||
{ label: 'Congé payé', colorKey: 'neutral', startDay: 2, span: 3 },
|
||||
];
|
||||
|
||||
it('couvre le premier et le dernier jour de la plage', () => {
|
||||
// La borne haute est le dernier jour d'absence, pas le jour de reprise :
|
||||
// `startDay + span` est donc exclu.
|
||||
expect(absenceOnDay(row({ absences }), 2)?.label).toBe('Congé payé');
|
||||
expect(absenceOnDay(row({ absences }), 4)?.label).toBe('Congé payé');
|
||||
});
|
||||
|
||||
it('ne déborde ni avant ni après', () => {
|
||||
expect(absenceOnDay(row({ absences }), 1)).toBeUndefined();
|
||||
expect(absenceOnDay(row({ absences }), 5)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('headcountByDay', () => {
|
||||
it('compte présents, absents et repos sur chaque colonne', () => {
|
||||
const present = row({
|
||||
membershipId: 'a',
|
||||
days: [[shift], [shift], [], [], [], [], []],
|
||||
});
|
||||
const absent = row({
|
||||
membershipId: 'b',
|
||||
absences: [
|
||||
{ label: 'Maladie', colorKey: 'neutral', startDay: 0, span: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const tally = headcountByDay([present, absent], 7);
|
||||
|
||||
expect(tally[0]).toEqual({ present: 1, absent: 1, off: 0 });
|
||||
expect(tally[1]).toEqual({ present: 1, absent: 0, off: 1 });
|
||||
expect(tally[6]).toEqual({ present: 0, absent: 0, off: 2 });
|
||||
});
|
||||
|
||||
it('écarte la ligne des besoins non couverts', () => {
|
||||
// Elle ne porte personne : la compter en repos gonflerait un effectif qui
|
||||
// n'existe pas.
|
||||
const unassigned = row({ membershipId: null, unassigned: true });
|
||||
expect(headcountByDay([unassigned], 7)[0]).toEqual({
|
||||
present: 0,
|
||||
absent: 0,
|
||||
off: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
minutesToTime,
|
||||
PREFERENCE_KEYS,
|
||||
PREFERENCE_TOGGLES,
|
||||
timeToMinutes,
|
||||
togglesOf,
|
||||
} from '@/domain/settings/preferences';
|
||||
|
||||
describe('catalogue de préférences', () => {
|
||||
it('n’a pas deux fois la même clé', () => {
|
||||
expect(new Set(PREFERENCE_KEYS).size).toBe(PREFERENCE_KEYS.length);
|
||||
});
|
||||
|
||||
it('range chaque préférence dans un seul groupe', () => {
|
||||
// Les groupes se déduisent du catalogue plutôt que d'être listés ici : une
|
||||
// liste tenue à la main laisserait un groupe entier sans écran, et le test
|
||||
// passerait quand même.
|
||||
const groups = [
|
||||
...new Set(PREFERENCE_TOGGLES.map((toggle) => toggle.group)),
|
||||
];
|
||||
const grouped = groups.flatMap((group) => togglesOf(group));
|
||||
expect(grouped).toHaveLength(PREFERENCE_TOGGLES.length);
|
||||
});
|
||||
|
||||
it('avertit sur les réglages qui changent un calcul', () => {
|
||||
// Un interrupteur qui déplace des heures entre normales et majorées ne doit
|
||||
// pas se présenter comme un réglage d'affichage.
|
||||
for (const key of [
|
||||
'smoothOvertimeMonthly',
|
||||
'includeRestInNormalHours',
|
||||
'paidBreaks',
|
||||
]) {
|
||||
const toggle = PREFERENCE_TOGGLES.find((entry) => entry.key === key);
|
||||
expect(toggle?.warning, `${key} sans avertissement`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('heure de bascule du soir', () => {
|
||||
it('convertit dans les deux sens', () => {
|
||||
expect(minutesToTime(1200)).toBe('20:00');
|
||||
expect(timeToMinutes('20:00')).toBe(1200);
|
||||
expect(minutesToTime(0)).toBe('00:00');
|
||||
expect(timeToMinutes('00:00')).toBe(0);
|
||||
});
|
||||
|
||||
it('refuse une heure invalide plutôt que de la ramener à zéro', () => {
|
||||
// Silencieusement ramenée à minuit, une saisie fautive ferait passer tous
|
||||
// les créneaux pour des créneaux du soir.
|
||||
expect(timeToMinutes('24:00')).toBeNull();
|
||||
expect(timeToMinutes('20:60')).toBeNull();
|
||||
expect(timeToMinutes('8:00')).toBeNull();
|
||||
expect(timeToMinutes('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -117,3 +117,22 @@ describe('rendu du registre', () => {
|
||||
expect(Buffer.from(pdf).subarray(0, 5).toString()).toBe('%PDF-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matricule proposé', () => {
|
||||
it('part à E0001 sur un effectif vide', async () => {
|
||||
const { nextEmployeeNumber } = await import('@/domain/hr/civil-status');
|
||||
expect(nextEmployeeNumber([])).toBe('E0001');
|
||||
});
|
||||
|
||||
it('suit le dernier rang attribué', async () => {
|
||||
const { nextEmployeeNumber } = await import('@/domain/hr/civil-status');
|
||||
expect(nextEmployeeNumber(['E0001', 'E0007', 'E0003'])).toBe('E0008');
|
||||
});
|
||||
|
||||
it('ignore les matricules repris d’un autre outil', async () => {
|
||||
const { nextEmployeeNumber } = await import('@/domain/hr/civil-status');
|
||||
// Un client qui migre apporte ses propres formes : les compter fausserait
|
||||
// le rang, et refuser l'embauche pour cette raison serait absurde.
|
||||
expect(nextEmployeeNumber(['SILAE-42', '00012', 'E0002'])).toBe('E0003');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user