Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f656d05af | ||
|
|
d8060eb4a9 | ||
|
|
f0ef6f12a1 | ||
|
|
8c12b08961 | ||
|
|
db03768025 | ||
|
|
5b60e7ddb6 | ||
|
|
1519b6bf9d | ||
|
|
e4be93a208 | ||
|
|
98b289784a | ||
|
|
527ea2e78a | ||
|
|
9e57467138 | ||
|
|
b608aa87cf | ||
|
|
06198ddbce | ||
|
|
e27d61dd4c | ||
|
|
3bf2cbabff | ||
|
|
8808fcdb95 | ||
|
|
b49ceb1f29 | ||
|
|
894e880198 | ||
|
|
1d64989239 | ||
|
|
93724b9d66 | ||
|
|
91f3b240e1 | ||
|
|
5a113de02f | ||
|
|
bb8f84696a | ||
|
|
c6abbaadc5 | ||
|
|
5a05fda2f9 | ||
|
|
61a273548b |
No files matched your search
@@ -34,6 +34,7 @@
|
||||
"@prisma/client": "7.9.1",
|
||||
"next": "16.3.0",
|
||||
"nodemailer": "^9.0.5",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "8.22.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.8",
|
||||
|
||||
Generated
+37
@@ -23,6 +23,9 @@ importers:
|
||||
nodemailer:
|
||||
specifier: ^9.0.5
|
||||
version: 9.0.5
|
||||
pdf-lib:
|
||||
specifier: ^1.17.1
|
||||
version: 1.17.1
|
||||
pg:
|
||||
specifier: 8.22.0
|
||||
version: 8.22.0
|
||||
@@ -769,6 +772,12 @@ packages:
|
||||
'@oxc-project/types@0.143.0':
|
||||
resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==}
|
||||
|
||||
'@pdf-lib/standard-fonts@1.0.0':
|
||||
resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
|
||||
|
||||
'@pdf-lib/upng@1.0.1':
|
||||
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
|
||||
|
||||
'@playwright/test@1.62.1':
|
||||
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2689,6 +2698,9 @@ packages:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2707,6 +2719,9 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdf-lib@1.17.1:
|
||||
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
|
||||
|
||||
perfect-debounce@2.1.0:
|
||||
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
|
||||
|
||||
@@ -3137,6 +3152,9 @@ packages:
|
||||
tsconfig-paths@3.15.0:
|
||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||
|
||||
tslib@1.14.1:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -3900,6 +3918,14 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.143.0': {}
|
||||
|
||||
'@pdf-lib/standard-fonts@1.0.0':
|
||||
dependencies:
|
||||
pako: 1.0.11
|
||||
|
||||
'@pdf-lib/upng@1.0.1':
|
||||
dependencies:
|
||||
pako: 1.0.11
|
||||
|
||||
'@playwright/test@1.62.1':
|
||||
dependencies:
|
||||
playwright: 1.62.1
|
||||
@@ -5943,6 +5969,8 @@ snapshots:
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -5955,6 +5983,13 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdf-lib@1.17.1:
|
||||
dependencies:
|
||||
'@pdf-lib/standard-fonts': 1.0.0
|
||||
'@pdf-lib/upng': 1.0.1
|
||||
pako: 1.0.11
|
||||
tslib: 1.14.1
|
||||
|
||||
perfect-debounce@2.1.0: {}
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
@@ -6432,6 +6467,8 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-bom: 3.0.0
|
||||
|
||||
tslib@1.14.1: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.20.6:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- État civil complet du dossier salarié.
|
||||
--
|
||||
-- Ces mentions manquaient au schéma alors que la loi les réclame ou que la
|
||||
-- paie s'appuie dessus :
|
||||
--
|
||||
-- - le **sexe** est exigé au registre unique du personnel (art. D1221-23) ;
|
||||
-- sans lui, tout dossier y sort incomplet ;
|
||||
-- - le **nom de naissance** ne se déduit pas du nom d'usage, et c'est lui que
|
||||
-- portent la déclaration sociale et le registre ;
|
||||
-- - le **pays** et le **département** de naissance sont demandés séparément
|
||||
-- par la déclaration : les extraire d'une commune saisie librement échouerait
|
||||
-- au premier « Bar-le-Duc (Meuse) » ;
|
||||
-- - la **situation de famille** et les **personnes à charge** commandent des
|
||||
-- droits que le salarié seul peut déclarer ;
|
||||
-- - l'**heure d'embauche** est demandée par la DPAE, que la date seule ne
|
||||
-- suffit pas à remplir.
|
||||
--
|
||||
-- Toutes les colonnes sont facultatives : un dossier incomplet doit pouvoir
|
||||
-- exister — c'est au registre de signaler ce qui lui manque, pas à la base de
|
||||
-- refuser l'embauche. Seule exception, l'envoi des plannings par SMS, qui est
|
||||
-- un consentement : explicitement faux par défaut, la charge de la preuve
|
||||
-- pesant sur l'employeur.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Gender" AS ENUM ('FEMALE', 'MALE', 'UNSPECIFIED');
|
||||
|
||||
-- Le PACS est distinct du concubinage et du mariage : les trois n'ouvrent pas
|
||||
-- les mêmes droits, et les confondre fausserait la paie.
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MaritalStatus" AS ENUM ('SINGLE', 'MARRIED', 'PACS', 'COHABITING', 'DIVORCED', 'WIDOWED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EmployeeProfile" ADD COLUMN "addressLine2" TEXT,
|
||||
ADD COLUMN "birthCountry" TEXT,
|
||||
ADD COLUMN "birthDepartment" TEXT,
|
||||
ADD COLUMN "birthName" TEXT,
|
||||
ADD COLUMN "dependents" INTEGER,
|
||||
ADD COLUMN "gender" "Gender",
|
||||
ADD COLUMN "landline" TEXT,
|
||||
ADD COLUMN "maritalStatus" "MaritalStatus",
|
||||
ADD COLUMN "smsSchedules" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserContract" ADD COLUMN "startTime" TEXT;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Modèles de documents — PLAN.md §4.7 et WP-10.
|
||||
--
|
||||
-- `templateId` sur Document est en ON DELETE SET NULL et non CASCADE : une
|
||||
-- pièce déjà remise à un salarié ne doit pas disparaître parce qu'on a effacé
|
||||
-- le modèle qui l'a produite. Le lien se perd, le document reste.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE "DocumentTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"bodyHtml" TEXT NOT NULL,
|
||||
"availableFields" JSONB NOT NULL,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DocumentTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "DocumentTemplate_accountId_name_key" ON "DocumentTemplate"("accountId", "name");
|
||||
|
||||
CREATE INDEX "DocumentTemplate_accountId_idx" ON "DocumentTemplate"("accountId");
|
||||
|
||||
ALTER TABLE "DocumentTemplate" ADD CONSTRAINT "DocumentTemplate_accountId_fkey"
|
||||
FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Document" ADD COLUMN "templateId" TEXT;
|
||||
|
||||
CREATE INDEX "Document_templateId_idx" ON "Document"("templateId");
|
||||
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_templateId_fkey"
|
||||
FOREIGN KEY ("templateId") REFERENCES "DocumentTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Isolation par compte — même défense en profondeur que les autres tables.
|
||||
--
|
||||
-- Un modèle porte le corps d'attestations et de courriers types : sa fuite
|
||||
-- livrerait la raison sociale, les mentions et la forme des actes d'un autre
|
||||
-- client. L'extension Prisma filtre déjà ; ces règles valent pour le cas où
|
||||
-- elle serait contournée.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE "DocumentTemplate" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "DocumentTemplate" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON "DocumentTemplate"
|
||||
USING ("accountId" = planflow_current_account());
|
||||
|
||||
CREATE POLICY tenant_insert ON "DocumentTemplate"
|
||||
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
|
||||
@@ -0,0 +1,67 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Siège social et préférences du compte — PLAN.md §9.
|
||||
--
|
||||
-- Les colonnes d'adresse vont sur Account et non sur Location : le siège figure
|
||||
-- sur les documents édités et au registre du personnel, et il n'est pas
|
||||
-- nécessairement celui d'un magasin.
|
||||
--
|
||||
-- Les préférences sont une table à part plutôt que dix colonnes de plus sur
|
||||
-- Account : elles changent des calculs, se relisent ensemble, et la séparation
|
||||
-- garde lisible ce qui identifie l'entreprise et ce qui paramètre l'outil.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE "Account" ADD COLUMN "addressLine" TEXT;
|
||||
ALTER TABLE "Account" ADD COLUMN "postalCode" TEXT;
|
||||
ALTER TABLE "Account" ADD COLUMN "city" TEXT;
|
||||
ALTER TABLE "Account" ADD COLUMN "country" TEXT NOT NULL DEFAULT 'FR';
|
||||
ALTER TABLE "Account" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'Europe/Paris';
|
||||
|
||||
CREATE TABLE "AccountPreferences" (
|
||||
"accountId" TEXT NOT NULL,
|
||||
|
||||
"defaultMealPerShift" BOOLEAN NOT NULL DEFAULT false,
|
||||
"paidBreaks" BOOLEAN NOT NULL DEFAULT false,
|
||||
"lockEmployeeMeals" BOOLEAN NOT NULL DEFAULT false,
|
||||
"employeesSeeOwnTotals" BOOLEAN NOT NULL DEFAULT true,
|
||||
"employeesSeeTeamPlanning" BOOLEAN NOT NULL DEFAULT false,
|
||||
"hideForfaitCounters" BOOLEAN NOT NULL DEFAULT true,
|
||||
"eveningShiftStartMinutes" INTEGER NOT NULL DEFAULT 1200,
|
||||
|
||||
"employeesEditOwnProfile" BOOLEAN NOT NULL DEFAULT true,
|
||||
"employeesSeeOwnTimesheets" BOOLEAN NOT NULL DEFAULT true,
|
||||
"employeesSeeTeamContacts" BOOLEAN NOT NULL DEFAULT false,
|
||||
"managerCreatedArePlannable" BOOLEAN NOT NULL DEFAULT false,
|
||||
"directorsSharePeople" BOOLEAN NOT NULL DEFAULT false,
|
||||
"directorsAccessHrDashboard" BOOLEAN NOT NULL DEFAULT true,
|
||||
"directorsSeePaidLeave" BOOLEAN NOT NULL DEFAULT true,
|
||||
"managersSeePaidLeave" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
"smoothOvertimeMonthly" BOOLEAN NOT NULL DEFAULT false,
|
||||
"includeRestInNormalHours" BOOLEAN NOT NULL DEFAULT false,
|
||||
"autoEmployeeNumber" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AccountPreferences_pkey" PRIMARY KEY ("accountId")
|
||||
);
|
||||
|
||||
ALTER TABLE "AccountPreferences" ADD CONSTRAINT "AccountPreferences_accountId_fkey"
|
||||
FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Isolation par compte.
|
||||
--
|
||||
-- La clé primaire *est* l'identifiant de compte, si bien qu'une fuite
|
||||
-- supposerait déjà de connaître l'identifiant visé. La politique reste posée :
|
||||
-- la défense en profondeur ne se dispense pas au motif que l'attaque serait
|
||||
-- malcommode.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE "AccountPreferences" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "AccountPreferences" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON "AccountPreferences"
|
||||
USING ("accountId" = planflow_current_account());
|
||||
|
||||
CREATE POLICY tenant_insert ON "AccountPreferences"
|
||||
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
|
||||
@@ -0,0 +1,17 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Impression et productivité — PLAN.md §9, `/reglages/impression` et
|
||||
-- `/reglages/productivite`.
|
||||
--
|
||||
-- `productivityTargetPerHour` est nullable et non « 0 par défaut » : zéro est un
|
||||
-- objectif — inatteignable — alors que l'absence de valeur dit qu'aucun objectif
|
||||
-- n'a été fixé. Un écran qui affiche « 0 €/h attendu » pousse à corriger un
|
||||
-- réglage qui n'existe pas.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printLandscape" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printDensity" TEXT NOT NULL DEFAULT 'large';
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printContractTotals" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printOtherTeams" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printSunday" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "printSignatureColumn" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "AccountPreferences" ADD COLUMN "productivityTargetPerHour" DECIMAL(10,2);
|
||||
+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' },
|
||||
];
|
||||
+43
-13
@@ -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.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { getAbsenceQueue } from '@/server/absences/queries';
|
||||
|
||||
export const metadata = { title: 'Demandes à traiter · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ATraiterPage() {
|
||||
const result = await getAbsenceQueue('pending');
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Demandes à traiter"
|
||||
subtitle={
|
||||
result.rows.length === 0
|
||||
? 'Aucune demande en attente'
|
||||
: `${result.rows.length} demande${result.rows.length > 1 ? 's' : ''} en attente, la plus ancienne en premier`
|
||||
}
|
||||
/>
|
||||
|
||||
<AbsenceQueueView
|
||||
result={result}
|
||||
title="File d’attente"
|
||||
description="Aucune demande en attente de décision."
|
||||
/>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Les demandes sont classées par ancienneté : celle qui attend depuis le
|
||||
plus longtemps arrive en tête. Accepter écrit au registre des compteurs ;
|
||||
annuler ensuite contre-passe l’écriture plutôt que de l’effacer, et le
|
||||
solde revient au même chiffre.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons';
|
||||
import { RequestForm } from '@/components/absences/RequestForm';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||
import { datesBetween, isoWeekday } from '@/domain/absences/count';
|
||||
import { monthOf, parseMonthParam } from '@/domain/planning/month';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { getAbsenceBoard, statusLabel } from '@/server/absences/queries';
|
||||
|
||||
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ mois?: string }>;
|
||||
}
|
||||
|
||||
const COUNTER_LABELS: Record<string, string> = {
|
||||
PAID_LEAVE: 'Congés payés',
|
||||
RTT: 'RTT',
|
||||
COMPENSATORY_REST: 'Repos compensateur',
|
||||
MODULATION: 'Modulation',
|
||||
OVERTIME: 'Heures supplémentaires',
|
||||
};
|
||||
|
||||
const STATUS_TONES: Record<string, Tone> = {
|
||||
PENDING: 'warn',
|
||||
ACCEPTED: 'ok',
|
||||
DECLINED: 'danger',
|
||||
CANCELLED: 'neutral',
|
||||
EXPIRED: 'neutral',
|
||||
};
|
||||
|
||||
export default async function CongesPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
|
||||
const board = await getAbsenceBoard(month);
|
||||
|
||||
const href = (mois: string) => `/absences/calendrier?mois=${mois}`;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Calendrier des absences"
|
||||
subtitle={`${board.label} · ${board.inMonth.length} absence${board.inMonth.length > 1 ? 's' : ''} sur le mois`}
|
||||
actions={
|
||||
<>
|
||||
<Link
|
||||
href={href(board.previousParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
← Mois précédent
|
||||
</Link>
|
||||
<Link
|
||||
href={href(board.nextParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
Mois suivant →
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<RequestForm
|
||||
absenceTypes={board.absenceTypes}
|
||||
people={board.people}
|
||||
ownMembershipId={board.ownMembershipId}
|
||||
/>
|
||||
|
||||
<section id="attente" className="rounded-3 border border-line-1 bg-surface">
|
||||
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
Demandes en attente
|
||||
{board.pending.length > 0 ? ` · ${board.pending.length}` : ''}
|
||||
</h2>
|
||||
|
||||
{board.pending.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">
|
||||
Aucune demande à traiter.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{board.pending.map((request) => (
|
||||
<li
|
||||
key={request.id}
|
||||
className="flex flex-wrap items-center gap-3 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-56 flex-1">
|
||||
<p className="text-sm font-medium text-ink-1">
|
||||
{request.name} · {request.typeLabel}
|
||||
</p>
|
||||
<p className="text-micro text-ink-3">
|
||||
du {frenchDate(request.startDate)} au{' '}
|
||||
{frenchDate(request.endDate)} ·{' '}
|
||||
<span className="tnum">{request.days}</span> jour
|
||||
{request.days > 1 ? 's' : ''} décompté
|
||||
{request.days > 1 ? 's' : ''}
|
||||
</p>
|
||||
{request.comment ? (
|
||||
<p className="mt-0.5 text-micro text-ink-2">
|
||||
« {request.comment} »
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{board.canDecide ? (
|
||||
<DecisionButtons timeOffId={request.id} />
|
||||
) : (
|
||||
<CancelButton timeOffId={request.id} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<MonthCalendar board={board} />
|
||||
|
||||
<section className="rounded-3 border border-line-1 bg-surface">
|
||||
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
Absences du mois
|
||||
</h2>
|
||||
{board.inMonth.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">
|
||||
Aucune absence sur cette période.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{board.inMonth.map((request) => (
|
||||
<li
|
||||
key={request.id}
|
||||
className="flex flex-wrap items-center gap-3 px-4 py-2.5"
|
||||
>
|
||||
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
|
||||
{statusLabel(request.status)}
|
||||
</Badge>
|
||||
<span className="text-sm text-ink-1">{request.name}</span>
|
||||
<span className="text-sm text-ink-2">{request.typeLabel}</span>
|
||||
<span className="text-micro text-ink-3">
|
||||
{frenchDate(request.startDate)} → {frenchDate(request.endDate)}
|
||||
</span>
|
||||
<span className="tnum ml-auto text-xs text-ink-2">
|
||||
{request.days} j
|
||||
</span>
|
||||
{request.status === 'ACCEPTED' ? (
|
||||
<CancelButton timeOffId={request.id} />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="compteurs"
|
||||
className="rounded-3 border border-line-1 bg-surface"
|
||||
>
|
||||
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
Compteurs
|
||||
</h2>
|
||||
{board.counters.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">
|
||||
Aucun compteur ouvert. Un compteur se crée à la première écriture —
|
||||
acceptation d’un congé ou ajustement manuel.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left">
|
||||
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
|
||||
Salarié
|
||||
</th>
|
||||
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
|
||||
Compteur
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
|
||||
Acquis
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
|
||||
Pris
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
|
||||
Solde
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{board.counters.map((counter) => (
|
||||
<tr
|
||||
key={`${counter.membershipId}-${counter.counterType}`}
|
||||
className="border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<td className="px-4 py-1.5 text-ink-1">{counter.name}</td>
|
||||
<td className="px-4 py-1.5 text-ink-2">
|
||||
{COUNTER_LABELS[counter.counterType] ?? counter.counterType}
|
||||
</td>
|
||||
<td className="tnum px-4 py-1.5 text-right text-ink-2">
|
||||
{counter.accrued}
|
||||
</td>
|
||||
<td className="tnum px-4 py-1.5 text-right text-ink-2">
|
||||
{counter.taken}
|
||||
</td>
|
||||
<td className="tnum px-4 py-1.5 text-right font-semibold text-ink-1">
|
||||
{counter.balance}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p className="border-t border-line-1 px-4 py-2 text-micro text-ink-3">
|
||||
Le solde est la somme des écritures du registre, jamais une valeur
|
||||
stockée : un solde stocké se désynchronise, et la désynchronisation ne
|
||||
se voit qu’au moment où un salarié conteste.
|
||||
</p>
|
||||
</section>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
/** Calendrier du mois, une colonne par jour. */
|
||||
function MonthCalendar({
|
||||
board,
|
||||
}: {
|
||||
board: Awaited<ReturnType<typeof getAbsenceBoard>>;
|
||||
}) {
|
||||
const accepted = board.inMonth.filter(
|
||||
(request) => request.status === 'ACCEPTED' || request.status === 'PENDING',
|
||||
);
|
||||
const people = [...new Set(accepted.map((request) => request.membershipId))];
|
||||
|
||||
if (people.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<caption className="sr-only">
|
||||
Calendrier des absences, {board.label}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 bg-surface-2">
|
||||
<th
|
||||
scope="col"
|
||||
className="sticky left-0 z-10 min-w-40 bg-surface-2 px-3 py-2 text-left text-micro font-semibold text-ink-3 uppercase"
|
||||
>
|
||||
Salarié
|
||||
</th>
|
||||
{board.days.map((date) => (
|
||||
<th
|
||||
key={date}
|
||||
scope="col"
|
||||
className={cx(
|
||||
'tnum w-7 px-0.5 py-2 text-center font-semibold',
|
||||
isoWeekday(date) >= 6
|
||||
? 'bg-surface-3 text-ink-2'
|
||||
: 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{Number(date.slice(8, 10))}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{people.map((membershipId) => {
|
||||
const own = accepted.filter(
|
||||
(request) => request.membershipId === membershipId,
|
||||
);
|
||||
const covered = new Map<string, (typeof own)[number]>();
|
||||
for (const request of own) {
|
||||
for (const date of datesBetween(
|
||||
request.startDate,
|
||||
request.endDate,
|
||||
)) {
|
||||
covered.set(date, request);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={membershipId}
|
||||
className="border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
className="sticky left-0 z-10 bg-surface px-3 py-1.5 text-left text-sm font-medium text-ink-1"
|
||||
>
|
||||
{own[0]?.name ?? ''}
|
||||
</th>
|
||||
{board.days.map((date) => {
|
||||
const request = covered.get(date);
|
||||
return (
|
||||
<td
|
||||
key={date}
|
||||
className={cx(
|
||||
'border-l border-line-1 px-0.5 py-1.5 text-center',
|
||||
isoWeekday(date) >= 6 && 'bg-surface-2',
|
||||
)}
|
||||
title={
|
||||
request
|
||||
? `${request.typeLabel} — ${statusLabel(request.status)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{request ? (
|
||||
<span
|
||||
className={cx(
|
||||
'block h-4 rounded-1',
|
||||
request.status === 'PENDING'
|
||||
? 'border border-dashed border-warn bg-warn-soft'
|
||||
: 'bg-info-soft',
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{request.typeLabel} le {date}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function frenchDate(isoDate: string): string {
|
||||
return isoDate.split('-').reverse().join('/');
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { getAbsenceQueue } from '@/server/absences/queries';
|
||||
|
||||
export const metadata = { title: 'Absences expirées · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ExpireesPage() {
|
||||
const result = await getAbsenceQueue('expired');
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Absences expirées"
|
||||
subtitle="Demandes dont la date est passée sans décision"
|
||||
/>
|
||||
|
||||
<AbsenceQueueView
|
||||
result={result}
|
||||
title="Demandes expirées"
|
||||
description="Aucune demande n’a expiré."
|
||||
/>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Une demande expirée n’a jamais été décidée : elle n’a donc rien écrit au
|
||||
registre des compteurs, et le solde du salarié n’en porte aucune trace.
|
||||
Elle reste visible parce qu’une absence non traitée à temps est un fait
|
||||
opposable — au salarié qui l’a demandée comme à l’employeur qui ne l’a
|
||||
pas tranchée.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { getAbsenceQueue } from '@/server/absences/queries';
|
||||
|
||||
export const metadata = { title: 'Absences traitées · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ nature?: string }>;
|
||||
}
|
||||
|
||||
export default async function TraiteesPage({ searchParams }: PageProps) {
|
||||
const { nature } = await searchParams;
|
||||
// Filtre relevé à l'audit des menus : « Toutes les absences » ou
|
||||
// « Uniquement les absences Sécurité sociale ». Deux valeurs, pas trois.
|
||||
const socialSecurityOnly = nature === 'ss';
|
||||
const result = await getAbsenceQueue('treated', { socialSecurityOnly });
|
||||
|
||||
const tab = (value: string | null, label: string) => {
|
||||
const active = (value === 'ss') === socialSecurityOnly;
|
||||
return (
|
||||
<Link
|
||||
href={value ? `/absences/traitees?nature=${value}` : '/absences/traitees'}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={
|
||||
active
|
||||
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
|
||||
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Absences traitées"
|
||||
subtitle="Décisions rendues : acceptées, refusées et annulées"
|
||||
/>
|
||||
|
||||
<AbsenceQueueView
|
||||
result={result}
|
||||
title="Décisions rendues"
|
||||
description="Aucune absence traitée pour ce filtre."
|
||||
filterBar={
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{tab(null, 'Toutes les absences')}
|
||||
{tab('ss', 'Uniquement Sécurité sociale')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Le motif d’une absence Sécurité sociale est une donnée de santé : il
|
||||
n’apparaît qu’aux personnes habilitées à lire les pièces du dossier. Pour
|
||||
les autres, la ligne dit « Absence » et rien de plus.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
+17
-327
@@ -1,332 +1,22 @@
|
||||
import Link from 'next/link';
|
||||
import { permanentRedirect } from 'next/navigation';
|
||||
|
||||
import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons';
|
||||
import { RequestForm } from '@/components/absences/RequestForm';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||
import { datesBetween, isoWeekday } from '@/domain/absences/count';
|
||||
import { monthOf, parseMonthParam } from '@/domain/planning/month';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { getAbsenceBoard, statusLabel } from '@/server/absences/queries';
|
||||
|
||||
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ mois?: string }>;
|
||||
}
|
||||
|
||||
const COUNTER_LABELS: Record<string, string> = {
|
||||
PAID_LEAVE: 'Congés payés',
|
||||
RTT: 'RTT',
|
||||
COMPENSATORY_REST: 'Repos compensateur',
|
||||
MODULATION: 'Modulation',
|
||||
OVERTIME: 'Heures supplémentaires',
|
||||
};
|
||||
|
||||
const STATUS_TONES: Record<string, Tone> = {
|
||||
PENDING: 'warn',
|
||||
ACCEPTED: 'ok',
|
||||
DECLINED: 'danger',
|
||||
CANCELLED: 'neutral',
|
||||
EXPIRED: 'neutral',
|
||||
};
|
||||
|
||||
export default async function CongesPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
|
||||
const board = await getAbsenceBoard(month);
|
||||
|
||||
const href = (mois: string) => `/conges?mois=${mois}`;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Calendrier des absences"
|
||||
subtitle={`${board.label} · ${board.inMonth.length} absence${board.inMonth.length > 1 ? 's' : ''} sur le mois`}
|
||||
actions={
|
||||
<>
|
||||
<Link
|
||||
href={href(board.previousParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
← Mois précédent
|
||||
</Link>
|
||||
<Link
|
||||
href={href(board.nextParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
Mois suivant →
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<RequestForm
|
||||
absenceTypes={board.absenceTypes}
|
||||
people={board.people}
|
||||
ownMembershipId={board.ownMembershipId}
|
||||
/>
|
||||
|
||||
<section id="attente" className="rounded-3 border border-line-1 bg-surface">
|
||||
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
Demandes en attente
|
||||
{board.pending.length > 0 ? ` · ${board.pending.length}` : ''}
|
||||
</h2>
|
||||
|
||||
{board.pending.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">
|
||||
Aucune demande à traiter.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{board.pending.map((request) => (
|
||||
<li
|
||||
key={request.id}
|
||||
className="flex flex-wrap items-center gap-3 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-56 flex-1">
|
||||
<p className="text-sm font-medium text-ink-1">
|
||||
{request.name} · {request.typeLabel}
|
||||
</p>
|
||||
<p className="text-micro text-ink-3">
|
||||
du {frenchDate(request.startDate)} au{' '}
|
||||
{frenchDate(request.endDate)} ·{' '}
|
||||
<span className="tnum">{request.days}</span> jour
|
||||
{request.days > 1 ? 's' : ''} décompté
|
||||
{request.days > 1 ? 's' : ''}
|
||||
</p>
|
||||
{request.comment ? (
|
||||
<p className="mt-0.5 text-micro text-ink-2">
|
||||
« {request.comment} »
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{board.canDecide ? (
|
||||
<DecisionButtons timeOffId={request.id} />
|
||||
) : (
|
||||
<CancelButton timeOffId={request.id} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<MonthCalendar board={board} />
|
||||
|
||||
<section className="rounded-3 border border-line-1 bg-surface">
|
||||
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
Absences du mois
|
||||
</h2>
|
||||
{board.inMonth.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">
|
||||
Aucune absence sur cette période.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{board.inMonth.map((request) => (
|
||||
<li
|
||||
key={request.id}
|
||||
className="flex flex-wrap items-center gap-3 px-4 py-2.5"
|
||||
>
|
||||
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
|
||||
{statusLabel(request.status)}
|
||||
</Badge>
|
||||
<span className="text-sm text-ink-1">{request.name}</span>
|
||||
<span className="text-sm text-ink-2">{request.typeLabel}</span>
|
||||
<span className="text-micro text-ink-3">
|
||||
{frenchDate(request.startDate)} → {frenchDate(request.endDate)}
|
||||
</span>
|
||||
<span className="tnum ml-auto text-xs text-ink-2">
|
||||
{request.days} j
|
||||
</span>
|
||||
{request.status === 'ACCEPTED' ? (
|
||||
<CancelButton timeOffId={request.id} />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="compteurs"
|
||||
className="rounded-3 border border-line-1 bg-surface"
|
||||
>
|
||||
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
Compteurs
|
||||
</h2>
|
||||
{board.counters.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">
|
||||
Aucun compteur ouvert. Un compteur se crée à la première écriture —
|
||||
acceptation d’un congé ou ajustement manuel.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left">
|
||||
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
|
||||
Salarié
|
||||
</th>
|
||||
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
|
||||
Compteur
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
|
||||
Acquis
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
|
||||
Pris
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
|
||||
Solde
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{board.counters.map((counter) => (
|
||||
<tr
|
||||
key={`${counter.membershipId}-${counter.counterType}`}
|
||||
className="border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<td className="px-4 py-1.5 text-ink-1">{counter.name}</td>
|
||||
<td className="px-4 py-1.5 text-ink-2">
|
||||
{COUNTER_LABELS[counter.counterType] ?? counter.counterType}
|
||||
</td>
|
||||
<td className="tnum px-4 py-1.5 text-right text-ink-2">
|
||||
{counter.accrued}
|
||||
</td>
|
||||
<td className="tnum px-4 py-1.5 text-right text-ink-2">
|
||||
{counter.taken}
|
||||
</td>
|
||||
<td className="tnum px-4 py-1.5 text-right font-semibold text-ink-1">
|
||||
{counter.balance}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p className="border-t border-line-1 px-4 py-2 text-micro text-ink-3">
|
||||
Le solde est la somme des écritures du registre, jamais une valeur
|
||||
stockée : un solde stocké se désynchronise, et la désynchronisation ne
|
||||
se voit qu’au moment où un salarié conteste.
|
||||
</p>
|
||||
</section>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
/** Calendrier du mois, une colonne par jour. */
|
||||
function MonthCalendar({
|
||||
board,
|
||||
/**
|
||||
* Ancienne adresse du calendrier des absences.
|
||||
*
|
||||
* Les absences vivent désormais sous `/absences`, avec une route par état
|
||||
* (§9). Le calendrier a déménagé plutôt que d'être dupliqué : deux écrans
|
||||
* lisant le même modèle finissent toujours par diverger d'une correction.
|
||||
*
|
||||
* La redirection est **permanente** et conserve le mois demandé : un lien de
|
||||
* planning partagé dans un message continue d'ouvrir la bonne page.
|
||||
*/
|
||||
export default async function CongesRedirect({
|
||||
searchParams,
|
||||
}: {
|
||||
board: Awaited<ReturnType<typeof getAbsenceBoard>>;
|
||||
searchParams: Promise<{ mois?: string }>;
|
||||
}) {
|
||||
const accepted = board.inMonth.filter(
|
||||
(request) => request.status === 'ACCEPTED' || request.status === 'PENDING',
|
||||
);
|
||||
const people = [...new Set(accepted.map((request) => request.membershipId))];
|
||||
|
||||
if (people.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<caption className="sr-only">
|
||||
Calendrier des absences, {board.label}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 bg-surface-2">
|
||||
<th
|
||||
scope="col"
|
||||
className="sticky left-0 z-10 min-w-40 bg-surface-2 px-3 py-2 text-left text-micro font-semibold text-ink-3 uppercase"
|
||||
>
|
||||
Salarié
|
||||
</th>
|
||||
{board.days.map((date) => (
|
||||
<th
|
||||
key={date}
|
||||
scope="col"
|
||||
className={cx(
|
||||
'tnum w-7 px-0.5 py-2 text-center font-semibold',
|
||||
isoWeekday(date) >= 6
|
||||
? 'bg-surface-3 text-ink-2'
|
||||
: 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{Number(date.slice(8, 10))}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{people.map((membershipId) => {
|
||||
const own = accepted.filter(
|
||||
(request) => request.membershipId === membershipId,
|
||||
);
|
||||
const covered = new Map<string, (typeof own)[number]>();
|
||||
for (const request of own) {
|
||||
for (const date of datesBetween(
|
||||
request.startDate,
|
||||
request.endDate,
|
||||
)) {
|
||||
covered.set(date, request);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={membershipId}
|
||||
className="border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
className="sticky left-0 z-10 bg-surface px-3 py-1.5 text-left text-sm font-medium text-ink-1"
|
||||
>
|
||||
{own[0]?.name ?? ''}
|
||||
</th>
|
||||
{board.days.map((date) => {
|
||||
const request = covered.get(date);
|
||||
return (
|
||||
<td
|
||||
key={date}
|
||||
className={cx(
|
||||
'border-l border-line-1 px-0.5 py-1.5 text-center',
|
||||
isoWeekday(date) >= 6 && 'bg-surface-2',
|
||||
)}
|
||||
title={
|
||||
request
|
||||
? `${request.typeLabel} — ${statusLabel(request.status)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{request ? (
|
||||
<span
|
||||
className={cx(
|
||||
'block h-4 rounded-1',
|
||||
request.status === 'PENDING'
|
||||
? 'border border-dashed border-warn bg-warn-soft'
|
||||
: 'bg-info-soft',
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{request.typeLabel} le {date}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
const { mois } = await searchParams;
|
||||
permanentRedirect(
|
||||
mois ? `/absences/calendrier?mois=${mois}` : '/absences/calendrier',
|
||||
);
|
||||
}
|
||||
|
||||
function frenchDate(isoDate: string): string {
|
||||
return isoDate.split('-').reverse().join('/');
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type {
|
||||
ContractLocation,
|
||||
HiringOptions,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
/**
|
||||
* Embauche, en panneau latéral.
|
||||
*
|
||||
* Une trentaine de champs : posés au bas de la liste, ils occupaient plus de
|
||||
* place que l'effectif qu'on vient consulter. Le panneau glisse par la droite
|
||||
* et laisse l'annuaire visible — on embauche en regardant qui est déjà là.
|
||||
*
|
||||
* `<dialog>` natif plutôt qu'un panneau maison : le piège de focus, la
|
||||
* fermeture par Échap et le fond inerte viennent avec, et une `<div>` doit les
|
||||
* réimplémenter sans jamais les tenir tout à fait.
|
||||
*/
|
||||
export function AddEmployeeDialog({
|
||||
locations,
|
||||
options,
|
||||
}: {
|
||||
locations: ContractLocation[];
|
||||
options: HiringOptions;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
if (!element) return;
|
||||
if (open && !element.open) element.showModal();
|
||||
if (!open && element.open) element.close();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setOpen(true)}>
|
||||
Ajouter un collaborateur
|
||||
</Button>
|
||||
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-labelledby="titre-embauche"
|
||||
onClose={() => setOpen(false)}
|
||||
className="mt-0 mr-0 mb-0 ml-auto h-full max-h-none w-[min(30rem,100vw)] max-w-none border-l border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-3 border-b border-line-1 px-5 py-4">
|
||||
<h2 id="titre-embauche" className="text-md font-semibold">
|
||||
Nouvel employé
|
||||
</h2>
|
||||
<span className="flex-1" />
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Fermer
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pt-5">
|
||||
{open ? (
|
||||
// Monté à l'ouverture : un formulaire démonté entre deux usages
|
||||
// repart vide, sans traîner la saisie abandonnée la fois d'avant.
|
||||
<AddEmployeeForm
|
||||
locations={locations}
|
||||
options={options}
|
||||
onSaved={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { useActionState, useState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
@@ -8,38 +8,329 @@ import {
|
||||
createEmployeeAction,
|
||||
type ActionState,
|
||||
} from '@/server/employees/actions';
|
||||
import type {
|
||||
ContractLocation,
|
||||
HiringOptions,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
export function AddEmployeeForm() {
|
||||
/**
|
||||
* Embauche.
|
||||
*
|
||||
* Deux sections et un seul envoi : ce qui identifie la personne, puis ce qui
|
||||
* l'emploie. Un salarié créé sans contrat ni équipe est un dossier que rien ne
|
||||
* rattache — il n'apparaît sur aucune grille et ne se déclare pas — d'où un
|
||||
* contrat proposé d'emblée, et décochable seulement pour les cas où
|
||||
* l'établissement n'est pas encore tranché.
|
||||
*/
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
const CONTRACT_TYPES = [
|
||||
['CDI', 'CDI'],
|
||||
['CDD', 'CDD'],
|
||||
['APPRENTISSAGE', 'Apprentissage'],
|
||||
['STAGIAIRE', 'Stagiaire'],
|
||||
['SAISONNIER', 'Saisonnier'],
|
||||
['EXTRA', 'Extra'],
|
||||
['INTERIM', 'Intérim'],
|
||||
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
|
||||
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
|
||||
] as const;
|
||||
|
||||
const selectClass =
|
||||
'h-9 min-w-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
export function AddEmployeeForm({
|
||||
locations,
|
||||
options,
|
||||
onSaved,
|
||||
}: {
|
||||
/** Vide quand la capacité d'ouvrir un contrat manque : la section disparaît. */
|
||||
locations: ContractLocation[];
|
||||
options: HiringOptions;
|
||||
/** Appelé une fois l'ajout accepté — le panneau s'y referme. */
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const [state, formAction] = useActionState<ActionState, FormData>(
|
||||
createEmployeeAction,
|
||||
{},
|
||||
empty,
|
||||
);
|
||||
const [withContract, setWithContract] = useState(locations.length > 0);
|
||||
const [locationId, setLocationId] = useState(locations[0]?.id ?? '');
|
||||
const [forfait, setForfait] = useState(false);
|
||||
const [autoNumber, setAutoNumber] = useState(true);
|
||||
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
|
||||
|
||||
// Acquitté une fois pour toutes : sans cela, un succès resté en mémoire
|
||||
// refermerait le panneau à sa réouverture.
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
onSaved?.();
|
||||
}
|
||||
|
||||
const teams =
|
||||
locations.find((location) => location.id === locationId)?.teams ?? [];
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={formAction}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-3"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Section title="Informations salarié">
|
||||
<Field label="Prénom" name="firstName" required />
|
||||
<Field label="Nom" name="lastName" required />
|
||||
<Field label="Matricule" name="employeeNumber" required />
|
||||
</div>
|
||||
<Field
|
||||
label="Nom de naissance"
|
||||
name="birthName"
|
||||
hint="Laissez vide s’il est identique au nom de famille."
|
||||
/>
|
||||
<Field label="Nom de famille" name="lastName" required />
|
||||
|
||||
<Field
|
||||
label="Adresse électronique"
|
||||
name="email"
|
||||
type="email"
|
||||
hint="Facultative. Un salarié sans adresse reste plannifiable et déclarable ; il n'aura simplement pas d'accès à l'application."
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-sm font-medium">Matricule</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoNumber(!autoNumber)}
|
||||
className="text-micro font-medium text-accent-soft-ink underline underline-offset-2"
|
||||
>
|
||||
{autoNumber
|
||||
? 'Saisir un matricule'
|
||||
: 'Passer à la génération automatique'}
|
||||
</button>
|
||||
</div>
|
||||
{autoNumber ? (
|
||||
<p className="text-micro text-ink-3">
|
||||
Attribué à la suite du dernier, au moment de l’enregistrement.
|
||||
</p>
|
||||
) : (
|
||||
<input
|
||||
name="employeeNumber"
|
||||
aria-label="Matricule"
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field label="Date de naissance" name="birthDate" type="date" />
|
||||
|
||||
<Field
|
||||
label="Adresse électronique"
|
||||
name="email"
|
||||
type="email"
|
||||
hint="Permet de se connecter à PlanFlow et de recevoir ses plannings. Sans elle, le salarié reste planifiable et déclarable."
|
||||
/>
|
||||
<Field label="Téléphone mobile" name="phone" type="tel" />
|
||||
|
||||
<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"
|
||||
name="withContract"
|
||||
checked={withContract}
|
||||
onChange={(event) => setWithContract(event.target.checked)}
|
||||
className="size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
Ouvrir un contrat maintenant
|
||||
</label>
|
||||
|
||||
{withContract ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Date de début de contrat"
|
||||
name="startDate"
|
||||
type="date"
|
||||
required
|
||||
defaultValue={today()}
|
||||
/>
|
||||
<Field
|
||||
label="Heure de début de contrat"
|
||||
name="startTime"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="09:00"
|
||||
hint="Demandée par la DPAE."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Organisation du temps
|
||||
</span>
|
||||
<select
|
||||
name="workTimeArrangement"
|
||||
className={selectClass}
|
||||
onChange={(event) =>
|
||||
setForfait(event.target.value === 'FORFAIT_JOURS')
|
||||
}
|
||||
>
|
||||
<option value="HOURLY">Décompte horaire</option>
|
||||
<option value="FORFAIT_JOURS">Forfait jours</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{forfait ? (
|
||||
<Field
|
||||
label="Jours par an"
|
||||
name="forfaitDaysPerYear"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="218"
|
||||
defaultValue="218"
|
||||
/>
|
||||
) : (
|
||||
<Field
|
||||
label="Temps de travail hebdomadaire"
|
||||
name="weeklyHours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="60"
|
||||
defaultValue="35"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{forfait ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Référence de la convention de forfait"
|
||||
name="forfaitAgreementRef"
|
||||
required
|
||||
hint="Sans convention individuelle écrite, le forfait est inopposable."
|
||||
/>
|
||||
<Field
|
||||
label="Date d’accord du salarié"
|
||||
name="forfaitAgreedAt"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label="Fin du contrat"
|
||||
name="endDate"
|
||||
type="date"
|
||||
hint="Vide pour un contrat sans terme."
|
||||
/>
|
||||
|
||||
<label className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
Établissement par défaut
|
||||
</span>
|
||||
<select
|
||||
name="locationId"
|
||||
className={selectClass}
|
||||
value={locationId}
|
||||
onChange={(event) => setLocationId(event.target.value)}
|
||||
>
|
||||
{locations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<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}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Filtres de l'annuaire.
|
||||
*
|
||||
* L'état vit dans l'adresse, pas dans le composant : un effectif filtré
|
||||
* s'envoie par lien, se met en favori, et le retour arrière défait le dernier
|
||||
* choix plutôt que de vider la page.
|
||||
*/
|
||||
|
||||
/** Pastilles plutôt que champs : une barre de filtres se survole, elle ne se
|
||||
* remplit pas. */
|
||||
const selectClass =
|
||||
'h-10 min-w-0 cursor-pointer rounded-full border border-line-2 bg-surface px-4 text-sm text-ink-1 outline-none hover:bg-surface-2 focus-visible:border-focus';
|
||||
|
||||
export interface FilterOptions {
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
roles: Array<{ id: string; name: string }>;
|
||||
contractTypes: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
export function DirectoryFilters({ options }: { options: FilterOptions }) {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const current = (key: string) => params.get(key) ?? '';
|
||||
|
||||
function apply(key: string, value: string) {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
router.replace(next.size > 0 ? `/equipe?${next}` : '/equipe');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="sr-only">Rechercher un collaborateur</span>
|
||||
<input
|
||||
name="q"
|
||||
type="search"
|
||||
defaultValue={current('q')}
|
||||
placeholder="Rechercher par prénom, nom ou matricule."
|
||||
className="h-11 rounded-full border border-line-2 bg-surface px-4 text-sm text-ink-1 outline-none placeholder:text-ink-3 focus-visible:border-focus"
|
||||
onChange={(event) => {
|
||||
// La frappe ne déclenche pas une requête par caractère : elle
|
||||
// attend un silence. Sans cela, chercher « Dupont » en lance six.
|
||||
const value = event.target.value;
|
||||
if (debounce.current) clearTimeout(debounce.current);
|
||||
debounce.current = setTimeout(() => apply('q', value), 300);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<select
|
||||
aria-label="Établissement"
|
||||
className={selectClass}
|
||||
value={current('etablissement')}
|
||||
onChange={(event) => apply('etablissement', event.target.value)}
|
||||
>
|
||||
<option value="">Tous les établissements</option>
|
||||
{options.locations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
aria-label="Rôle"
|
||||
className={selectClass}
|
||||
value={current('role')}
|
||||
onChange={(event) => apply('role', event.target.value)}
|
||||
>
|
||||
<option value="">Tous les rôles</option>
|
||||
{options.roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
aria-label="Type de contrat"
|
||||
className={selectClass}
|
||||
value={current('contrat')}
|
||||
onChange={(event) => apply('contrat', event.target.value)}
|
||||
>
|
||||
<option value="">Tous les types de contrats</option>
|
||||
{options.contractTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
aria-label="Présence"
|
||||
className={selectClass}
|
||||
value={current('presence')}
|
||||
onChange={(event) => apply('presence', event.target.value)}
|
||||
>
|
||||
<option value="">Utilisateurs actifs</option>
|
||||
<option value="archived">Archivés</option>
|
||||
<option value="all">Tous</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
aria-label="Tri"
|
||||
className={selectClass}
|
||||
value={current('tri')}
|
||||
onChange={(event) => apply('tri', event.target.value)}
|
||||
>
|
||||
<option value="">Tri par nom</option>
|
||||
<option value="number">Tri par matricule</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
/**
|
||||
* Édition du registre unique du personnel.
|
||||
*
|
||||
* L'établissement est demandé avant le téléchargement, et non déduit : le
|
||||
* registre se tient **par établissement**, et en tirer un au hasard pour une
|
||||
* entreprise qui en compte trente ne répondrait à aucune demande d'inspection.
|
||||
*
|
||||
* L'avertissement sur les mentions manquantes arrive avant le fichier, pas
|
||||
* dedans : découvrir un registre incomplet en l'ouvrant, c'est le découvrir
|
||||
* devant l'inspection.
|
||||
*/
|
||||
|
||||
export interface RegisterLocation {
|
||||
id: string;
|
||||
name: string;
|
||||
incomplete: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function RegisterDialog({
|
||||
locations,
|
||||
}: {
|
||||
locations: RegisterLocation[];
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [locationId, setLocationId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
if (!element) return;
|
||||
if (open && !element.open) element.showModal();
|
||||
if (!open && element.open) element.close();
|
||||
}, [open]);
|
||||
|
||||
const chosen = locations.find((location) => location.id === locationId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
Registre unique du personnel
|
||||
</Button>
|
||||
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-labelledby="titre-rup"
|
||||
onClose={() => setOpen(false)}
|
||||
className="m-auto w-[min(38rem,calc(100vw-2rem))] rounded-3 border border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]"
|
||||
>
|
||||
<header className="flex items-center gap-3 px-5 pt-5">
|
||||
<h2 id="titre-rup" className="text-lg font-semibold">
|
||||
Téléchargez votre registre unique du personnel
|
||||
</h2>
|
||||
<span className="flex-1" />
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Fermer
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<p className="rounded-3 border border-line-1 bg-surface-2 p-4 text-sm leading-[var(--lh-prose)] text-ink-2">
|
||||
Tout employeur doit tenir un registre unique du personnel par
|
||||
établissement. Aucune forme n’est imposée : en cas de contrôle par
|
||||
l’inspection du travail, le format numérique peut être présenté.
|
||||
</p>
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
Établissement pour lequel éditer le registre
|
||||
</span>
|
||||
<select
|
||||
value={locationId}
|
||||
onChange={(event) => setLocationId(event.target.value)}
|
||||
className="h-10 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
|
||||
>
|
||||
<option value="">Choisissez un établissement…</option>
|
||||
{locations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{chosen && chosen.incomplete > 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-3 border border-warn bg-warn-soft p-4 text-sm text-warn-soft-ink"
|
||||
>
|
||||
<p className="font-semibold">
|
||||
Des mentions manquent pour {chosen.incomplete} salarié
|
||||
{chosen.incomplete > 1 ? 's' : ''} de cet établissement
|
||||
</p>
|
||||
<p className="mt-1 leading-[var(--lh-prose)]">
|
||||
Le registre reste éditable, et les manques y figurent en clair.
|
||||
Un registre incomplet expose à une contravention par salarié
|
||||
concerné : complétez les dossiers avant de le présenter.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{chosen && chosen.total === 0 ? (
|
||||
<p className="text-sm text-ink-3">
|
||||
Aucun contrat n’est rattaché à cet établissement : le registre
|
||||
sera vide.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-3 border-t border-line-1 px-5 py-4">
|
||||
<Button onClick={() => setOpen(false)}>Annuler</Button>
|
||||
{/* Un lien, pas un bouton : le navigateur télécharge sans quitter la
|
||||
page, et l'adresse reste rejouable. */}
|
||||
<a
|
||||
href={
|
||||
locationId ? `/equipe/registre?etablissement=${locationId}` : '#'
|
||||
}
|
||||
aria-disabled={!locationId}
|
||||
onClick={(event) => {
|
||||
if (!locationId) event.preventDefault();
|
||||
else setOpen(false);
|
||||
}}
|
||||
className={
|
||||
locationId
|
||||
? 'inline-flex h-8 items-center rounded-2 border border-accent bg-accent px-3.5 text-sm font-medium text-accent-ink hover:bg-accent-hover'
|
||||
: 'pointer-events-none inline-flex h-8 items-center rounded-2 border border-line-2 bg-surface-3 px-3.5 text-sm font-medium text-ink-3'
|
||||
}
|
||||
>
|
||||
Télécharger
|
||||
</a>
|
||||
</footer>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Carte de consultation du dossier.
|
||||
*
|
||||
* Deux colonnes par ligne — l'intitulé, puis la valeur — et un filet entre
|
||||
* chaque : c'est la forme d'un état civil, qui se parcourt du regard en
|
||||
* cherchant un champ précis plutôt qu'en lisant de haut en bas.
|
||||
*/
|
||||
export function InfoCard({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-3 border border-line-1 bg-surface p-5">
|
||||
<header className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-7 flex-none rounded-2 border border-accent bg-accent-soft"
|
||||
/>
|
||||
<h2 className="text-md font-semibold">{title}</h2>
|
||||
</header>
|
||||
<dl className="mt-4">{children}</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoRow({
|
||||
label,
|
||||
value,
|
||||
tnum = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
/** Chiffres alignés : dates, téléphones, numéros. */
|
||||
tnum?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line-1 py-3 last:border-b-0">
|
||||
<dt className="min-w-[11rem] flex-none text-sm font-medium text-ink-1">
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={
|
||||
value
|
||||
? `text-sm text-ink-2${tnum ? ' tnum' : ''}`
|
||||
: 'text-sm text-ink-3'
|
||||
}
|
||||
>
|
||||
{value || 'Non renseigné'}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Grille à deux colonnes des cartes du dossier. */
|
||||
export function InfoGrid({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid items-start gap-5 [grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr))]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
/**
|
||||
* Onglets de la fiche salarié.
|
||||
*
|
||||
* Chaque onglet est une **route**, pas un état de composant : une fiche
|
||||
* s'envoie par lien, se rouvre au même endroit, et le retour arrière ramène à
|
||||
* l'onglet précédent plutôt qu'à la liste.
|
||||
*/
|
||||
|
||||
const TABS = [
|
||||
{ segment: '', label: 'Informations personnelles' },
|
||||
{ segment: 'contrats', label: 'Contrats' },
|
||||
{ segment: 'planification', label: 'Planification et accès' },
|
||||
{ segment: 'absences', label: 'Congés et Absences' },
|
||||
{ segment: 'documents', label: 'Documents' },
|
||||
] as const;
|
||||
|
||||
export function MemberTabs({ id }: { id: string }) {
|
||||
const pathname = usePathname();
|
||||
const base = `/equipe/${id}`;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections de la fiche"
|
||||
className="flex gap-1 overflow-x-auto rounded-3 border border-line-1 bg-surface p-1.5"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const href = tab.segment ? `${base}/${tab.segment}` : base;
|
||||
const active = tab.segment
|
||||
? pathname.startsWith(href)
|
||||
: pathname === base;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.segment}
|
||||
href={href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cx(
|
||||
'shrink-0 rounded-full px-3.5 py-1.5 text-sm font-medium whitespace-nowrap',
|
||||
'transition-colors duration-[var(--d-1)] ease-organic',
|
||||
active
|
||||
? 'bg-surface-3 text-ink-1'
|
||||
: 'text-ink-2 hover:bg-surface-2 hover:text-ink-1',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
|
||||
import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import {
|
||||
GENDERS,
|
||||
MARITAL_STATUSES,
|
||||
genderLabel,
|
||||
maritalStatusLabel,
|
||||
} from '@/domain/hr/civil-status';
|
||||
import {
|
||||
updateProfileAction,
|
||||
updateSensitiveAction,
|
||||
type ProfileActionState,
|
||||
} from '@/server/employees/profile-actions';
|
||||
|
||||
/**
|
||||
* Dossier personnel : consultation, puis saisie.
|
||||
*
|
||||
* Un seul écran et deux modes, plutôt qu'une page de lecture et une page de
|
||||
* formulaire. Le dossier se corrige champ par champ, souvent au téléphone avec
|
||||
* la personne concernée : la faire changer de page pour changer un chiffre
|
||||
* ferait perdre le reste de vue.
|
||||
*/
|
||||
|
||||
export interface ProfileFields {
|
||||
gender: string;
|
||||
firstName: string;
|
||||
birthName: string;
|
||||
lastName: string;
|
||||
birthDate: string;
|
||||
birthPlace: string;
|
||||
birthCountry: string;
|
||||
birthDepartment: string;
|
||||
nationality: string;
|
||||
maritalStatus: string;
|
||||
dependents: string;
|
||||
personalEmail: string;
|
||||
phone: string;
|
||||
landline: string;
|
||||
smsSchedules: boolean;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
country: string;
|
||||
emergencyContactName: string;
|
||||
emergencyContactPhone: string;
|
||||
}
|
||||
|
||||
export interface SensitiveFields {
|
||||
socialSecurityNumber: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
}
|
||||
|
||||
const empty: ProfileActionState = {};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
|
||||
/**
|
||||
* Liste déroulante à valeur facultative.
|
||||
*
|
||||
* L'option vide est explicite : sans elle, ouvrir le formulaire poserait la
|
||||
* première valeur de la liste sur un champ que personne n'a renseigné.
|
||||
*/
|
||||
function Select({
|
||||
label,
|
||||
name,
|
||||
defaultValue,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
defaultValue: string;
|
||||
options: ReadonlyArray<readonly [string, string]>;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<select
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus"
|
||||
>
|
||||
<option value="">Non renseigné</option>
|
||||
{options.map(([value, text]) => (
|
||||
<option key={value} value={value}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function readableDate(value: string): string {
|
||||
if (!value) return '';
|
||||
const parsed = new Date(`${value}T00:00:00Z`);
|
||||
return Number.isNaN(parsed.getTime()) ? value : dateFormat.format(parsed);
|
||||
}
|
||||
|
||||
export function PersonalInfoPanel({
|
||||
membershipId,
|
||||
profile,
|
||||
sensitive,
|
||||
canEdit,
|
||||
}: {
|
||||
membershipId: string;
|
||||
profile: ProfileFields;
|
||||
/** Absent quand la capacité de lecture manque : la carte n'est pas rendue. */
|
||||
sensitive: SensitiveFields | null;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [state, save] = useActionState(updateProfileAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
|
||||
|
||||
// L'enregistrement referme le mode saisie, mais seulement après la réponse :
|
||||
// refermer à l'envoi ferait disparaître le message d'erreur avec le
|
||||
// formulaire, et avec lui ce que la personne venait de taper.
|
||||
//
|
||||
// La réponse est **acquittée** une fois pour toutes : sans cela, un succès
|
||||
// resté en mémoire refermerait aussitôt le formulaire à la prochaine
|
||||
// ouverture, et le dossier ne serait plus jamais modifiable.
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{editing ? (
|
||||
<form action={save} className="flex flex-col gap-5">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
<InfoGrid>
|
||||
<InfoCard title="État civil">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Select
|
||||
label="Genre"
|
||||
name="gender"
|
||||
defaultValue={profile.gender}
|
||||
options={GENDERS}
|
||||
/>
|
||||
<Field label="Prénom" name="firstName" required defaultValue={profile.firstName} />
|
||||
<Field
|
||||
label="Nom de naissance"
|
||||
name="birthName"
|
||||
defaultValue={profile.birthName}
|
||||
/>
|
||||
<Field label="Nom de famille" name="lastName" required defaultValue={profile.lastName} />
|
||||
<Field label="Nationalité" name="nationality" defaultValue={profile.nationality} />
|
||||
<Field
|
||||
label="Date de naissance"
|
||||
name="birthDate"
|
||||
type="date"
|
||||
defaultValue={profile.birthDate}
|
||||
/>
|
||||
<Field
|
||||
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}
|
||||
/>
|
||||
<Select
|
||||
label="Situation familiale"
|
||||
name="maritalStatus"
|
||||
defaultValue={profile.maritalStatus}
|
||||
options={MARITAL_STATUSES}
|
||||
/>
|
||||
<Field
|
||||
label="Nombre de personnes à charge"
|
||||
name="dependents"
|
||||
type="number"
|
||||
min="0"
|
||||
max="99"
|
||||
defaultValue={profile.dependents}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Coordonnées">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Field
|
||||
label="Adresse électronique personnelle"
|
||||
name="personalEmail"
|
||||
type="email"
|
||||
defaultValue={profile.personalEmail}
|
||||
/>
|
||||
<Field label="Téléphone mobile" name="phone" type="tel" defaultValue={profile.phone} />
|
||||
<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} />
|
||||
</div>
|
||||
<Field label="Pays" name="country" defaultValue={profile.country} />
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Contact d’urgence">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Field
|
||||
label="Nom"
|
||||
name="emergencyContactName"
|
||||
defaultValue={profile.emergencyContactName}
|
||||
/>
|
||||
<Field
|
||||
label="Téléphone"
|
||||
name="emergencyContactPhone"
|
||||
type="tel"
|
||||
defaultValue={profile.emergencyContactPhone}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
{/* Barre collante : le dossier est long, et le bouton d'enregistrement
|
||||
ne doit pas se trouver hors de l'écran au moment où l'on finit de
|
||||
corriger un champ du haut. */}
|
||||
<div className="sticky bottom-4 flex flex-wrap items-center justify-center gap-3 rounded-3 border border-line-1 bg-surface p-3 shadow-[var(--shadow-1,0_1px_2px_rgba(0,0,0,0.06))]">
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<SubmitButton>Enregistrer les modifications</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<InfoGrid>
|
||||
<InfoCard title="État civil">
|
||||
<InfoRow label="Genre" value={genderLabel(profile.gender || null)} />
|
||||
<InfoRow label="Prénom" value={profile.firstName} />
|
||||
<InfoRow label="Nom de naissance" value={profile.birthName} />
|
||||
<InfoRow label="Nom de famille" value={profile.lastName} />
|
||||
<InfoRow label="Nationalité" value={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} />
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Contact d’urgence">
|
||||
<InfoRow label="Nom" value={profile.emergencyContactName} />
|
||||
<InfoRow label="Téléphone" value={profile.emergencyContactPhone} tnum />
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
|
||||
{canEdit ? (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="primary" onClick={() => setEditing(true)}>
|
||||
Modifier les informations personnelles
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{sensitive ? (
|
||||
<SensitivePanel
|
||||
membershipId={membershipId}
|
||||
sensitive={sensitive}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NIR, IBAN et BIC.
|
||||
*
|
||||
* Séparés du reste, et pas seulement à l'écran : ils sont chiffrés au repos,
|
||||
* leur lecture demande sa propre capacité, et leur écriture passe par une autre
|
||||
* action. Les mêler au formulaire commun ferait dépendre leur sort d'un droit
|
||||
* qui ne les concerne pas.
|
||||
*/
|
||||
function SensitivePanel({
|
||||
membershipId,
|
||||
sensitive,
|
||||
canEdit,
|
||||
}: {
|
||||
membershipId: string;
|
||||
sensitive: SensitiveFields;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [state, save] = useActionState(updateSensitiveAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title="Données protégées">
|
||||
{editing ? (
|
||||
<form action={save} className="flex flex-col gap-3 pt-1">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
<Field
|
||||
label="Numéro de sécurité sociale"
|
||||
name="socialSecurityNumber"
|
||||
inputMode="numeric"
|
||||
defaultValue={sensitive.socialSecurityNumber}
|
||||
hint="Quinze chiffres. Conservé chiffré ; jamais écrit au journal."
|
||||
/>
|
||||
<Field label="IBAN" name="iban" defaultValue={sensitive.iban} />
|
||||
<Field label="BIC" name="bic" defaultValue={sensitive.bic} />
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer</SubmitButton>
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Numéro de sécurité sociale"
|
||||
value={sensitive.socialSecurityNumber}
|
||||
tnum
|
||||
/>
|
||||
<InfoRow label="IBAN" value={sensitive.iban} tnum />
|
||||
<InfoRow label="BIC" value={sensitive.bic} tnum />
|
||||
{canEdit ? (
|
||||
<div className="pt-3">
|
||||
<Button onClick={() => setEditing(true)}>Modifier</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { listMemberAbsences, statusLabel } from '@/server/absences/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
|
||||
|
||||
const STATUS_TONES: Record<string, Tone> = {
|
||||
PENDING: 'warn',
|
||||
ACCEPTED: 'ok',
|
||||
DECLINED: 'danger',
|
||||
CANCELLED: 'neutral',
|
||||
EXPIRED: 'neutral',
|
||||
};
|
||||
|
||||
const readable = (iso: string) => dateFormat.format(new Date(`${iso}T00:00:00Z`));
|
||||
|
||||
export default async function AbsencesTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const absences = await listMemberAbsences(id);
|
||||
|
||||
if (!absences) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Absences non consultables"
|
||||
description="Votre rôle ne donne pas accès aux congés des autres salariés."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader title="Compteurs" />
|
||||
{absences.counters.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun compteur"
|
||||
description="Les compteurs s’ouvrent à la première acquisition de la période."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-x-8 gap-y-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(170px,1fr))]">
|
||||
{absences.counters.map((counter) => (
|
||||
<div key={`${counter.membershipId}-${counter.counterType}`}>
|
||||
<p className="text-micro font-semibold text-ink-1">
|
||||
{counter.counterType}
|
||||
</p>
|
||||
<p className="tnum mt-1 text-lg font-semibold">
|
||||
{counter.balance}
|
||||
</p>
|
||||
<p className="tnum text-micro text-ink-3">
|
||||
{counter.accrued} acquis · {counter.taken} pris
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Congés et absences"
|
||||
badge={<Badge tone="neutral">{absences.requests.length}</Badge>}
|
||||
/>
|
||||
{absences.requests.length === 0 ? (
|
||||
<EmptyState title="Aucune absence enregistrée" />
|
||||
) : (
|
||||
<ul>
|
||||
{absences.requests.map((request) => (
|
||||
<li
|
||||
key={request.id}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<span className="font-medium">{request.typeLabel}</span>
|
||||
<span className="tnum text-xs text-ink-2">
|
||||
{readable(request.startDate)}
|
||||
{request.endDate !== request.startDate
|
||||
? ` → ${readable(request.endDate)}`
|
||||
: ''}
|
||||
</span>
|
||||
<span className="tnum text-xs text-ink-3">
|
||||
{request.days} j
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
|
||||
{statusLabel(request.status)}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
|
||||
import { InfoCard, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import {
|
||||
createAmendmentAction,
|
||||
endContractAction,
|
||||
type ActionState,
|
||||
} from '@/server/employees/actions';
|
||||
|
||||
/**
|
||||
* Vie du contrat en cours.
|
||||
*
|
||||
* Trois gestes et un seul menu : lire le détail, déclarer un changement,
|
||||
* terminer. Ils sont réunis parce qu'ils portent sur le même objet, et séparés
|
||||
* du formulaire de création parce qu'un contrat qui existe ne se recrée pas.
|
||||
*/
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
|
||||
export interface ActiveContract {
|
||||
id: string;
|
||||
label: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
trialEndDate: string | null;
|
||||
weeklyHours: string;
|
||||
forfaitJours: boolean;
|
||||
forfaitDaysPerYear: string | null;
|
||||
forfaitAgreementRef: string | null;
|
||||
isModulated: boolean;
|
||||
classification: string | null;
|
||||
coefficient: string | null;
|
||||
locationName: string | null;
|
||||
monthlySalary: string | null;
|
||||
jobTitle: string | null;
|
||||
}
|
||||
|
||||
const readable = (iso: string | null) =>
|
||||
iso ? dateFormat.format(new Date(`${iso}T00:00:00Z`)) : '';
|
||||
|
||||
type Panel = 'none' | 'detail' | 'amend' | 'end';
|
||||
|
||||
export function ContractActions({
|
||||
contract,
|
||||
canEdit,
|
||||
canSeeSalary,
|
||||
}: {
|
||||
contract: ActiveContract;
|
||||
canEdit: boolean;
|
||||
canSeeSalary: boolean;
|
||||
}) {
|
||||
const [panel, setPanel] = useState<Panel>('none');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-md font-semibold">Contrat en cours</h2>
|
||||
<span className="flex-1" />
|
||||
<Button
|
||||
onClick={() => setPanel(panel === 'detail' ? 'none' : 'detail')}
|
||||
>
|
||||
{panel === 'detail' ? 'Masquer le détail' : 'Afficher le détail'}
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setPanel(panel === 'amend' ? 'none' : 'amend')}
|
||||
>
|
||||
Déclarer un changement
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setPanel(panel === 'end' ? 'none' : 'end')}
|
||||
>
|
||||
Terminer le contrat
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<InfoCard title={contract.label}>
|
||||
<InfoRow label="Début du contrat" value={readable(contract.startDate)} tnum />
|
||||
<InfoRow label="Fin du contrat" value={readable(contract.endDate)} tnum />
|
||||
<InfoRow label="Emploi" value={contract.jobTitle} />
|
||||
<InfoRow
|
||||
label="Organisation du temps"
|
||||
value={
|
||||
contract.forfaitJours
|
||||
? `Forfait jours · ${contract.forfaitDaysPerYear ?? '—'} jours par an`
|
||||
: `${contract.weeklyHours} heures hebdomadaires`
|
||||
}
|
||||
/>
|
||||
{canSeeSalary ? (
|
||||
<InfoRow
|
||||
label="Rémunération mensuelle brute"
|
||||
value={contract.monthlySalary ? `${contract.monthlySalary} €` : ''}
|
||||
tnum
|
||||
/>
|
||||
) : null}
|
||||
<InfoRow label="Établissement" value={contract.locationName} />
|
||||
|
||||
{panel === 'detail' ? (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Fin de période d’essai"
|
||||
value={readable(contract.trialEndDate)}
|
||||
tnum
|
||||
/>
|
||||
<InfoRow label="Classification" value={contract.classification} />
|
||||
<InfoRow label="Coefficient" value={contract.coefficient} tnum />
|
||||
<InfoRow
|
||||
label="Modulation"
|
||||
value={contract.isModulated ? 'Oui' : 'Non'}
|
||||
/>
|
||||
{contract.forfaitJours ? (
|
||||
<InfoRow
|
||||
label="Convention de forfait"
|
||||
value={contract.forfaitAgreementRef}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</InfoCard>
|
||||
|
||||
{panel === 'amend' ? (
|
||||
<AmendmentForm
|
||||
contractId={contract.id}
|
||||
weeklyHours={contract.weeklyHours}
|
||||
onDone={() => setPanel('none')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{panel === 'end' ? (
|
||||
<EndForm contractId={contract.id} onDone={() => setPanel('none')} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Avenant.
|
||||
*
|
||||
* Le motif est obligatoire : un changement de durée sans raison écrite est
|
||||
* indéfendable devant un contrôle, et illisible six mois plus tard.
|
||||
*/
|
||||
function AmendmentForm({
|
||||
contractId,
|
||||
weeklyHours,
|
||||
onDone,
|
||||
}: {
|
||||
contractId: string;
|
||||
weeklyHours: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [state, submit] = useActionState(createAmendmentAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title="Déclarer un changement">
|
||||
<form action={submit} className="flex flex-col gap-3 pt-1">
|
||||
<input type="hidden" name="contractId" value={contractId} />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Date d’effet"
|
||||
name="effectiveDate"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label="Durée hebdomadaire"
|
||||
name="weeklyHours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="60"
|
||||
defaultValue={weeklyHours}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="Motif"
|
||||
name="reason"
|
||||
required
|
||||
hint="Ce que l’avenant change, et pourquoi. Conservé au journal."
|
||||
/>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer l’avenant</SubmitButton>
|
||||
<Button type="button" onClick={onDone}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
function EndForm({
|
||||
contractId,
|
||||
onDone,
|
||||
}: {
|
||||
contractId: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [state, submit] = useActionState(endContractAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title="Terminer le contrat">
|
||||
<form action={submit} className="flex flex-col gap-3 pt-1">
|
||||
<input type="hidden" name="contractId" value={contractId} />
|
||||
<p className="text-sm text-ink-2">
|
||||
Le contrat est daté et clos, non supprimé : la période travaillée
|
||||
reste opposable, et le solde de tout compte s’appuie dessus.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Date de fin" name="endDate" type="date" required />
|
||||
<Field
|
||||
label="Motif de fin"
|
||||
name="endReason"
|
||||
required
|
||||
hint="Démission, fin de CDD, rupture conventionnelle…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Terminer le contrat</SubmitButton>
|
||||
<Button type="button" onClick={onDone}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
|
||||
import { InfoCard } from '@/app/(app)/equipe/[id]/InfoCard';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import {
|
||||
createContractAction,
|
||||
type ActionState,
|
||||
} from '@/server/employees/actions';
|
||||
|
||||
/**
|
||||
* Ouverture d'un contrat.
|
||||
*
|
||||
* Le forfait jours découvre ses propres champs : la convention individuelle
|
||||
* écrite et sa date d'accord sont exigées par la règle métier, et les demander
|
||||
* en permanence ferait porter à tous les contrats horaires deux champs qui ne
|
||||
* les concernent pas.
|
||||
*/
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
const CONTRACT_TYPES = [
|
||||
['CDI', 'CDI'],
|
||||
['CDD', 'CDD'],
|
||||
['APPRENTISSAGE', 'Apprentissage'],
|
||||
['STAGIAIRE', 'Stagiaire'],
|
||||
['SAISONNIER', 'Saisonnier'],
|
||||
['EXTRA', 'Extra'],
|
||||
['INTERIM', 'Intérim'],
|
||||
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
|
||||
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
|
||||
] as const;
|
||||
|
||||
const selectClass =
|
||||
'h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
|
||||
|
||||
export function NewContractForm({
|
||||
membershipId,
|
||||
locations,
|
||||
}: {
|
||||
membershipId: string;
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
const [state, submit] = useActionState(createContractAction, empty);
|
||||
const [forfait, setForfait] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="primary" onClick={() => setOpen(true)}>
|
||||
Ouvrir un contrat
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title="Nouveau contrat">
|
||||
<form action={submit} className="flex flex-col gap-3 pt-1">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Établissement</span>
|
||||
<select name="locationId" required className={selectClass}>
|
||||
{locations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Type de contrat</span>
|
||||
<select name="contractType" required className={selectClass}>
|
||||
{CONTRACT_TYPES.map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Début du contrat" name="startDate" type="date" required />
|
||||
<Field
|
||||
label="Fin du contrat"
|
||||
name="endDate"
|
||||
type="date"
|
||||
hint="Laissez vide pour un contrat sans terme."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Organisation du temps</span>
|
||||
<select
|
||||
name="workTimeArrangement"
|
||||
className={selectClass}
|
||||
onChange={(event) =>
|
||||
setForfait(event.target.value === 'FORFAIT_JOURS')
|
||||
}
|
||||
>
|
||||
<option value="HOURLY">Décompte horaire</option>
|
||||
<option value="FORFAIT_JOURS">Forfait jours</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{forfait ? (
|
||||
<Field
|
||||
label="Jours par an"
|
||||
name="forfaitDaysPerYear"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="218"
|
||||
defaultValue="218"
|
||||
hint="Plafond conventionnel : 218 jours, journée de solidarité incluse."
|
||||
/>
|
||||
) : (
|
||||
<Field
|
||||
label="Durée hebdomadaire"
|
||||
name="weeklyHours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="60"
|
||||
defaultValue="35"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{forfait ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Référence de la convention de forfait"
|
||||
name="forfaitAgreementRef"
|
||||
required
|
||||
hint="Sans convention individuelle écrite, le forfait est inopposable."
|
||||
/>
|
||||
<Field
|
||||
label="Date d’accord du salarié"
|
||||
name="forfaitAgreedAt"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Ouvrir le contrat</SubmitButton>
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import {
|
||||
ContractActions,
|
||||
type ActiveContract,
|
||||
} from '@/app/(app)/equipe/[id]/contrats/ContractActions';
|
||||
import { NewContractForm } from '@/app/(app)/equipe/[id]/contrats/NewContractForm';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
getEmployee,
|
||||
listContractLocations,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
|
||||
/** Les dates traversent la frontière serveur/client en ISO court : un `Date`
|
||||
* y perd son fuseau, et « 1er novembre » deviendrait « 31 octobre » à l'ouest. */
|
||||
const iso = (value: Date | null) => value?.toISOString().slice(0, 10) ?? null;
|
||||
|
||||
export default async function ContractsTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
const active = employee.contracts.find(
|
||||
(contract) => contract.status === 'ACTIVE',
|
||||
);
|
||||
|
||||
// La liste n'est chargée que si elle sert : sans contrat en cours, l'écran
|
||||
// propose d'en ouvrir un, et la capacité peut manquer.
|
||||
const locations = active ? [] : await listContractLocations().catch(() => []);
|
||||
|
||||
const activeContract: ActiveContract | null = active
|
||||
? {
|
||||
id: active.id,
|
||||
label: active.label,
|
||||
startDate: iso(active.startDate) as string,
|
||||
endDate: iso(active.endDate),
|
||||
trialEndDate: iso(active.trialEndDate),
|
||||
weeklyHours: active.weeklyHours,
|
||||
forfaitJours: active.forfaitJours,
|
||||
forfaitDaysPerYear: active.forfaitDaysPerYear,
|
||||
forfaitAgreementRef: active.forfaitAgreementRef,
|
||||
isModulated: active.isModulated,
|
||||
classification: active.classification,
|
||||
coefficient: active.coefficient,
|
||||
locationName: active.locationName,
|
||||
monthlySalary: active.monthlySalary,
|
||||
jobTitle: employee.headline.jobTitle,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{activeContract ? (
|
||||
<ContractActions
|
||||
contract={activeContract}
|
||||
canEdit={employee.canEdit}
|
||||
canSeeSalary={employee.canSeeSalary}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<EmptyState
|
||||
title="Aucun contrat en cours"
|
||||
description="Ce salarié ne peut être ni planifié ni déclaré tant qu’aucune période n’est ouverte."
|
||||
/>
|
||||
{employee.canEdit && locations.length > 0 ? (
|
||||
<NewContractForm membershipId={employee.id} locations={locations} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Tous les contrats et avenants"
|
||||
badge={<Badge tone="neutral">{employee.contracts.length}</Badge>}
|
||||
/>
|
||||
{employee.contracts.length === 0 ? (
|
||||
<EmptyState title="Aucun contrat enregistré" />
|
||||
) : (
|
||||
<ul>
|
||||
{employee.contracts.map((contract) => (
|
||||
<li
|
||||
key={contract.id}
|
||||
className="border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{contract.label}</span>
|
||||
{contract.forfaitJours ? (
|
||||
<Badge tone="accent">Forfait jours</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">{contract.weeklyHours} h</Badge>
|
||||
)}
|
||||
<span className="tnum text-xs text-ink-2">
|
||||
{dateFormat.format(contract.startDate)}
|
||||
{contract.endDate
|
||||
? ` → ${dateFormat.format(contract.endDate)}`
|
||||
: ' → en cours'}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{employee.canSeeSalary && contract.monthlySalary ? (
|
||||
<span className="tnum text-sm">
|
||||
{contract.monthlySalary} € brut
|
||||
</span>
|
||||
) : null}
|
||||
<Badge tone={contract.status === 'ACTIVE' ? 'ok' : 'neutral'}>
|
||||
{contract.status === 'ACTIVE' ? 'En cours' : 'Terminé'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{contract.endReason ? (
|
||||
<p className="mt-1 text-xs text-ink-3">
|
||||
Fin · {contract.endReason}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{contract.amendments.length > 0 ? (
|
||||
<ul className="mt-2 border-l-2 border-line-2 pl-3">
|
||||
{contract.amendments.map((amendment) => (
|
||||
<li key={amendment.id} className="py-1 text-xs text-ink-2">
|
||||
<span className="tnum">
|
||||
{dateFormat.format(amendment.effectiveDate)}
|
||||
</span>{' '}
|
||||
— avenant
|
||||
{amendment.reason ? ` · ${amendment.reason}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel';
|
||||
import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { listDocuments } from '@/server/documents/queries';
|
||||
import { getEmployee } from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function DocumentsTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
// `members.documents.view` peut manquer là où `members.view` est accordée :
|
||||
// la section disparaît alors, plutôt que d'échouer sur toute la page.
|
||||
const documents = await listDocuments(id).catch(() => null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{documents ? (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Pièces du dossier"
|
||||
badge={<Badge tone="neutral">{documents.documents.length}</Badge>}
|
||||
/>
|
||||
<DocumentsPanel
|
||||
membershipId={employee.id}
|
||||
documents={documents.documents}
|
||||
canManage={documents.canManage}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Documents non consultables"
|
||||
description="Votre rôle ne donne pas accès aux pièces du dossier."
|
||||
/>
|
||||
)}
|
||||
|
||||
{employee.canInvite ? (
|
||||
<Card>
|
||||
<CardHeader title="Accès à l’application" />
|
||||
<InvitationPanel
|
||||
membershipId={employee.id}
|
||||
defaultEmail={employee.email ?? employee.profile?.personalEmail ?? ''}
|
||||
hasAccount={employee.hasAccount}
|
||||
invitation={employee.invitation}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { MemberTabs } from '@/app/(app)/equipe/[id]/MemberTabs';
|
||||
import { PageBody } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { getEmployee, getMemberPlacement } from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'full' });
|
||||
|
||||
/**
|
||||
* Gabarit de la fiche salarié.
|
||||
*
|
||||
* Le bandeau et les onglets sont tenus ici, et non répétés dans chaque onglet :
|
||||
* ils ne changent pas d'un onglet à l'autre, et les rejouer ferait clignoter
|
||||
* l'identité de la personne consultée à chaque navigation.
|
||||
*/
|
||||
export default async function MemberLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
const placement = await getMemberPlacement(id);
|
||||
const primaryTeam =
|
||||
placement?.teams.find((team) => team.isPrimary) ?? placement?.teams[0];
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<header className="rounded-3 bg-surface-2 p-6">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-14 flex-none items-center justify-center rounded-full bg-accent-soft text-md font-semibold text-accent-soft-ink"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.018em]">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-ink-2">
|
||||
{employee.headline.jobTitle ??
|
||||
`Matricule ${employee.employeeNumber}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!employee.hasAccount ? (
|
||||
<Badge tone="info">Sans accès applicatif</Badge>
|
||||
) : null}
|
||||
<Badge tone="accent">{employee.roleName}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-6 grid gap-x-6 gap-y-4 [grid-template-columns:repeat(auto-fit,minmax(150px,1fr))]">
|
||||
<HeadlineItem
|
||||
label="Début du contrat"
|
||||
value={
|
||||
employee.contract
|
||||
? dateFormat.format(employee.contract.since)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Fin du contrat"
|
||||
value={
|
||||
employee.headline.contractEnd
|
||||
? dateFormat.format(employee.headline.contractEnd)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Type de contrat"
|
||||
value={employee.contract?.label ?? null}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Établissement"
|
||||
value={employee.locationName}
|
||||
/>
|
||||
<HeadlineItem label="Équipe" value={primaryTeam?.name ?? null} />
|
||||
<HeadlineItem
|
||||
label="Responsable hiérarchique"
|
||||
value={employee.headline.lineManagerName}
|
||||
/>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<MemberTabs id={id} />
|
||||
|
||||
{children}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
/** Un tiret, et non « non renseigné » : le bandeau se lit d'un balayage. */
|
||||
function HeadlineItem({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-micro font-semibold text-ink-1">{label}</dt>
|
||||
<dd className="mt-1 truncate text-sm text-ink-2">{value ?? '—'}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel';
|
||||
import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { listDocuments } from '@/server/documents/queries';
|
||||
import {
|
||||
PersonalInfoPanel,
|
||||
type ProfileFields,
|
||||
type SensitiveFields,
|
||||
} from '@/app/(app)/equipe/[id]/PersonalInfoPanel';
|
||||
import { EmptyState } from '@/components/ui/Card';
|
||||
import { getEmployee } from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
/** `null` et `undefined` deviennent la chaîne vide : un `<input>` non contrôlé
|
||||
* affiche « null » sinon. */
|
||||
const text = (value: string | null | undefined): string => value ?? '';
|
||||
|
||||
export default async function FichePage({
|
||||
export default async function PersonalTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -21,181 +23,61 @@ export default async function FichePage({
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
// `members.documents.view` peut manquer là où `members.view` est accordée :
|
||||
// la section disparaît alors, plutôt que d'échouer sur toute la page.
|
||||
const documents = await listDocuments(id).catch(() => null);
|
||||
if (!employee.profile) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Aucun dossier personnel"
|
||||
description="Ce salarié n’a pas de dossier. Il a probablement été créé hors de l’application ; reprenez sa création depuis la liste de l’équipe."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const active = employee.contracts.find(
|
||||
(contract) => contract.status === 'ACTIVE',
|
||||
);
|
||||
const profile: ProfileFields = {
|
||||
gender: text(employee.profile.gender),
|
||||
firstName: text(employee.profile.firstName),
|
||||
birthName: text(employee.profile.birthName),
|
||||
lastName: text(employee.profile.lastName),
|
||||
birthDate: employee.profile.birthDate
|
||||
? employee.profile.birthDate.toISOString().slice(0, 10)
|
||||
: '',
|
||||
birthPlace: text(employee.profile.birthPlace),
|
||||
birthCountry: text(employee.profile.birthCountry),
|
||||
birthDepartment: text(employee.profile.birthDepartment),
|
||||
nationality: text(employee.profile.nationality),
|
||||
maritalStatus: text(employee.profile.maritalStatus),
|
||||
dependents:
|
||||
employee.profile.dependents === null
|
||||
? ''
|
||||
: String(employee.profile.dependents),
|
||||
personalEmail: text(employee.profile.personalEmail),
|
||||
phone: text(employee.profile.phone),
|
||||
landline: text(employee.profile.landline),
|
||||
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),
|
||||
emergencyContactName: text(employee.profile.emergencyContactName),
|
||||
emergencyContactPhone: text(employee.profile.emergencyContactPhone),
|
||||
};
|
||||
|
||||
const sensitive: SensitiveFields | null = employee.profile.sensitive
|
||||
? {
|
||||
socialSecurityNumber: text(
|
||||
employee.profile.sensitive.socialSecurityNumber,
|
||||
),
|
||||
iban: text(employee.profile.sensitive.iban),
|
||||
bic: text(employee.profile.sensitive.bic),
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title={`${employee.firstName} ${employee.lastName}`}
|
||||
subtitle={[
|
||||
employee.contract?.label,
|
||||
employee.locationName,
|
||||
`matricule ${employee.employeeNumber}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-3 border border-line-1 bg-surface p-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-11 flex-none items-center justify-center rounded-full bg-surface-3 text-sm font-semibold text-ink-2"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{employee.email ?? 'Aucun compte applicatif'}
|
||||
</p>
|
||||
<p className="text-micro text-ink-3">Rôle · {employee.roleName}</p>
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
{active?.forfaitJours ? (
|
||||
<Badge tone="accent">
|
||||
Forfait jours · {active.forfaitDaysPerYear ?? '—'} j
|
||||
</Badge>
|
||||
) : active ? (
|
||||
<Badge tone="neutral">{active.weeklyHours} h hebdomadaires</Badge>
|
||||
) : null}
|
||||
{!employee.hasAccount ? (
|
||||
<Badge tone="info">Sans accès applicatif</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{documents ? (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Pièces du dossier"
|
||||
badge={<Badge tone="neutral">{documents.documents.length}</Badge>}
|
||||
/>
|
||||
<DocumentsPanel
|
||||
membershipId={employee.id}
|
||||
documents={documents.documents}
|
||||
canManage={documents.canManage}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{employee.canInvite ? (
|
||||
<Card>
|
||||
<CardHeader title="Accès à l’application" />
|
||||
<InvitationPanel
|
||||
membershipId={employee.id}
|
||||
defaultEmail={
|
||||
employee.email ?? employee.profile?.personalEmail ?? ''
|
||||
}
|
||||
hasAccount={employee.hasAccount}
|
||||
invitation={employee.invitation}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Contrats et avenants"
|
||||
badge={<Badge tone="neutral">{employee.contracts.length}</Badge>}
|
||||
/>
|
||||
{employee.contracts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun contrat"
|
||||
description="Ce salarié n’a pas encore de contrat. Il ne peut pas être planifié tant qu’aucune période n’est ouverte."
|
||||
/>
|
||||
) : (
|
||||
<ul>
|
||||
{employee.contracts.map((contract) => (
|
||||
<li
|
||||
key={contract.id}
|
||||
className="border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{contract.label}</span>
|
||||
{contract.forfaitJours ? (
|
||||
<Badge tone="accent">Forfait jours</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">{contract.weeklyHours} h</Badge>
|
||||
)}
|
||||
<span className="tnum text-xs text-ink-2">
|
||||
{dateFormat.format(contract.startDate)}
|
||||
{contract.endDate
|
||||
? ` → ${dateFormat.format(contract.endDate)}`
|
||||
: ' → en cours'}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{employee.canSeeSalary && contract.monthlySalary ? (
|
||||
<span className="tnum text-sm">
|
||||
{contract.monthlySalary} € brut
|
||||
</span>
|
||||
) : null}
|
||||
<Badge
|
||||
tone={contract.status === 'ACTIVE' ? 'ok' : 'neutral'}
|
||||
>
|
||||
{contract.status === 'ACTIVE' ? 'En cours' : 'Terminé'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{contract.amendments.length > 0 ? (
|
||||
<ul className="mt-2 border-l-2 border-line-2 pl-3">
|
||||
{contract.amendments.map((amendment) => (
|
||||
<li
|
||||
key={amendment.id}
|
||||
className="py-1 text-xs text-ink-2"
|
||||
>
|
||||
<span className="tnum">
|
||||
{dateFormat.format(amendment.effectiveDate)}
|
||||
</span>{' '}
|
||||
— avenant{amendment.reason ? ` · ${amendment.reason}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{employee.profile ? (
|
||||
<Card>
|
||||
<CardHeader title="Dossier personnel" />
|
||||
<dl className="grid gap-x-8 gap-y-3 p-4 text-sm [grid-template-columns:repeat(auto-fit,minmax(200px,1fr))]">
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">Téléphone</dt>
|
||||
<dd className="tnum">{employee.profile.phone ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">Ville</dt>
|
||||
<dd>{employee.profile.city ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">
|
||||
Numéro de sécurité sociale
|
||||
</dt>
|
||||
<dd className="tnum">
|
||||
{/* Non chargé quand la capacité manque : un champ absent de la
|
||||
réponse ne peut fuiter ni par le HTML ni par un journal. */}
|
||||
{employee.profile.socialSecurityNumber ?? (
|
||||
<span className="text-ink-3">Accès non autorisé</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">IBAN</dt>
|
||||
<dd className="tnum">
|
||||
{employee.profile.iban ?? (
|
||||
<span className="text-ink-3">Accès non autorisé</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
) : null}
|
||||
</PageBody>
|
||||
<PersonalInfoPanel
|
||||
membershipId={employee.id}
|
||||
profile={profile}
|
||||
sensitive={sensitive}
|
||||
canEdit={employee.canEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
|
||||
import { InfoCard } from '@/app/(app)/equipe/[id]/InfoCard';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import {
|
||||
updateScopeAction,
|
||||
updateTeamsAction,
|
||||
type PlacementActionState,
|
||||
} from '@/server/employees/placement-actions';
|
||||
import type { ContractLocation } from '@/server/employees/queries';
|
||||
|
||||
/**
|
||||
* Périmètre et rattachement, en saisie.
|
||||
*
|
||||
* Deux formulaires plutôt qu'un : ils ne portent ni la même décision ni la
|
||||
* même capacité, et les réunir donnerait à l'un le pouvoir de l'autre.
|
||||
*/
|
||||
|
||||
const empty: PlacementActionState = {};
|
||||
|
||||
export function ScopeForm({
|
||||
membershipId,
|
||||
locations,
|
||||
allLocations,
|
||||
scopedLocationIds,
|
||||
}: {
|
||||
membershipId: string;
|
||||
locations: ContractLocation[];
|
||||
allLocations: boolean;
|
||||
scopedLocationIds: string[];
|
||||
}) {
|
||||
const [state, save] = useActionState(updateScopeAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<PlacementActionState>(empty);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [all, setAll] = useState(allLocations);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Button onClick={() => setEditing(true)}>Modifier le périmètre</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={save} className="flex flex-col gap-3 pt-3">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allLocations"
|
||||
checked={all}
|
||||
onChange={(event) => setAll(event.target.checked)}
|
||||
className="mt-0.5 size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Accès généralisé</span>
|
||||
<span className="block text-micro text-ink-3">
|
||||
Tous les établissements, y compris ceux qui ouvriront plus tard.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{all ? null : (
|
||||
<fieldset className="flex flex-col gap-2 rounded-3 border border-line-1 p-3">
|
||||
<legend className="px-1 text-micro font-semibold text-ink-1">
|
||||
Établissements
|
||||
</legend>
|
||||
{locations.map((location) => (
|
||||
<label key={location.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="locationIds"
|
||||
value={location.id}
|
||||
defaultChecked={scopedLocationIds.includes(location.id)}
|
||||
className="size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
{location.name}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer le périmètre</SubmitButton>
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamsForm({
|
||||
membershipId,
|
||||
locations,
|
||||
teamIds,
|
||||
primaryTeamId,
|
||||
}: {
|
||||
membershipId: string;
|
||||
locations: ContractLocation[];
|
||||
teamIds: string[];
|
||||
primaryTeamId: string | null;
|
||||
}) {
|
||||
const [state, save] = useActionState(updateTeamsAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<PlacementActionState>(empty);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
const withTeams = locations.filter((location) => location.teams.length > 0);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Button onClick={() => setEditing(true)}>Modifier les équipes</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={save} className="flex flex-col gap-3 pt-3">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
{withTeams.length === 0 ? (
|
||||
<p className="text-sm text-ink-2">
|
||||
Aucune équipe déclarée. Elles se créent dans les réglages des
|
||||
établissements.
|
||||
</p>
|
||||
) : (
|
||||
<InfoCard title="Équipes">
|
||||
{withTeams.map((location) => (
|
||||
<fieldset key={location.id} className="flex flex-col gap-2 py-2">
|
||||
<legend className="text-micro font-semibold text-ink-1">
|
||||
{location.name}
|
||||
</legend>
|
||||
{location.teams.map((team) => (
|
||||
<div key={team.id} className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="teamIds"
|
||||
value={team.id}
|
||||
defaultChecked={teamIds.includes(team.id)}
|
||||
className="size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
{team.name}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-micro text-ink-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="primaryTeamId"
|
||||
value={team.id}
|
||||
defaultChecked={primaryTeamId === team.id}
|
||||
className="size-3.5 accent-[var(--accent)]"
|
||||
/>
|
||||
Principale
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
))}
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer les équipes</SubmitButton>
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
|
||||
import {
|
||||
ScopeForm,
|
||||
TeamsForm,
|
||||
} from '@/app/(app)/equipe/[id]/planification/PlacementPanel';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import { requireSession } from '@/server/context';
|
||||
import {
|
||||
getEmployee,
|
||||
getMemberPlacement,
|
||||
listPlacementOptions,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Rattachement et périmètre.
|
||||
*
|
||||
* L'établissement de rattachement reste en lecture seule : il est porté par le
|
||||
* contrat, et le changer sans avenant ferait diverger le planning du document
|
||||
* opposable.
|
||||
*/
|
||||
export default async function PlacementTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
const [placement, locations, session] = await Promise.all([
|
||||
getMemberPlacement(id),
|
||||
listPlacementOptions(),
|
||||
requireSession(),
|
||||
]);
|
||||
|
||||
const canScope = can(session.actor, 'settings.roles.manage');
|
||||
const canTeams = can(session.actor, 'settings.teams.manage');
|
||||
|
||||
return (
|
||||
<InfoGrid>
|
||||
<InfoCard title="Rattachement">
|
||||
<InfoRow
|
||||
label="Établissement"
|
||||
value={placement?.contractLocationName ?? employee.locationName}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Équipes"
|
||||
value={
|
||||
placement && placement.teams.length > 0
|
||||
? placement.teams
|
||||
.map((team) =>
|
||||
team.isPrimary ? `${team.name} (principale)` : team.name,
|
||||
)
|
||||
.join(', ')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<InfoRow label="Matricule" value={employee.employeeNumber} tnum />
|
||||
<InfoRow
|
||||
label="Responsable hiérarchique"
|
||||
value={employee.headline.lineManagerName}
|
||||
/>
|
||||
|
||||
<p className="pt-3 text-micro text-ink-3">
|
||||
L’établissement vient du contrat : il se change par avenant, pas ici.
|
||||
</p>
|
||||
|
||||
{canTeams ? (
|
||||
<div className="pt-3">
|
||||
<TeamsForm
|
||||
membershipId={employee.id}
|
||||
locations={locations}
|
||||
teamIds={placement?.teamIds ?? []}
|
||||
primaryTeamId={placement?.primaryTeamId ?? null}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Périmètre d’accès">
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line-1 py-3">
|
||||
<dt className="min-w-[11rem] flex-none text-sm font-medium text-ink-1">
|
||||
Accès généralisé
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
{placement?.allLocations ? (
|
||||
<Badge tone="ok">Tous les établissements</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">Limité</Badge>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<InfoRow
|
||||
label="Établissements"
|
||||
value={
|
||||
placement?.allLocations
|
||||
? 'Tous, ouvertures futures comprises'
|
||||
: (placement?.scopedLocations.join(', ') ?? '')
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Équipes"
|
||||
value={placement?.scopedTeams.join(', ') ?? ''}
|
||||
/>
|
||||
<InfoRow label="Rôle" value={employee.roleName} />
|
||||
|
||||
{canScope ? (
|
||||
<div className="pt-3">
|
||||
<ScopeForm
|
||||
membershipId={employee.id}
|
||||
locations={locations}
|
||||
allLocations={placement?.allLocations ?? false}
|
||||
scopedLocationIds={placement?.scopedLocationIds ?? []}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="pt-3 text-micro text-ink-3">
|
||||
Élargir un périmètre revient à distribuer des droits : la capacité
|
||||
« Gérer les rôles » est requise.
|
||||
</p>
|
||||
)}
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
);
|
||||
}
|
||||
+178
-106
@@ -1,129 +1,201 @@
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { AddEmployeeDialog } from '@/app/(app)/equipe/AddEmployeeDialog';
|
||||
import { DirectoryFilters } from '@/app/(app)/equipe/DirectoryFilters';
|
||||
import { RegisterDialog } from '@/app/(app)/equipe/RegisterDialog';
|
||||
import { listRegisterLocations } from '@/server/employees/rup';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm';
|
||||
import { listEmployees } from '@/server/employees/queries';
|
||||
import { EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
listContractLocations,
|
||||
listEmployees,
|
||||
listHiringOptions,
|
||||
} from '@/server/employees/queries';
|
||||
|
||||
export const metadata = { title: 'Équipe · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const STATUS_TONES: Record<string, { label: string; tone: Tone }> = {
|
||||
ACTIVE: { label: 'Actif', tone: 'ok' },
|
||||
INVITED: { label: 'Invitation en attente', tone: 'info' },
|
||||
ARCHIVED: { label: 'Archivé', tone: 'neutral' },
|
||||
const INVITATION_TONES: Record<string, { label: string; tone: Tone }> = {
|
||||
ACCEPTED: { label: 'Acceptée', tone: 'ok' },
|
||||
PENDING: { label: 'En attente', tone: 'info' },
|
||||
EXPIRED: { label: 'Expirée', tone: 'warn' },
|
||||
REVOKED: { label: 'Révoquée', tone: 'neutral' },
|
||||
};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
|
||||
const one = (value: string | string[] | undefined): string | undefined =>
|
||||
Array.isArray(value) ? value[0] : value;
|
||||
|
||||
export default async function EquipePage() {
|
||||
const employees = await listEmployees();
|
||||
const withoutAccount = employees.filter((e) => !e.hasAccount).length;
|
||||
export default async function EquipePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const presence = one(params.presence);
|
||||
|
||||
const directory = await listEmployees({
|
||||
...(one(params.q) ? { q: one(params.q) as string } : {}),
|
||||
...(one(params.etablissement)
|
||||
? { locationId: one(params.etablissement) as string }
|
||||
: {}),
|
||||
...(one(params.role) ? { roleId: one(params.role) as string } : {}),
|
||||
...(one(params.contrat)
|
||||
? { contractType: one(params.contrat) as string }
|
||||
: {}),
|
||||
...(presence === 'archived' || presence === 'all'
|
||||
? { presence }
|
||||
: { presence: 'active' as const }),
|
||||
...(one(params.tri) === 'number' ? { sort: 'number' as const } : {}),
|
||||
});
|
||||
|
||||
// Sans la capacité d'ouvrir un contrat, la liste revient vide et le
|
||||
// formulaire s'en tient à l'identité.
|
||||
const hiringLocations = await listContractLocations().catch(() => []);
|
||||
|
||||
// Sans `members.register.export`, le bouton du registre ne paraît pas.
|
||||
const registerLocations = await listRegisterLocations().catch(() => []);
|
||||
|
||||
const hiringOptions = await listHiringOptions().catch(() => ({
|
||||
managers: [],
|
||||
rttPolicies: [],
|
||||
}));
|
||||
|
||||
const filtered = directory.rows.length !== directory.total;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Équipe"
|
||||
subtitle={`${employees.length} salarié${employees.length > 1 ? 's' : ''}${
|
||||
withoutAccount > 0 ? ` · ${withoutAccount} sans compte applicatif` : ''
|
||||
}`}
|
||||
// Le décompte n'apparaît que s'il apprend quelque chose : « 87 salariés »
|
||||
// au-dessus d'une liste de 87 lignes ne dit rien que la liste ne dise.
|
||||
{...(filtered
|
||||
? { subtitle: `${directory.rows.length} sur ${directory.total} salariés` }
|
||||
: {})}
|
||||
actions={
|
||||
<>
|
||||
{registerLocations.length > 0 ? (
|
||||
<RegisterDialog locations={registerLocations} />
|
||||
) : null}
|
||||
<AddEmployeeDialog
|
||||
locations={hiringLocations}
|
||||
options={hiringOptions}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Effectif" />
|
||||
{employees.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun salarié"
|
||||
description="Ajoutez un premier salarié pour commencer à planifier."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[880px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 bg-surface-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
<th className="px-4 py-2.5">Collaborateur</th>
|
||||
<th className="px-4 py-2.5">Matricule</th>
|
||||
<th className="px-4 py-2.5">Contrat</th>
|
||||
<th className="px-4 py-2.5">Établissement</th>
|
||||
<th className="px-4 py-2.5">Rôle</th>
|
||||
<th className="px-4 py-2.5">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.map((employee) => {
|
||||
const status =
|
||||
STATUS_TONES[employee.status] ?? STATUS_TONES.ACTIVE!;
|
||||
return (
|
||||
<tr
|
||||
key={employee.id}
|
||||
className="border-b border-line-1 last:border-b-0 hover:bg-surface-2"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<Link
|
||||
href={`/equipe/${employee.id}`}
|
||||
className="flex items-center gap-2.5 rounded-2"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-7 flex-none items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</span>
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
{employee.email ?? 'Sans compte applicatif'}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="tnum px-4 py-2.5 text-ink-2">
|
||||
{employee.employeeNumber}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{employee.contract ? (
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span>{employee.contract.label}</span>
|
||||
{employee.contract.forfaitJours ? (
|
||||
<Badge tone="accent">Forfait jours</Badge>
|
||||
) : null}
|
||||
<span className="tnum text-micro text-ink-3">
|
||||
depuis le{' '}
|
||||
{dateFormat.format(employee.contract.since)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<Badge tone="warn">Sans contrat</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">
|
||||
{employee.locationName ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">
|
||||
{employee.roleName}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge tone={status.tone}>{status.label}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{/* `useSearchParams` impose une frontière de suspense : sans elle, la
|
||||
page entière serait rendue à la demande au lieu d'être servie puis
|
||||
hydratée. */}
|
||||
<Suspense fallback={<div className="h-[6rem]" />}>
|
||||
<DirectoryFilters
|
||||
options={{
|
||||
locations: directory.locations,
|
||||
roles: directory.roles,
|
||||
contractTypes: directory.contractTypes,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Ajouter un salarié" />
|
||||
<div className="p-4">
|
||||
<AddEmployeeForm />
|
||||
{directory.rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title={filtered ? 'Aucun résultat' : 'Aucun salarié'}
|
||||
description={
|
||||
filtered
|
||||
? 'Aucun salarié ne répond à ces critères. Élargissez la recherche.'
|
||||
: 'Ajoutez un premier salarié pour commencer à planifier.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[940px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 text-left text-micro font-semibold tracking-[0.04em] text-ink-3">
|
||||
<th className="px-3 py-3 font-semibold">Collaborateur</th>
|
||||
<th className="px-3 py-3 font-semibold">Rôle</th>
|
||||
<th className="px-3 py-3 font-semibold">Email</th>
|
||||
<th className="px-3 py-3 font-semibold">Mobile</th>
|
||||
<th className="px-3 py-3 font-semibold">Rattachement</th>
|
||||
<th className="px-3 py-3 font-semibold">Invitation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{directory.rows.map((employee) => {
|
||||
const invitation = employee.invitationState
|
||||
? INVITATION_TONES[employee.invitationState]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={employee.id}
|
||||
className="border-b border-line-1 last:border-b-0 hover:bg-surface-2"
|
||||
>
|
||||
<td className="px-3 py-3">
|
||||
<Link
|
||||
href={`/equipe/${employee.id}`}
|
||||
className="flex items-center gap-2.5 rounded-2"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-8 flex-none items-center justify-center rounded-full bg-accent-soft text-micro font-semibold text-accent-soft-ink"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium underline decoration-line-3 underline-offset-2">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</span>
|
||||
{/* Le matricule ne double la colonne « Email » que
|
||||
lorsqu'il n'y en a pas : c'est alors la seule
|
||||
adresse à laquelle on désigne ce salarié. */}
|
||||
{employee.email ? null : (
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
Matricule {employee.employeeNumber}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<Badge tone="accent">{employee.roleName}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-ink-2">
|
||||
{employee.email ?? (
|
||||
<span className="text-ink-3">Sans compte</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="tnum px-3 py-3 text-ink-2">
|
||||
{employee.phone ?? (
|
||||
<span className="text-ink-3">Non renseigné</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-ink-2">
|
||||
{employee.locationName ? (
|
||||
<>
|
||||
{employee.locationName}
|
||||
{employee.teamName ? ` / ${employee.teamName}` : ''}
|
||||
</>
|
||||
) : (
|
||||
<Badge tone="warn">Sans contrat</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
{invitation ? (
|
||||
<Badge tone={invitation.tone}>{invitation.label}</Badge>
|
||||
) : (
|
||||
<span className="text-micro text-ink-3">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -276,7 +276,7 @@ function EmployeeHome() {
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/conges"
|
||||
href="/absences/calendrier"
|
||||
className="flex h-full flex-col gap-1 rounded-3 border border-line-1 bg-surface p-4 hover:bg-surface-2"
|
||||
>
|
||||
<span className="text-sm font-medium text-ink-1">
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PrintButton } from '@/components/planning/PrintButton';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { displayName, type BoardRow } from '@/domain/planning/board';
|
||||
import { absenceOnDay } from '@/domain/planning/presence';
|
||||
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
|
||||
import { isPosteCode, posteTokens } from '@/lib/design/postes';
|
||||
import { requireSession } from '@/server/context';
|
||||
import { getWeekBoard } from '@/server/planning/queries';
|
||||
|
||||
export const metadata = { title: 'Planning · impression · PlanFlow' };
|
||||
|
||||
/**
|
||||
* Planning à afficher — PLAN.md §9, `/planning/impression`.
|
||||
*
|
||||
* Ce n'est pas la grille d'édition avec les boutons cachés. C'est une mise en
|
||||
* page faite pour la feuille punaisée en salle de pause : toutes les équipes à
|
||||
* la suite, pas de panneau d'édition, pas de compteurs — et un seul aplat par
|
||||
* créneau, avec l'horaire et le code du poste écrits en toutes lettres.
|
||||
*
|
||||
* Rien n'est recalculé : le même `getWeekBoard` que les cinq vues. Une feuille
|
||||
* imprimée qui contredit l'écran est pire qu'une absence de feuille, parce
|
||||
* qu'elle survit à la correction.
|
||||
*
|
||||
* Le rendu PDF passe par la boîte d'impression du navigateur (voir
|
||||
* `PrintButton`) : un second moteur de rendu serait un second endroit où la
|
||||
* grille peut diverger.
|
||||
*/
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
|
||||
}
|
||||
|
||||
function ShiftLine({ shift }: { shift: BoardRow['days'][number][number] }) {
|
||||
const tokens = isPosteCode(shift.poste) ? posteTokens(shift.poste) : null;
|
||||
|
||||
return (
|
||||
<li
|
||||
className="print-keep rounded-2 border px-1.5 py-1 text-micro leading-tight"
|
||||
style={
|
||||
tokens
|
||||
? {
|
||||
background: tokens.bg,
|
||||
color: tokens.fg,
|
||||
borderColor: tokens.edge,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="block font-semibold tnum">{shift.time}</span>
|
||||
<span className="block">{shift.poste.toUpperCase()}</span>
|
||||
{shift.breakMinutes > 0 ? (
|
||||
<span className="block tnum">pause {shift.breakMinutes} min</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ImpressionPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const session = await requireSession();
|
||||
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
|
||||
const board = await getWeekBoard(week, params.etablissement);
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Impression"
|
||||
subtitle="Aucun établissement n'est encore créé."
|
||||
/>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
// Sans la capacité de voir le non publié, la feuille ne doit pas la
|
||||
// contourner : imprimer un brouillon revient à le publier sur un mur.
|
||||
const seeUnpublished = can(session.actor, 'planning.view_unpublished');
|
||||
const sections = board.sections.filter(
|
||||
(section) =>
|
||||
!section.hidden && (seeUnpublished || section.status === 'PUBLISHED'),
|
||||
);
|
||||
|
||||
const hiddenDrafts = board.sections.filter(
|
||||
(section) => !section.hidden && section.status !== 'PUBLISHED',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<div data-print="hide">
|
||||
<PageHeader
|
||||
title={`Planning à afficher · semaine ${week.isoWeek}`}
|
||||
subtitle={`${board.location.name} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
|
||||
actions={
|
||||
<>
|
||||
<Link
|
||||
href={`/planning/semaine?semaine=${board.weekParam}&etablissement=${board.location.id}`}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
Retour à la grille
|
||||
</Link>
|
||||
<PrintButton label="Imprimer la semaine" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-ink-2">
|
||||
{board.location.name} — semaine {week.isoWeek}, {board.label}
|
||||
</p>
|
||||
|
||||
{hiddenDrafts > 0 && seeUnpublished ? (
|
||||
<p
|
||||
data-print="hide"
|
||||
role="status"
|
||||
className="rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs text-warn-soft-ink"
|
||||
>
|
||||
{hiddenDrafts} équipe{hiddenDrafts > 1 ? 's' : ''} non publiée
|
||||
{hiddenDrafts > 1 ? 's' : ''} figure
|
||||
{hiddenDrafts > 1 ? 'nt' : ''} sur cette feuille. Un planning affiché
|
||||
en salle vaut information des salariés : publiez avant d’imprimer.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Rien à imprimer"
|
||||
description="Aucune équipe publiée sur cette semaine."
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{sections.map((section) => (
|
||||
<Card key={section.teamId} className="print-keep">
|
||||
<CardHeader
|
||||
title={section.teamName}
|
||||
badge={
|
||||
section.status === 'PUBLISHED' ? (
|
||||
<Badge tone="ok">Publié</Badge>
|
||||
) : (
|
||||
<Badge tone="warn">Brouillon</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1040px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1">
|
||||
<th className="w-48 px-3 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Salarié
|
||||
</th>
|
||||
{board.headings.map((heading) => (
|
||||
<th
|
||||
key={heading}
|
||||
className="px-2 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
{heading}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line-1">
|
||||
{[
|
||||
...section.rows,
|
||||
...(section.unassignedRow ? [section.unassignedRow] : []),
|
||||
].map((row) => (
|
||||
<tr key={row.membershipId ?? 'non-assignes'} className="print-keep">
|
||||
<th className="px-3 py-2 text-left align-top font-medium">
|
||||
{displayName(row)}
|
||||
<span className="block text-micro font-normal text-ink-3">
|
||||
{row.unassigned ? 'À pourvoir' : row.job}
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{board.dates.map((date, index) => {
|
||||
const absence = absenceOnDay(row, index);
|
||||
const shifts = row.days[index] ?? [];
|
||||
|
||||
return (
|
||||
<td key={date} className="px-2 py-2 align-top">
|
||||
{absence ? (
|
||||
<span className="block rounded-2 border border-line-2 bg-surface-2 px-1.5 py-1 text-micro">
|
||||
{absence.label}
|
||||
</span>
|
||||
) : shifts.length === 0 ? (
|
||||
<span className="text-micro text-ink-3">—</span>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{shifts.map((shift) => (
|
||||
<ShiftLine key={shift.id} shift={shift} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<p className="text-micro text-ink-3">
|
||||
Édité depuis PlanFlow · {board.location.name} · semaine {week.isoWeek}.
|
||||
Les horaires font foi tels qu’ils étaient à l’édition ; une modification
|
||||
postérieure du planning n’apparaît pas sur cette feuille.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { displayName } from '@/domain/planning/board';
|
||||
import {
|
||||
absenceOnDay,
|
||||
dayState,
|
||||
headcountByDay,
|
||||
type DayState,
|
||||
} from '@/domain/planning/presence';
|
||||
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { getWeekBoard } from '@/server/planning/queries';
|
||||
|
||||
export const metadata = { title: 'Planning · présences · PlanFlow' };
|
||||
|
||||
/**
|
||||
* Vue des présences et absences — cinquième vue de §9.
|
||||
*
|
||||
* Elle répond à une question que les quatre autres posent mal : **qui est là
|
||||
* aujourd'hui ?** La grille hebdomadaire montre des créneaux, pas des
|
||||
* personnes ; il faut la lire colonne par colonne et compter de tête.
|
||||
*
|
||||
* Elle lit le **même modèle** que les autres vues, sans requête propre : une
|
||||
* modification faite dans la grille est correcte ici à l'instant même. Une vue
|
||||
* qui recalcule ses présences de son côté finit par contredire le planning
|
||||
* qu'elle prétend résumer.
|
||||
*/
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
|
||||
}
|
||||
|
||||
const STATE_TONE: Record<DayState, Tone> = {
|
||||
present: 'ok',
|
||||
absent: 'warn',
|
||||
off: 'neutral',
|
||||
};
|
||||
|
||||
const STATE_LABEL: Record<DayState, string> = {
|
||||
present: 'Présent',
|
||||
absent: 'Absent',
|
||||
off: 'Repos',
|
||||
};
|
||||
|
||||
/**
|
||||
* État d'un salarié un jour donné.
|
||||
*
|
||||
* L'absence l'emporte sur le créneau : un salarié dont l'absence a été acceptée
|
||||
* après la construction du planning garde des créneaux en base, et l'afficher
|
||||
* « présent » enverrait un manager le chercher en rayon.
|
||||
*/
|
||||
export default async function PresencesPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
|
||||
const board = await getWeekBoard(week, params.etablissement);
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Présences et absences"
|
||||
subtitle="Aucun établissement n'est encore créé."
|
||||
/>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
const href = (semaine: string, etablissement = board.location.id) =>
|
||||
`/planning/presences?semaine=${semaine}&etablissement=${etablissement}`;
|
||||
|
||||
// Les besoins non couverts n'ont personne à compter : ils appartiennent à la
|
||||
// grille, pas à un état de présence.
|
||||
const sections = board.sections
|
||||
.filter((section) => !section.hidden)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
rows: section.rows.filter((row) => !row.unassigned),
|
||||
}));
|
||||
|
||||
const perDay = headcountByDay(
|
||||
sections.flatMap((section) => section.rows),
|
||||
board.dates.length,
|
||||
).map((tally, index) => ({ date: board.dates[index] as string, ...tally }));
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title={`Présences · semaine ${week.isoWeek}`}
|
||||
subtitle={`${board.location.name} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
|
||||
actions={
|
||||
<>
|
||||
<Link
|
||||
href={href(board.previousParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
← Semaine précédente
|
||||
</Link>
|
||||
<Link
|
||||
href={href(board.nextParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
Semaine suivante →
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Effectif présent par jour" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1">
|
||||
<th className="px-4 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Jour
|
||||
</th>
|
||||
{board.headings.map((heading) => (
|
||||
<th
|
||||
key={heading}
|
||||
className="px-3 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
{heading}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line-1">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Présents</th>
|
||||
{perDay.map((day) => (
|
||||
<td key={day.date} className="px-3 py-2 text-center tnum">
|
||||
{day.present}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Absents</th>
|
||||
{perDay.map((day) => (
|
||||
<td
|
||||
key={day.date}
|
||||
className={cx(
|
||||
'px-3 py-2 text-center tnum',
|
||||
day.absent > 0 ? 'text-warn-soft-ink' : 'text-ink-3',
|
||||
)}
|
||||
>
|
||||
{day.absent}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Repos</th>
|
||||
{perDay.map((day) => (
|
||||
<td
|
||||
key={day.date}
|
||||
className="px-3 py-2 text-center tnum text-ink-3"
|
||||
>
|
||||
{day.off}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Aucune équipe"
|
||||
description="Créez une équipe et rattachez-y des salariés pour suivre les présences."
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{sections.map((section) => (
|
||||
<Card key={section.teamId}>
|
||||
<CardHeader
|
||||
title={section.teamName}
|
||||
badge={
|
||||
<Badge
|
||||
tone={section.status === 'PUBLISHED' ? 'ok' : 'warn'}
|
||||
>
|
||||
{section.status === 'PUBLISHED' ? 'Publié' : 'Non publié'}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
{section.rows.length === 0 ? (
|
||||
<EmptyState title="Aucun salarié rattaché" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1">
|
||||
<th className="px-4 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Salarié
|
||||
</th>
|
||||
{board.headings.map((heading) => (
|
||||
<th
|
||||
key={heading}
|
||||
className="px-3 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
{heading}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line-1">
|
||||
{section.rows.map((row) => (
|
||||
<tr key={row.membershipId ?? displayName(row)}>
|
||||
<th className="px-4 py-2 text-left font-medium">
|
||||
{row.membershipId ? (
|
||||
<Link
|
||||
href={`/equipe/${row.membershipId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{displayName(row)}
|
||||
</Link>
|
||||
) : (
|
||||
displayName(row)
|
||||
)}
|
||||
<span className="block text-micro font-normal text-ink-3">
|
||||
{row.job}
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{board.dates.map((date, index) => {
|
||||
const state = dayState(row, index);
|
||||
const absence = absenceOnDay(row, index);
|
||||
return (
|
||||
<td key={date} className="px-3 py-2 text-center">
|
||||
<Badge tone={STATE_TONE[state]}>
|
||||
{absence ? absence.label : STATE_LABEL[state]}
|
||||
</Badge>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Une absence acceptée l’emporte sur les créneaux restés au planning : un
|
||||
salarié dont le congé a été validé après la construction de la semaine
|
||||
apparaît absent, même si ses créneaux n’ont pas encore été retirés.
|
||||
L’inverse enverrait un responsable le chercher en rayon.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { getActivity } from '@/server/reports/activity';
|
||||
|
||||
export const metadata = { title: 'Journal d’activité · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{
|
||||
domaine?: string;
|
||||
objet?: string;
|
||||
du?: string;
|
||||
au?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const stamp = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
|
||||
export default async function ActivitePage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
// `exactOptionalPropertyTypes` : un filtre absent ne se transmet pas comme
|
||||
// « undefined », il ne se transmet pas du tout.
|
||||
const page = await getActivity({
|
||||
...(params.domaine ? { domain: params.domaine } : {}),
|
||||
...(params.objet ? { entityType: params.objet } : {}),
|
||||
...(params.du ? { from: params.du } : {}),
|
||||
...(params.au ? { to: params.au } : {}),
|
||||
});
|
||||
|
||||
const withParam = (key: string, value: string | undefined) => {
|
||||
const next = new URLSearchParams();
|
||||
for (const [name, current] of Object.entries(params)) {
|
||||
if (current) next.set(name, current);
|
||||
}
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
const qs = next.toString();
|
||||
return qs ? `/rapports/activite?${qs}` : '/rapports/activite';
|
||||
};
|
||||
|
||||
const chip = (href: string, label: string, active: boolean) => (
|
||||
<Link
|
||||
key={`${href}-${label}`}
|
||||
href={href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={
|
||||
active
|
||||
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
|
||||
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Journal d’activité"
|
||||
subtitle={`${page.total} entrée${page.total > 1 ? 's' : ''} sur la période`}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Filtres"
|
||||
badge={
|
||||
page.truncated ? (
|
||||
<Badge tone="warn">200 dernières affichées</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{chip(withParam('domaine', undefined), 'Tous les domaines', !params.domaine)}
|
||||
{page.domains.map((domain) =>
|
||||
chip(
|
||||
withParam('domaine', domain),
|
||||
domain,
|
||||
params.domaine === domain,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{chip(withParam('objet', undefined), 'Tous les objets', !params.objet)}
|
||||
{page.entityTypes.map((type) =>
|
||||
chip(withParam('objet', type), type, params.objet === type),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="flex flex-wrap items-end gap-3" method="get">
|
||||
{params.domaine ? (
|
||||
<input type="hidden" name="domaine" value={params.domaine} />
|
||||
) : null}
|
||||
{params.objet ? (
|
||||
<input type="hidden" name="objet" value={params.objet} />
|
||||
) : null}
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Du</span>
|
||||
<input
|
||||
type="date"
|
||||
name="du"
|
||||
defaultValue={params.du}
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Au</span>
|
||||
<input
|
||||
type="date"
|
||||
name="au"
|
||||
defaultValue={params.au}
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="h-9 rounded-2 border border-line-3 px-3 text-sm hover:bg-surface-2"
|
||||
>
|
||||
Appliquer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Entrées" />
|
||||
|
||||
{page.entries.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucune entrée"
|
||||
description="Aucune action enregistrée pour ces filtres."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left">
|
||||
{['Horodatage', 'Auteur', 'Action', 'Objet', 'Champs', 'Justification'].map(
|
||||
(heading) => (
|
||||
<th
|
||||
key={heading}
|
||||
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
{heading}
|
||||
</th>
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line-1">
|
||||
{page.entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="px-4 py-2 tnum whitespace-nowrap">
|
||||
{stamp.format(entry.occurredAt)}
|
||||
</td>
|
||||
<td className="px-4 py-2">{entry.actorName}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{entry.action}</td>
|
||||
<td className="px-4 py-2 text-ink-2">
|
||||
{entry.entityType}
|
||||
<span className="block font-mono text-micro text-ink-3">
|
||||
{entry.entityId}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-ink-2">
|
||||
{entry.changedFields.length === 0
|
||||
? '—'
|
||||
: entry.changedFields.join(', ')}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-ink-2">
|
||||
{entry.reason ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Le journal enregistre l’avant et l’après de chaque mutation — c’est ce
|
||||
qui le rend opposable. Cet écran n’affiche que le <em>nom</em> des champs
|
||||
modifiés : la valeur se lit sur la fiche concernée, sous ses propres
|
||||
autorisations. Un salaire ou un motif médical n’a pas à être visible de
|
||||
quiconque ouvre la piste d’audit.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AccountIdentityForm } from '@/components/settings/AccountIdentityForm';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { getAccountIdentity } from '@/server/settings/account';
|
||||
|
||||
export const metadata = { title: 'Informations du compte · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ComptePage() {
|
||||
const account = await getAccountIdentity();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Informations du compte"
|
||||
subtitle="Identité de l’entreprise et siège social"
|
||||
/>
|
||||
|
||||
{account === null ? (
|
||||
<Card>
|
||||
<EmptyState title="Compte introuvable" />
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader title={account.name} />
|
||||
<AccountIdentityForm account={account} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Le siège n’est pas un établissement : il porte l’identité juridique, là
|
||||
où{' '}
|
||||
<Link href="/reglages/etablissements" className="underline">
|
||||
les établissements
|
||||
</Link>{' '}
|
||||
portent le SIRET, le fuseau effectif et les plannings. Une entreprise
|
||||
dont le siège n’ouvre aucun magasin garde donc une adresse ici et aucune
|
||||
équipe.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import type { AgreementParameters } from '@/domain/compliance/parameters';
|
||||
import {
|
||||
publishAgreementVersionAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/agreement';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
function Group({
|
||||
title,
|
||||
note,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
note?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-2 border-t border-line-1 pt-3">
|
||||
<legend className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
{title}
|
||||
</legend>
|
||||
{note ? <p className="text-micro text-ink-3">{note}</p> : null}
|
||||
<div className="flex flex-wrap gap-3">{children}</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function Area({
|
||||
label,
|
||||
name,
|
||||
defaultValue,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
defaultValue: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<textarea
|
||||
name={name}
|
||||
rows={4}
|
||||
defaultValue={defaultValue}
|
||||
className="rounded-2 border border-line-2 bg-surface px-3 py-2 font-mono text-xs text-ink-1 outline-none focus-visible:border-focus"
|
||||
/>
|
||||
<span className="text-micro text-ink-3">{hint}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgreementForm({
|
||||
idcc,
|
||||
name,
|
||||
parameters,
|
||||
}: {
|
||||
idcc: string;
|
||||
name: string;
|
||||
parameters: AgreementParameters;
|
||||
}) {
|
||||
const [state, action] = useActionState(publishAgreementVersionAction, empty);
|
||||
const p = parameters;
|
||||
|
||||
const tiers = p.overtime.tiers
|
||||
.map((tier) => `${tier.fromMinutes};${tier.toMinutes ?? ''};${tier.ratePercent}`)
|
||||
.join('\n');
|
||||
const derogations = p.partTime.derogations
|
||||
.map((row) => `${row.code};${row.label};${row.minWeeklyMinutes}`)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-col gap-4 p-4">
|
||||
<Group title="Version" note="Les durées sont en minutes entières.">
|
||||
<Field label="IDCC" name="idcc" defaultValue={idcc} required maxLength={10} />
|
||||
<Field label="Intitulé" name="name" defaultValue={name} required maxLength={160} />
|
||||
<Field
|
||||
label="En vigueur à partir du"
|
||||
name="effectiveFrom"
|
||||
type="date"
|
||||
required
|
||||
hint="La version précédente reste applicable aux semaines antérieures."
|
||||
/>
|
||||
<Field
|
||||
label="Source"
|
||||
name="source"
|
||||
required
|
||||
maxLength={400}
|
||||
placeholder="Legifrance, texte consolidé au…"
|
||||
hint="Sans source, le paramètre n’est pas opposable."
|
||||
/>
|
||||
<Field label="Approuvé par" name="approvedBy" maxLength={160} />
|
||||
</Group>
|
||||
|
||||
<Group title="Durées">
|
||||
<Field label="Référence hebdomadaire" name="weeklyReferenceMinutes" type="number" defaultValue={p.weeklyReferenceMinutes} required />
|
||||
<Field label="Maximum quotidien" name="maxDailyWorkMinutes" type="number" defaultValue={p.maxDailyWorkMinutes} required />
|
||||
<Field
|
||||
label="Amplitude quotidienne"
|
||||
name="maxDailyAmplitudeMinutes"
|
||||
type="number"
|
||||
defaultValue={p.maxDailyAmplitudeMinutes ?? ''}
|
||||
hint="Vide : la convention n’en fixe pas, la règle reste muette."
|
||||
/>
|
||||
<Field label="Repos quotidien minimal" name="minDailyRestMinutes" type="number" defaultValue={p.minDailyRestMinutes} required />
|
||||
<Field label="Repos hebdomadaire minimal" name="minWeeklyRestMinutes" type="number" defaultValue={p.minWeeklyRestMinutes} required />
|
||||
<Field label="Maximum hebdomadaire absolu" name="maxWeeklyWorkAbsoluteMinutes" type="number" defaultValue={p.maxWeeklyWorkAbsoluteMinutes} required />
|
||||
<Field label="Fenêtre de moyenne (semaines)" name="averagedWindowWeeks" type="number" defaultValue={p.averagedWeeklyWork.windowWeeks} required />
|
||||
<Field label="Moyenne maximale" name="averagedMaxMinutes" type="number" defaultValue={p.averagedWeeklyWork.maxAverageMinutes} required />
|
||||
<Field label="Jours consécutifs maximum" name="maxConsecutiveWorkDays" type="number" defaultValue={p.maxConsecutiveWorkDays} required />
|
||||
<Field label="Seuil de pause" name="breakThresholdMinutes" type="number" defaultValue={p.breakAfterThreshold.thresholdMinutes} required />
|
||||
<Field label="Pause minimale" name="breakMinMinutes" type="number" defaultValue={p.breakAfterThreshold.minBreakMinutes} required />
|
||||
<Field label="Tolérance d’écart au contrat" name="deviationToleranceMinutes" type="number" defaultValue={p.contractDeviationToleranceMinutes} required />
|
||||
</Group>
|
||||
|
||||
<Group title="Temps partiel">
|
||||
<Field label="Minimum hebdomadaire" name="partTimeMinWeeklyMinutes" type="number" defaultValue={p.partTime.minWeeklyMinutes} required />
|
||||
<Area
|
||||
label="Dérogations"
|
||||
name="derogations"
|
||||
defaultValue={derogations}
|
||||
hint="Une par ligne : code;libellé;minutes."
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group title="Heures supplémentaires">
|
||||
<Area
|
||||
label="Tranches"
|
||||
name="overtimeTiers"
|
||||
defaultValue={tiers}
|
||||
hint="Une par ligne : début;fin;taux. Fin vide = sans plafond. Cumulatives depuis le début de la semaine."
|
||||
/>
|
||||
<Field label="Contingent annuel" name="overtimeQuotaMinutes" type="number" defaultValue={p.overtime.annualQuotaMinutes} required />
|
||||
<Field label="Repos au-delà du contingent (%)" name="overtimeBeyondRestPercent" type="number" step="0.01" defaultValue={p.overtime.beyondQuotaRestPercent} required />
|
||||
</Group>
|
||||
|
||||
<Group title="Heures complémentaires">
|
||||
<Field label="Taux première tranche (%)" name="complementaryFirstRate" type="number" step="0.01" defaultValue={p.complementaryHours.firstTierRatePercent} required />
|
||||
<Field label="Taux au-delà (%)" name="complementaryBeyondRate" type="number" step="0.01" defaultValue={p.complementaryHours.beyondRatePercent} required />
|
||||
<Field label="Fraction première tranche" name="complementaryFirstFraction" type="number" step="0.01" defaultValue={p.complementaryHours.firstTierFraction} required />
|
||||
<Field label="Plafond (fraction)" name="complementaryCapFraction" type="number" step="0.01" defaultValue={p.complementaryHours.capFraction} required />
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Dimanche"
|
||||
note="La majoration et le repos compensateur vont ensemble : l’un sans l’autre est un manquement à L3132-27."
|
||||
>
|
||||
<Field label="Majoration (%)" name="sundayPremiumPercent" type="number" step="0.01" defaultValue={p.sunday.premiumPercent} required />
|
||||
<Field label="Repos compensateur (%)" name="sundayRestPercent" type="number" step="0.01" defaultValue={p.sunday.compensatoryRestPercent} required />
|
||||
<Field label="Dimanches du maire par an" name="sundayMayorQuota" type="number" defaultValue={p.sunday.mayorQuotaPerYear} required />
|
||||
</Group>
|
||||
|
||||
<Group title="Jours fériés">
|
||||
<Field label="Indemnité si travaillé (%)" name="holidayWorkedPremium" type="number" step="0.01" defaultValue={p.holiday.workedPremiumPercent} required />
|
||||
<Field label="1er mai travaillé (%)" name="holidayLabourDayPremium" type="number" step="0.01" defaultValue={p.holiday.labourDayPremiumPercent} required />
|
||||
<Field label="Fériés chômés garantis" name="holidayPaidOffDays" type="number" defaultValue={p.holiday.paidOffDaysPerYear} required />
|
||||
<Field label="Repos de substitution (%)" name="holidaySubstitutionRest" type="number" step="0.01" defaultValue={p.holiday.substitutionRestPercent} required />
|
||||
</Group>
|
||||
|
||||
<Group title="Nuit et forfait jours">
|
||||
<Field label="Début de nuit (min. depuis minuit)" name="nightStartMinutes" type="number" defaultValue={p.night.startMinutes} required />
|
||||
<Field label="Fin de nuit" name="nightEndMinutes" type="number" defaultValue={p.night.endMinutes} required />
|
||||
<Field label="Non imposable à partir de" name="nightNotEnforceableFromAge" type="number" defaultValue={p.night.notEnforceableFromAge} required />
|
||||
<Field label="Jours de forfait maximum" name="forfaitMaxDaysPerYear" type="number" defaultValue={p.forfaitJours.maxDaysPerYear} required />
|
||||
<Field label="Entretien de charge (mois)" name="forfaitReviewMonths" type="number" defaultValue={p.forfaitJours.workloadReviewIntervalMonths} required />
|
||||
</Group>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Publier une nouvelle version</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">
|
||||
Version enregistrée. Les semaines antérieures gardent la précédente.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { AgreementForm } from '@/app/(app)/reglages/convention/AgreementForm';
|
||||
import {
|
||||
IDCC_1517_PROVENANCE,
|
||||
type ParameterOrigin,
|
||||
} from '@/domain/compliance/idcc1517';
|
||||
import { listAgreementVersions } from '@/server/settings/agreement';
|
||||
|
||||
export const metadata = { title: 'Convention collective · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const ORIGIN_TONE: Record<ParameterOrigin, 'danger' | 'info' | 'warn'> = {
|
||||
OP: 'danger',
|
||||
CCN: 'info',
|
||||
ENT: 'warn',
|
||||
};
|
||||
|
||||
const ORIGIN_LABEL: Record<ParameterOrigin, string> = {
|
||||
OP: 'Ordre public',
|
||||
CCN: 'Convention',
|
||||
ENT: 'Accord d’entreprise',
|
||||
};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
|
||||
export default async function ConventionPage() {
|
||||
const versions = await listAgreementVersions();
|
||||
const current = versions.find((version) => version.isCurrent) ?? versions[0];
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Convention collective"
|
||||
subtitle={
|
||||
current
|
||||
? `IDCC ${current.idcc} · version ${current.version}`
|
||||
: 'Aucune convention chargée'
|
||||
}
|
||||
/>
|
||||
|
||||
{!current ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Aucune convention"
|
||||
description="Le moteur de règles lit ses seuils en base. Sans convention chargée, aucune règle horaire ne s’applique."
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Origine des valeurs"
|
||||
badge={<Badge tone="neutral">{IDCC_1517_PROVENANCE.length}</Badge>}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left">
|
||||
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Paramètre
|
||||
</th>
|
||||
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Valeur
|
||||
</th>
|
||||
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Origine
|
||||
</th>
|
||||
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Source
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line-1">
|
||||
{IDCC_1517_PROVENANCE.map((entry) => (
|
||||
<tr key={entry.key}>
|
||||
<td className="px-4 py-2">{entry.label}</td>
|
||||
<td className="px-4 py-2 tnum">{entry.value}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge tone={ORIGIN_TONE[entry.origin]}>
|
||||
{ORIGIN_LABEL[entry.origin]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-ink-2">{entry.source}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="border-t border-line-1 px-4 py-3 text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Une valeur d’ordre public ne se négocie pas ; une valeur
|
||||
conventionnelle change si la convention change ; une valeur d’accord
|
||||
d’entreprise suppose un accord signé. Confondre les trois fait
|
||||
apparaître comme discutable ce qui s’impose, et l’inverse.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Versions"
|
||||
badge={<Badge tone="neutral">{versions.length}</Badge>}
|
||||
/>
|
||||
{versions.length === 0 ? (
|
||||
<EmptyState title="Aucune version enregistrée" />
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{versions.map((version) => (
|
||||
<li key={version.id} className="flex flex-wrap items-center gap-2 px-4 py-3">
|
||||
<Badge tone={version.isCurrent ? 'ok' : 'neutral'}>
|
||||
v{version.version}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">{version.name}</span>
|
||||
<span className="text-sm text-ink-2">
|
||||
en vigueur au {dateFormat.format(version.effectiveFrom)}
|
||||
</span>
|
||||
{version.isCurrent ? <Badge tone="accent">Applicable</Badge> : null}
|
||||
<span className="flex-1" />
|
||||
<span className="text-xs text-ink-3">
|
||||
{version.approvedBy
|
||||
? `Approuvée par ${version.approvedBy}`
|
||||
: 'Non approuvée'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{current ? (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Éditer les paramètres"
|
||||
badge={<Badge tone="warn">crée une version</Badge>}
|
||||
/>
|
||||
<AgreementForm
|
||||
idcc={current.idcc}
|
||||
name={current.name}
|
||||
parameters={current.parameters}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import {
|
||||
createJobTitleAction,
|
||||
renameJobTitleAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/job-titles';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
export function AddJobTitleForm() {
|
||||
const [state, action] = useActionState(createJobTitleAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={action}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field
|
||||
label="Intitulé"
|
||||
name="name"
|
||||
required
|
||||
maxLength={120}
|
||||
placeholder="Hôte de caisse"
|
||||
hint="L’intitulé figure au registre unique du personnel et sur le contrat."
|
||||
/>
|
||||
<SubmitButton>Ajouter l’emploi</SubmitButton>
|
||||
</div>
|
||||
<FormError>{state.error}</FormError>
|
||||
{state.ok ? (
|
||||
<p className="text-xs text-ok-soft-ink">Emploi enregistré.</p>
|
||||
) : null}
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenameJobTitleForm({
|
||||
id,
|
||||
name,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
}) {
|
||||
const [state, action] = useActionState(renameJobTitleAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-1 items-end gap-2">
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<Field
|
||||
label="Intitulé"
|
||||
name="name"
|
||||
defaultValue={name}
|
||||
required
|
||||
maxLength={120}
|
||||
className="h-8"
|
||||
/>
|
||||
<SubmitButton size="sm">Renommer</SubmitButton>
|
||||
<FormError>{state.error}</FormError>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
AddJobTitleForm,
|
||||
RenameJobTitleForm,
|
||||
} from '@/app/(app)/reglages/emplois/JobTitleForms';
|
||||
import {
|
||||
archiveJobTitleAction,
|
||||
listJobTitles,
|
||||
} from '@/server/settings/job-titles';
|
||||
|
||||
export const metadata = { title: 'Emplois · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function EmploisPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ archives?: string }>;
|
||||
}) {
|
||||
const { archives } = await searchParams;
|
||||
const showArchived = archives === '1';
|
||||
const titles = await listJobTitles(showArchived);
|
||||
const active = titles.filter((title) => title.archivedAt === null);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Emplois"
|
||||
subtitle={`${active.length} emploi${active.length > 1 ? 's' : ''} en vigueur`}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Référentiel"
|
||||
badge={
|
||||
<Badge tone="neutral">
|
||||
{titles.length} affiché{titles.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<a
|
||||
href={showArchived ? '/reglages/emplois' : '/reglages/emplois?archives=1'}
|
||||
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
|
||||
>
|
||||
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
{titles.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun emploi"
|
||||
description="Un contrat porte une qualification ; créez d’abord les emplois de l’organisation."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{titles.map((title) => (
|
||||
<li
|
||||
key={title.id}
|
||||
className="flex flex-wrap items-end gap-3 px-4 py-3"
|
||||
>
|
||||
<RenameJobTitleForm id={title.id} name={title.name} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone="neutral">
|
||||
{title.contractCount} contrat
|
||||
{title.contractCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{title.archivedAt ? <Badge tone="warn">Archivé</Badge> : null}
|
||||
</div>
|
||||
|
||||
<form action={archiveJobTitleAction}>
|
||||
<input type="hidden" name="id" value={title.id} />
|
||||
{title.archivedAt ? (
|
||||
<input type="hidden" name="restore" value="1" />
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" type="submit">
|
||||
{title.archivedAt ? 'Rétablir' : 'Archiver'}
|
||||
</Button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Nouvel emploi" />
|
||||
<div className="p-4">
|
||||
<AddJobTitleForm />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Un emploi porté par un contrat est archivé, jamais supprimé : le registre
|
||||
unique du personnel doit rester lisible pendant toute la durée de
|
||||
conservation, y compris pour un salarié parti.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import { POSTE_CODES, POSTE_LABELS } from '@/lib/design/postes';
|
||||
import {
|
||||
createLabelAction,
|
||||
updateLabelAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/labels';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
function PaletteSelect({ defaultValue }: { defaultValue?: string }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Teinte</span>
|
||||
<select
|
||||
name="paletteKey"
|
||||
defaultValue={defaultValue ?? POSTE_CODES[0]}
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{POSTE_CODES.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{POSTE_LABELS[code]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddLabelForm() {
|
||||
const [state, action] = useActionState(createLabelAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={action}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field
|
||||
label="Code"
|
||||
name="code"
|
||||
required
|
||||
maxLength={12}
|
||||
placeholder="cai"
|
||||
hint="Affiché sur le bloc de planning : la couleur ne porte jamais l’information seule."
|
||||
/>
|
||||
<Field label="Libellé" name="name" required maxLength={120} placeholder="Caisse" />
|
||||
<PaletteSelect />
|
||||
<SubmitButton>Ajouter l’étiquette</SubmitButton>
|
||||
</div>
|
||||
<FormError>{state.error}</FormError>
|
||||
{state.ok ? (
|
||||
<p className="text-xs text-ok-soft-ink">Étiquette enregistrée.</p>
|
||||
) : null}
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditLabelForm({
|
||||
id,
|
||||
name,
|
||||
paletteKey,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
paletteKey: string;
|
||||
}) {
|
||||
const [state, action] = useActionState(updateLabelAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-1 flex-wrap items-end gap-2">
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<Field label="Libellé" name="name" defaultValue={name} required maxLength={120} />
|
||||
<PaletteSelect defaultValue={paletteKey} />
|
||||
<SubmitButton size="sm">Enregistrer</SubmitButton>
|
||||
<FormError>{state.error}</FormError>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
AddLabelForm,
|
||||
EditLabelForm,
|
||||
} from '@/app/(app)/reglages/etiquettes/LabelForms';
|
||||
import { isPosteCode, posteTokens } from '@/lib/design/postes';
|
||||
import { archiveLabelAction, listLabels } from '@/server/settings/labels';
|
||||
|
||||
export const metadata = { title: 'Étiquettes · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function Swatch({ paletteKey, code }: { paletteKey: string; code: string }) {
|
||||
if (!isPosteCode(paletteKey)) {
|
||||
return <Badge tone="warn">teinte inconnue</Badge>;
|
||||
}
|
||||
const tokens = posteTokens(paletteKey);
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-6 shrink-0 items-center rounded-2 border px-2 text-micro font-semibold"
|
||||
style={{
|
||||
background: tokens.bg,
|
||||
color: tokens.fg,
|
||||
borderColor: tokens.edge,
|
||||
}}
|
||||
>
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function EtiquettesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ archives?: string }>;
|
||||
}) {
|
||||
const { archives } = await searchParams;
|
||||
const showArchived = archives === '1';
|
||||
const labels = await listLabels(showArchived);
|
||||
const active = labels.filter((label) => label.archivedAt === null);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Étiquettes"
|
||||
subtitle={`${active.length} étiquette${active.length > 1 ? 's' : ''} en vigueur`}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Postes de la grille"
|
||||
action={
|
||||
<a
|
||||
href={
|
||||
showArchived
|
||||
? '/reglages/etiquettes'
|
||||
: '/reglages/etiquettes?archives=1'
|
||||
}
|
||||
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
|
||||
>
|
||||
{showArchived ? 'Masquer les archivées' : 'Voir les archivées'}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
{labels.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucune étiquette"
|
||||
description="Une étiquette colore le créneau et nomme le poste tenu. Créez-en une pour commencer."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{labels.map((label) => (
|
||||
<li
|
||||
key={label.id}
|
||||
className="flex flex-wrap items-end gap-3 px-4 py-3"
|
||||
>
|
||||
<Swatch paletteKey={label.paletteKey} code={label.code} />
|
||||
|
||||
<EditLabelForm
|
||||
id={label.id}
|
||||
name={label.name}
|
||||
paletteKey={label.paletteKey}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone="neutral">
|
||||
{label.shiftCount} créneau{label.shiftCount > 1 ? 'x' : ''}
|
||||
</Badge>
|
||||
{label.archivedAt ? <Badge tone="warn">Archivée</Badge> : null}
|
||||
</div>
|
||||
|
||||
<form action={archiveLabelAction}>
|
||||
<input type="hidden" name="id" value={label.id} />
|
||||
{label.archivedAt ? (
|
||||
<input type="hidden" name="restore" value="1" />
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" type="submit">
|
||||
{label.archivedAt ? 'Rétablir' : 'Archiver'}
|
||||
</Button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Nouvelle étiquette" />
|
||||
<div className="p-4">
|
||||
<AddLabelForm />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Les douze teintes viennent d’une palette calculée pour rester
|
||||
distinguables en vision daltonienne et à l’impression en noir et blanc.
|
||||
C’est pourquoi le code du poste s’affiche toujours avec la couleur, et
|
||||
pourquoi la teinte se choisit dans la liste plutôt qu’à la pipette.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import { PRINT_DENSITIES, togglesOf } from '@/domain/settings/preferences';
|
||||
import {
|
||||
savePreferencesAction,
|
||||
type ActionState,
|
||||
type Preferences,
|
||||
} from '@/server/settings/account';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
export function PrintForm({ preferences }: { preferences: Preferences }) {
|
||||
const [state, action] = useActionState(savePreferencesAction, empty);
|
||||
const toggles = togglesOf('print');
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-col gap-4 px-4 py-4">
|
||||
{toggles.map((toggle) => (
|
||||
<input key={`scope-${toggle.key}`} type="hidden" name="scope" value={toggle.key} />
|
||||
))}
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm font-medium">Orientation</legend>
|
||||
<p className="text-micro text-ink-3">
|
||||
Une semaine se lit en largeur : le paysage évite de couper le
|
||||
dimanche sur une seconde page.
|
||||
</p>
|
||||
{[
|
||||
{ value: 'landscape', label: 'Paysage' },
|
||||
{ value: 'portrait', label: 'Portrait' },
|
||||
].map((option) => (
|
||||
<label key={option.value} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="printOrientation"
|
||||
value={option.value}
|
||||
defaultChecked={
|
||||
(option.value === 'landscape') === preferences.printLandscape
|
||||
}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<label className="flex max-w-sm flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Densité</span>
|
||||
<select
|
||||
name="printDensity"
|
||||
defaultValue={preferences.printDensity}
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{PRINT_DENSITIES.map((density) => (
|
||||
<option key={density.key} value={density.key}>
|
||||
{density.label} — {density.hint}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{toggles.map((toggle) => (
|
||||
<div key={toggle.key} className="flex flex-col gap-1">
|
||||
<label className="flex items-start gap-2.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={toggle.key}
|
||||
defaultChecked={Boolean(
|
||||
preferences[toggle.key as keyof Preferences],
|
||||
)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{toggle.label}</span>
|
||||
<span className="block text-micro leading-[var(--lh-prose)] text-ink-3">
|
||||
{toggle.hint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{toggle.warning ? (
|
||||
<p className="ml-6 rounded-2 border border-warn bg-warn-soft px-2.5 py-1.5 text-micro leading-[var(--lh-prose)] text-warn-soft-ink">
|
||||
{toggle.warning}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Réglages enregistrés.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PrintForm } from '@/app/(app)/reglages/impression/PrintForm';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { getPreferences } from '@/server/settings/account';
|
||||
|
||||
export const metadata = { title: 'Impression · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ImpressionReglagesPage() {
|
||||
const preferences = await getPreferences();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Impression"
|
||||
subtitle="Mise en page du planning affiché en salle"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Planning imprimé" />
|
||||
<PrintForm preferences={preferences} />
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Ces réglages gouvernent{' '}
|
||||
<Link href="/planning/impression" className="underline">
|
||||
le planning à afficher
|
||||
</Link>
|
||||
. Le rendu passe par la boîte d’impression du navigateur, qui sait aussi
|
||||
enregistrer en PDF : un second moteur de rendu serait un second endroit
|
||||
où la feuille peut diverger de l’écran.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import {
|
||||
createTemplateAction,
|
||||
updateTemplateAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/templates';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
function BodyField({ defaultValue }: { defaultValue?: string }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Corps du document</span>
|
||||
<textarea
|
||||
name="bodyHtml"
|
||||
rows={10}
|
||||
required
|
||||
defaultValue={defaultValue}
|
||||
placeholder="<p>Je soussigné…, atteste que {{salarie.prenom}} {{salarie.nom}} est employé…</p>"
|
||||
className="rounded-2 border border-line-2 bg-surface px-3 py-2 font-mono text-xs text-ink-1 outline-none focus-visible:border-focus"
|
||||
/>
|
||||
<span className="text-micro text-ink-3">
|
||||
Les variables s’écrivent entre doubles accolades. Une variable inconnue
|
||||
fait refuser l’enregistrement — mieux vaut la corriger ici que de
|
||||
découvrir un trou dans l’attestation au moment de la remettre.
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddTemplateForm() {
|
||||
const [state, action] = useActionState(createTemplateAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={action}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<Field
|
||||
label="Nom du modèle"
|
||||
name="name"
|
||||
required
|
||||
maxLength={160}
|
||||
placeholder="Attestation d’emploi"
|
||||
/>
|
||||
<BodyField />
|
||||
<FormError>{state.error}</FormError>
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Créer le modèle</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Modèle enregistré.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditTemplateForm({
|
||||
id,
|
||||
name,
|
||||
bodyHtml,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
bodyHtml: string;
|
||||
}) {
|
||||
const [state, action] = useActionState(updateTemplateAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-col gap-3">
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<Field label="Nom du modèle" name="name" defaultValue={name} required maxLength={160} />
|
||||
<BodyField defaultValue={bodyHtml} />
|
||||
<FormError>{state.error}</FormError>
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton size="sm">Enregistrer</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Modifications prises.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
AddTemplateForm,
|
||||
EditTemplateForm,
|
||||
} from '@/app/(app)/reglages/modeles-documents/TemplateForms';
|
||||
import { TEMPLATE_FIELDS } from '@/domain/documents/template';
|
||||
import {
|
||||
archiveTemplateAction,
|
||||
listTemplates,
|
||||
} from '@/server/settings/templates';
|
||||
|
||||
export const metadata = { title: 'Modèles de documents · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const SCOPE_LABEL = {
|
||||
employee: 'Salarié',
|
||||
contract: 'Contrat',
|
||||
location: 'Établissement',
|
||||
} as const;
|
||||
|
||||
export default async function ModelesDocumentsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ archives?: string }>;
|
||||
}) {
|
||||
const { archives } = await searchParams;
|
||||
const showArchived = archives === '1';
|
||||
const templates = await listTemplates(showArchived);
|
||||
const active = templates.filter((template) => template.archivedAt === null);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Modèles de documents"
|
||||
subtitle={`${active.length} modèle${active.length > 1 ? 's' : ''} disponible${active.length > 1 ? 's' : ''}`}
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[1fr_18rem]">
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Modèles"
|
||||
action={
|
||||
<a
|
||||
href={
|
||||
showArchived
|
||||
? '/reglages/modeles-documents'
|
||||
: '/reglages/modeles-documents?archives=1'
|
||||
}
|
||||
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
|
||||
>
|
||||
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun modèle"
|
||||
description="Un modèle produit une attestation ou un courrier type en résolvant les variables du dossier."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{templates.map((template) => (
|
||||
<li key={template.id} className="px-4 py-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold">
|
||||
{template.name}
|
||||
</span>
|
||||
<Badge tone="neutral">
|
||||
{template.documentCount} pièce
|
||||
{template.documentCount > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{template.archivedAt ? (
|
||||
<Badge tone="warn">Archivé</Badge>
|
||||
) : null}
|
||||
<span className="flex-1" />
|
||||
<form action={archiveTemplateAction}>
|
||||
<input type="hidden" name="id" value={template.id} />
|
||||
{template.archivedAt ? (
|
||||
<input type="hidden" name="restore" value="1" />
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" type="submit">
|
||||
{template.archivedAt ? 'Rétablir' : 'Archiver'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{template.fields.length > 0 ? (
|
||||
<ul className="mb-3 flex flex-wrap gap-1.5">
|
||||
{template.fields.map((field) => (
|
||||
<li key={field}>
|
||||
<Badge tone="info">{field}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mb-3 text-xs text-ink-3">
|
||||
Aucune variable : ce modèle produit toujours le même
|
||||
texte.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
|
||||
Modifier
|
||||
</summary>
|
||||
<div className="pt-3">
|
||||
<EditTemplateForm
|
||||
id={template.id}
|
||||
name={template.name}
|
||||
bodyHtml={template.bodyHtml}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Nouveau modèle" />
|
||||
<div className="p-4">
|
||||
<AddTemplateForm />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<aside className="flex flex-col gap-3">
|
||||
<Card>
|
||||
<CardHeader title="Variables disponibles" />
|
||||
<ul className="divide-y divide-line-1">
|
||||
{TEMPLATE_FIELDS.map((field) => (
|
||||
<li key={field.key} className="px-3 py-2">
|
||||
<code className="text-xs text-ink-1">
|
||||
{'{{'}
|
||||
{field.key}
|
||||
{'}}'}
|
||||
</code>
|
||||
<span className="mt-0.5 flex items-center gap-1.5">
|
||||
<span className="text-micro text-ink-3">{field.label}</span>
|
||||
<Badge tone="neutral" subtle>
|
||||
{SCOPE_LABEL[field.scope]}
|
||||
</Badge>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<p className="text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Les variables d’établissement se résolvent dans le contexte de
|
||||
l’établissement porté par le contrat du salarié : la même attestation
|
||||
ne porte pas la même raison sociale d’un magasin à l’autre.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PreferenceBlock } from '@/components/settings/PreferenceForms';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { getPreferences } from '@/server/settings/account';
|
||||
|
||||
export const metadata = { title: 'Réglages de paie · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ReglagesPaiePage() {
|
||||
const preferences = await getPreferences();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Réglages de paie"
|
||||
subtitle="Décompte des heures et matricules"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Décompte" />
|
||||
<PreferenceBlock group="payroll" preferences={preferences} />
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Ces trois réglages changent des chiffres, pas un affichage. Une période
|
||||
déjà verrouillée n’est pas recalculée : le changement vaut pour les
|
||||
périodes ouvertes, et les{' '}
|
||||
<Link href="/paie/periodes" className="underline">
|
||||
instantanés de paie
|
||||
</Link>{' '}
|
||||
antérieurs restent tels qu’ils ont été figés. C’est ce qui garde une paie
|
||||
de mars reproductible après une modification en juin.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import {
|
||||
assignRttPolicyAction,
|
||||
createRttPolicyAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/rtt-policies';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
export function AddPolicyForm() {
|
||||
const [state, action] = useActionState(createRttPolicyAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={action}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Nom"
|
||||
name="name"
|
||||
required
|
||||
maxLength={160}
|
||||
placeholder="RTT cadres"
|
||||
/>
|
||||
<Field
|
||||
label="Jours par an"
|
||||
name="daysPerYear"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max="365"
|
||||
required
|
||||
defaultValue="0"
|
||||
/>
|
||||
<Field
|
||||
label="Début de période"
|
||||
name="periodStart"
|
||||
required
|
||||
placeholder="01-01"
|
||||
pattern="(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])"
|
||||
hint="Format MM-JJ : la période se reconduit d’une année sur l’autre, sans millésime à réécrire."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input type="checkbox" name="autoRenew" defaultChecked className="mt-0.5" />
|
||||
<span>
|
||||
Reconduction automatique
|
||||
<span className="block text-micro text-ink-3">
|
||||
La politique ouvre un nouveau droit à chaque début de période.
|
||||
Archivée, elle cesse de le faire.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Créer la politique</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Politique enregistrée.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssignForm({
|
||||
policyId,
|
||||
candidates,
|
||||
assigned,
|
||||
}: {
|
||||
policyId: string;
|
||||
candidates: Array<{ membershipId: string; name: string }>;
|
||||
assigned: string[];
|
||||
}) {
|
||||
const [state, action] = useActionState(assignRttPolicyAction, empty);
|
||||
const available = candidates.filter(
|
||||
(candidate) => !assigned.includes(candidate.membershipId),
|
||||
);
|
||||
|
||||
if (available.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-ink-3">
|
||||
Tous les salariés actifs sont déjà couverts par cette politique.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PersistentForm
|
||||
action={action}
|
||||
resetAfter={state.ok ? state : null}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<input type="hidden" name="policyId" value={policyId} />
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Salariés à assigner</span>
|
||||
<select
|
||||
name="membershipIds"
|
||||
multiple
|
||||
size={Math.min(8, available.length)}
|
||||
className="rounded-2 border border-line-2 bg-surface px-2 py-1 text-sm text-ink-1"
|
||||
>
|
||||
{available.map((candidate) => (
|
||||
<option key={candidate.membershipId} value={candidate.membershipId}>
|
||||
{candidate.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton size="sm">Assigner</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Affectation enregistrée.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import {
|
||||
AddPolicyForm,
|
||||
AssignForm,
|
||||
} from '@/app/(app)/reglages/politiques-rtt/PolicyForms';
|
||||
import {
|
||||
archiveRttPolicyAction,
|
||||
getPolicyBoard,
|
||||
unassignRttPolicyAction,
|
||||
} from '@/server/settings/rtt-policies';
|
||||
|
||||
export const metadata = { title: 'Politiques de RTT · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** « 01-01 » → « 1er janvier ». */
|
||||
function periodLabel(periodStart: string): string {
|
||||
const [month, day] = periodStart.split('-');
|
||||
if (!month || !day) return periodStart;
|
||||
const date = new Date(Date.UTC(2001, Number(month) - 1, Number(day)));
|
||||
const formatted = new Intl.DateTimeFormat('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
timeZone: 'UTC',
|
||||
}).format(date);
|
||||
return day === '01' ? formatted.replace(/^1 /, '1er ') : formatted;
|
||||
}
|
||||
|
||||
export default async function PolitiquesRttPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ archives?: string }>;
|
||||
}) {
|
||||
const { archives } = await searchParams;
|
||||
const showArchived = archives === '1';
|
||||
const board = await getPolicyBoard(showArchived);
|
||||
const active = board.policies.filter((policy) => policy.status === 'ACTIVE');
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Politiques de RTT"
|
||||
subtitle={`${active.length} politique${active.length > 1 ? 's' : ''} active${active.length > 1 ? 's' : ''}`}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Politiques"
|
||||
action={
|
||||
<a
|
||||
href={
|
||||
showArchived
|
||||
? '/reglages/politiques-rtt'
|
||||
: '/reglages/politiques-rtt?archives=1'
|
||||
}
|
||||
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
|
||||
>
|
||||
{showArchived ? 'Masquer les archivées' : 'Voir les archivées'}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
{board.policies.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucune politique"
|
||||
description="Une politique ouvre un droit à RTT et l’inscrit au registre des compteurs à chaque début de période."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{board.policies.map((policy) => (
|
||||
<li key={policy.id} className="px-4 py-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold">{policy.name}</span>
|
||||
<Badge tone="accent" className="tnum">
|
||||
{policy.daysPerYear} j/an
|
||||
</Badge>
|
||||
<Badge tone="neutral">
|
||||
à partir du {periodLabel(policy.periodStart)}
|
||||
</Badge>
|
||||
{policy.autoRenew ? (
|
||||
<Badge tone="ok">Reconduite</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">Période unique</Badge>
|
||||
)}
|
||||
{policy.status === 'ARCHIVED' ? (
|
||||
<Badge tone="warn">Archivée</Badge>
|
||||
) : null}
|
||||
|
||||
<span className="flex-1" />
|
||||
|
||||
<form action={archiveRttPolicyAction}>
|
||||
<input type="hidden" name="id" value={policy.id} />
|
||||
{policy.status === 'ARCHIVED' ? (
|
||||
<input type="hidden" name="restore" value="1" />
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" type="submit">
|
||||
{policy.status === 'ARCHIVED' ? 'Rétablir' : 'Archiver'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="mb-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
{policy.assignedCount} salarié
|
||||
{policy.assignedCount > 1 ? 's' : ''} couvert
|
||||
{policy.assignedCount > 1 ? 's' : ''}
|
||||
</p>
|
||||
|
||||
{policy.assignees.length > 0 ? (
|
||||
<ul className="mb-3 flex flex-wrap gap-1.5">
|
||||
{policy.assignees.map((assignee) => (
|
||||
<li key={assignee.membershipId}>
|
||||
<form
|
||||
action={unassignRttPolicyAction}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-line-2 bg-surface-3 py-px pr-1 pl-2"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="policyId"
|
||||
value={policy.id}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="membershipId"
|
||||
value={assignee.membershipId}
|
||||
/>
|
||||
<span className="text-micro font-semibold text-ink-2">
|
||||
{assignee.name}
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={`Retirer ${assignee.name} de ${policy.name}`}
|
||||
className="cursor-pointer rounded-full px-1 text-micro text-ink-3 hover:text-danger"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mb-3 text-xs text-ink-3">
|
||||
Aucun salarié assigné : cette politique n’ouvre aucun droit.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{policy.status === 'ACTIVE' ? (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
|
||||
Assigner des employés
|
||||
</summary>
|
||||
<div className="pt-3">
|
||||
<AssignForm
|
||||
policyId={policy.id}
|
||||
candidates={board.candidates}
|
||||
assigned={policy.assignees.map(
|
||||
(assignee) => assignee.membershipId,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Nouvelle politique" />
|
||||
<div className="p-4">
|
||||
<AddPolicyForm />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Archiver arrête la reconduction ; les jours déjà acquis restent au
|
||||
registre des compteurs et les affectations en place. Une politique
|
||||
n’efface pas ce qu’elle a produit — un solde de RTT est un droit ouvert,
|
||||
pas un affichage.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { PreferenceBlock } from '@/components/settings/PreferenceForms';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { getPreferences } from '@/server/settings/account';
|
||||
|
||||
export const metadata = { title: 'Préférences · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function PreferencesPage() {
|
||||
const preferences = await getPreferences();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Préférences"
|
||||
subtitle="Comportement des plannings et droits ouverts aux salariés"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Plannings" />
|
||||
<PreferenceBlock
|
||||
group="planning"
|
||||
preferences={preferences}
|
||||
withEveningTime
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Droits" />
|
||||
<PreferenceBlock group="rights" preferences={preferences} />
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Ces réglages valent pour tout le compte. Ils s’ajoutent aux capacités des
|
||||
rôles sans les remplacer : ouvrir un droit ici ne donne rien à qui n’a
|
||||
pas la capacité correspondante, et le fermer la retire à tout le monde.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import {
|
||||
saveProductivityTargetAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/account';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
export function ProductivityForm({ target }: { target: string | null }) {
|
||||
const [state, action] = useActionState(saveProductivityTargetAction, empty);
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-col gap-3 p-4">
|
||||
<div className="flex max-w-sm items-end gap-2">
|
||||
<Field
|
||||
label="Objectif de productivité"
|
||||
name="target"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={target ?? ''}
|
||||
hint="Chiffre d’affaires attendu par heure travaillée, en euros. Laisser vide pour n’en fixer aucun."
|
||||
/>
|
||||
<span className="pb-2.5 text-sm text-ink-2">€/h</span>
|
||||
</div>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Objectif enregistré.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ProductivityForm } from '@/app/(app)/reglages/productivite/ProductivityForm';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { getPreferences } from '@/server/settings/account';
|
||||
|
||||
export const metadata = { title: 'Productivité · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ProductivitePage() {
|
||||
const preferences = await getPreferences();
|
||||
const target = preferences.productivityTargetPerHour;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Productivité"
|
||||
subtitle={
|
||||
target
|
||||
? `${target} € de chiffre d’affaires par heure travaillée`
|
||||
: 'Aucun objectif fixé'
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Objectif global"
|
||||
badge={
|
||||
target ? null : <Badge tone="neutral">non fixé</Badge>
|
||||
}
|
||||
/>
|
||||
<ProductivityForm target={target} />
|
||||
</Card>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
L’objectif vaut pour l’ensemble des établissements. Vider le champ
|
||||
l’efface plutôt que de le ramener à zéro : zéro serait un objectif
|
||||
inatteignable, l’absence de valeur dit qu’aucun objectif n’a été fixé.
|
||||
</p>
|
||||
|
||||
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
|
||||
Un objectif de productivité rapporte un chiffre d’affaires à des heures
|
||||
planifiées. Il éclaire une décision d’organisation ; il ne mesure pas le
|
||||
travail d’une personne, et n’a pas à être opposé à un salarié.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -28,27 +28,6 @@ export default async function RegistrePage() {
|
||||
subtitle={`${register.approvedCount} paramètre${register.approvedCount > 1 ? 's' : ''} approuvé${register.approvedCount > 1 ? 's' : ''} · ${register.pendingCount} en attente`}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div className="p-4 text-sm leading-[var(--lh-prose)] text-ink-2">
|
||||
<p>
|
||||
Chaque paramètre appliqué par PlanFlow doit porter sa{' '}
|
||||
<strong className="font-semibold text-ink-1">valeur</strong>, sa{' '}
|
||||
<strong className="font-semibold text-ink-1">source</strong>, sa{' '}
|
||||
<strong className="font-semibold text-ink-1">date d’effet</strong>,
|
||||
la <strong className="font-semibold text-ink-1">population</strong>{' '}
|
||||
concernée et un{' '}
|
||||
<strong className="font-semibold text-ink-1">approbateur</strong>.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Recopier la configuration d’un autre logiciel ne suffit pas : sans
|
||||
justification conservée, un paramètre n’est pas opposable en cas de
|
||||
contrôle. Les valeurs de la convention IDCC 1517 déjà chargées dans
|
||||
le moteur restent à recouper avec le texte consolidé et à faire
|
||||
valider par le gestionnaire de paie.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{register.missingDomains.length > 0 ? (
|
||||
<Card className="border-warn">
|
||||
<CardHeader
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import {
|
||||
DATA_DICTIONARY,
|
||||
LEGAL_BASIS_LABELS,
|
||||
} from '@/domain/legal/data-dictionary';
|
||||
import { query } from '@/server/context';
|
||||
|
||||
export const metadata = { title: 'RGPD · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function RgpdPage() {
|
||||
// Les durées ne sont pas écrites dans le dictionnaire : elles viennent des
|
||||
// politiques de conservation, qui se paramètrent et s'auditent. Un artefact
|
||||
// qui afficherait sa propre durée mentirait le jour où la politique change.
|
||||
const policies = await query('settings.access', async (db) =>
|
||||
db.retentionPolicy.findMany({
|
||||
orderBy: [{ objectType: 'asc' }, { effectiveFrom: 'desc' }],
|
||||
select: {
|
||||
objectType: true,
|
||||
durationMonths: true,
|
||||
effectiveFrom: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const durationFor = (objectTypes: string[]): string => {
|
||||
const matches = objectTypes
|
||||
.map((objectType) => {
|
||||
// Une politique précise l'emporte sur une politique générale :
|
||||
// « Document:SICK_NOTE » avant « Document ».
|
||||
const exact = policies.find((policy) => policy.objectType === objectType);
|
||||
if (exact) return exact;
|
||||
const base = objectType.split(':')[0] as string;
|
||||
return policies.find((policy) => policy.objectType === base);
|
||||
})
|
||||
.filter((policy) => policy !== undefined);
|
||||
|
||||
if (matches.length === 0) return 'Non déclarée';
|
||||
|
||||
const months = [...new Set(matches.map((policy) => policy.durationMonths))];
|
||||
return months
|
||||
.sort((a, b) => a - b)
|
||||
.map((count) =>
|
||||
count % 12 === 0 ? `${count / 12} an${count > 12 ? 's' : ''}` : `${count} mois`,
|
||||
)
|
||||
.join(' · ');
|
||||
};
|
||||
|
||||
const undeclared = DATA_DICTIONARY.filter(
|
||||
(entry) => durationFor(entry.objectTypes) === 'Non déclarée',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="RGPD"
|
||||
subtitle="Dictionnaire des données : finalité, base légale, destinataires, durée"
|
||||
/>
|
||||
|
||||
<p
|
||||
role="note"
|
||||
className="max-w-[70ch] rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs leading-[var(--lh-prose)] text-warn-soft-ink"
|
||||
>
|
||||
L’affectation d’une base légale est une qualification juridique. Ce
|
||||
tableau porte la lecture usuelle d’un traitement de paie et de planning ;
|
||||
il doit être confirmé par le responsable de traitement avant d’être
|
||||
opposé à qui que ce soit.
|
||||
</p>
|
||||
|
||||
{undeclared > 0 ? (
|
||||
<p
|
||||
role="status"
|
||||
className="max-w-[70ch] rounded-2 border border-danger bg-danger-soft px-3 py-2 text-xs leading-[var(--lh-prose)] text-danger-soft-ink"
|
||||
>
|
||||
{undeclared} catégorie{undeclared > 1 ? 's' : ''} sans durée de
|
||||
conservation déclarée. Le plan interdit d’appliquer une durée par
|
||||
défaut : déclarez-la dans{' '}
|
||||
<Link href="/reglages/conservation" className="underline">
|
||||
Durées de conservation
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Données traitées"
|
||||
badge={<Badge tone="neutral">{DATA_DICTIONARY.length}</Badge>}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left">
|
||||
{[
|
||||
'Catégorie',
|
||||
'Finalité',
|
||||
'Base légale',
|
||||
'Destinataires',
|
||||
'Durée',
|
||||
].map((heading) => (
|
||||
<th
|
||||
key={heading}
|
||||
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
{heading}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line-1">
|
||||
{DATA_DICTIONARY.map((entry) => {
|
||||
const duration = durationFor(entry.objectTypes);
|
||||
return (
|
||||
<tr key={entry.category}>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<span className="font-medium">{entry.category}</span>
|
||||
{entry.sensitive ? (
|
||||
<Badge tone="danger" className="ml-1.5">
|
||||
Catégorie particulière
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="mt-1 block font-mono text-micro text-ink-3">
|
||||
{entry.objectTypes.join(', ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-[28ch] px-4 py-3 align-top text-ink-2">
|
||||
{entry.purpose}
|
||||
{entry.notes ? (
|
||||
<span className="mt-1 block text-micro text-ink-3">
|
||||
{entry.notes}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
{LEGAL_BASIS_LABELS[entry.basis]}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-ink-2">
|
||||
{entry.recipients.join(', ')}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
{duration === 'Non déclarée' ? (
|
||||
<Badge tone="danger">Non déclarée</Badge>
|
||||
) : (
|
||||
<span className="tnum">{duration}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Droits des personnes" />
|
||||
<div className="flex flex-col gap-2 px-4 py-3 text-sm leading-[var(--lh-prose)] text-ink-2">
|
||||
<p>
|
||||
L’accès, la rectification et l’effacement s’exercent sur le dossier
|
||||
du salarié. L’effacement ne franchit pas les durées de conservation
|
||||
légales : une pièce sous obligation de conservation ne s’efface pas
|
||||
à la demande, et le refus doit être motivé.
|
||||
</p>
|
||||
<p>
|
||||
La portabilité passe par le paquet de départ : export filtré des
|
||||
données du salarié, accompagné de son manifeste et de son empreinte.
|
||||
</p>
|
||||
<p>
|
||||
Toute lecture d’une pièce de santé est journalisée et consultable
|
||||
dans le{' '}
|
||||
<Link href="/rapports/activite" className="underline">
|
||||
journal d’activité
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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,60 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
|
||||
import { asciiFileName, sanitiseFileName } from '@/domain/documents/rules';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { query } from '@/server/context';
|
||||
import { getRegisterData } from '@/server/employees/rup';
|
||||
import { renderRegisterPdf } from '@/server/employees/rup-pdf';
|
||||
|
||||
/**
|
||||
* Téléchargement du registre unique du personnel.
|
||||
*
|
||||
* Une route et non une action : le document est un fichier, pas un écran, et
|
||||
* son édition doit pouvoir être rejouée par une simple adresse — c'est aussi
|
||||
* ce qui permet de la présenter depuis un poste d'inspection.
|
||||
*
|
||||
* L'édition est journalisée. Le registre rassemble l'identité, la nationalité
|
||||
* et le parcours de tout le personnel d'un établissement : savoir qui l'a
|
||||
* extrait, et quand, fait partie de ce qu'on doit pouvoir répondre.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const locationId = request.nextUrl.searchParams.get('etablissement');
|
||||
if (!locationId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Choisissez un établissement.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = await getRegisterData(locationId);
|
||||
if (!data) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Établissement introuvable.' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
await query('members.register.export', async (db, actor) =>
|
||||
recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'members.register.export',
|
||||
entityType: 'Location',
|
||||
entityId: data.locationId,
|
||||
after: { lines: data.people.length, incomplete: data.incomplete },
|
||||
}),
|
||||
);
|
||||
|
||||
const pdf = await renderRegisterPdf(data);
|
||||
const stamp = new Date().toISOString().slice(0, 10).split('-').reverse().join('_');
|
||||
const name = sanitiseFileName(`RUP - ${data.locationName} - ${stamp}.pdf`);
|
||||
|
||||
return new NextResponse(pdf as BodyInit, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${asciiFileName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`,
|
||||
// Un registre se lit à l'instant où il est demandé : le mettre en cache
|
||||
// ferait présenter un effectif périmé au contrôle suivant.
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useActionState, useState, useTransition } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -28,8 +29,12 @@ export function MfaSettings({
|
||||
enrolled: boolean;
|
||||
required: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [offer, setOffer] = useState<OfferState | null>(null);
|
||||
const [starting, startTransition] = useTransition();
|
||||
// Les codes restent à l'écran tant que personne n'a dit les avoir notés : ce
|
||||
// sont eux, et non l'horloge, qui décident du moment où l'on passe à la suite.
|
||||
const [codesNoted, setCodesNoted] = useState(false);
|
||||
|
||||
const [confirmState, confirm, confirming] = useActionState(
|
||||
confirmEnrolmentAction,
|
||||
@@ -43,8 +48,22 @@ export function MfaSettings({
|
||||
const codes = confirmState.recoveryCodes;
|
||||
const active = (enrolled || confirmState.ok) && !disableState.ok;
|
||||
|
||||
if (codes && codes.length > 0) {
|
||||
return <RecoveryCodes codes={codes} />;
|
||||
if (codes && codes.length > 0 && !codesNoted) {
|
||||
return (
|
||||
<RecoveryCodes
|
||||
codes={codes}
|
||||
onNoted={() => {
|
||||
setCodesNoted(true);
|
||||
// Le layout applicatif décide de l'écran d'enrôlement au rendu, et un
|
||||
// layout n'est pas rejoué par la navigation client : sans ce
|
||||
// rafraîchissement, l'écran resterait posé sur toutes les pages, quel
|
||||
// que soit le menu choisi. Il est déclenché ici, et non par l'action
|
||||
// d'enrôlement, pour que les codes ne s'effacent pas avant d'avoir été
|
||||
// notés.
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (active) {
|
||||
@@ -193,7 +212,13 @@ export function MfaSettings({
|
||||
* Ils sont conservés hachés : ni le support ni un administrateur ne peuvent les
|
||||
* relire. La seule issue, ensuite, est d'en régénérer une série.
|
||||
*/
|
||||
function RecoveryCodes({ codes }: { codes: string[] }) {
|
||||
function RecoveryCodes({
|
||||
codes,
|
||||
onNoted,
|
||||
}: {
|
||||
codes: string[];
|
||||
onNoted: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<p className="text-sm text-ink-1">
|
||||
@@ -214,6 +239,11 @@ function RecoveryCodes({ codes }: { codes: string[] }) {
|
||||
Affichés une seule fois : ils ne sont pas conservés en clair. Chacun ne
|
||||
sert qu’une fois.
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="primary" onClick={onNoted}>
|
||||
J’ai noté mes codes, continuer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||
import {
|
||||
minutesToTime,
|
||||
togglesOf,
|
||||
type PreferenceGroup,
|
||||
} from '@/domain/settings/preferences';
|
||||
import {
|
||||
savePreferencesAction,
|
||||
type ActionState,
|
||||
type Preferences,
|
||||
} from '@/server/settings/account';
|
||||
|
||||
const empty: ActionState = {};
|
||||
|
||||
/**
|
||||
* Un bloc de préférences.
|
||||
*
|
||||
* Chaque formulaire déclare les clés qu'il gouverne dans des champs `scope`.
|
||||
* Sans eux, enregistrer « Plannings » remettrait à faux tout « Droits » : une
|
||||
* case décochée n'arrive pas dans le `FormData`, et le serveur ne saurait pas
|
||||
* distinguer « décoché » de « pas dans ce formulaire ».
|
||||
*/
|
||||
export function PreferenceBlock({
|
||||
group,
|
||||
preferences,
|
||||
withEveningTime = false,
|
||||
}: {
|
||||
group: PreferenceGroup;
|
||||
preferences: Preferences;
|
||||
withEveningTime?: boolean;
|
||||
}) {
|
||||
const [state, action] = useActionState(savePreferencesAction, empty);
|
||||
const toggles = togglesOf(group);
|
||||
|
||||
return (
|
||||
<PersistentForm action={action} className="flex flex-col gap-4 px-4 py-4">
|
||||
{toggles.map((toggle) => (
|
||||
<input key={`scope-${toggle.key}`} type="hidden" name="scope" value={toggle.key} />
|
||||
))}
|
||||
|
||||
{toggles.map((toggle) => (
|
||||
<div key={toggle.key} className="flex flex-col gap-1">
|
||||
<label className="flex items-start gap-2.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={toggle.key}
|
||||
defaultChecked={Boolean(
|
||||
preferences[toggle.key as keyof Preferences],
|
||||
)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{toggle.label}</span>
|
||||
<span className="block text-micro leading-[var(--lh-prose)] text-ink-3">
|
||||
{toggle.hint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{toggle.warning ? (
|
||||
<p className="ml-6 rounded-2 border border-warn bg-warn-soft px-2.5 py-1.5 text-micro leading-[var(--lh-prose)] text-warn-soft-ink">
|
||||
{toggle.warning}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{withEveningTime ? (
|
||||
<label className="flex max-w-xs flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
Un créneau est « du soir » à partir de
|
||||
</span>
|
||||
<input
|
||||
type="time"
|
||||
name="eveningShiftStart"
|
||||
defaultValue={minutesToTime(preferences.eveningShiftStartMinutes)}
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1"
|
||||
/>
|
||||
<span className="text-micro text-ink-3">
|
||||
Sert au repérage à l’écran et aux majorations de soirée, pas au
|
||||
travail de nuit, qui relève de la convention.
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Réglages enregistrés.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</PersistentForm>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Registre unique du personnel — articles L1221-13 et D1221-23 du code du
|
||||
* travail.
|
||||
*
|
||||
* Tenu par établissement et non par entreprise : c'est l'inspection d'un
|
||||
* établissement donné qui le réclame, et un registre global ne répondrait pas
|
||||
* à la demande.
|
||||
*
|
||||
* Les mentions sont fixées par le texte. Elles sont énumérées ici plutôt que
|
||||
* dispersées dans le rendu, pour qu'une relecture juridique porte sur un seul
|
||||
* endroit.
|
||||
*/
|
||||
|
||||
export interface RupPerson {
|
||||
lastName: string;
|
||||
firstName: string;
|
||||
/** Mention exigée. Non collectée par PlanFlow à ce jour. */
|
||||
sex: string | null;
|
||||
nationality: string | null;
|
||||
birthDate: Date | null;
|
||||
/** Emploi tenu, tel que porté par le contrat. */
|
||||
jobTitle: string | null;
|
||||
qualification: string | null;
|
||||
contractLabel: string | null;
|
||||
entryDate: Date | null;
|
||||
exitDate: Date | null;
|
||||
}
|
||||
|
||||
export interface RupColumn {
|
||||
key: keyof RupPerson;
|
||||
label: string;
|
||||
/** Part de la largeur utile. La somme vaut 1. */
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'ordre suit celui du texte réglementaire : identité, puis situation, puis
|
||||
* dates. Un registre réordonné se relit mal en regard de l'article.
|
||||
*/
|
||||
export const RUP_COLUMNS: RupColumn[] = [
|
||||
{ key: 'lastName', label: 'Nom', width: 0.13 },
|
||||
{ key: 'firstName', label: 'Prénom', width: 0.11 },
|
||||
{ key: 'sex', label: 'Sexe', width: 0.05 },
|
||||
{ key: 'nationality', label: 'Nationalité', width: 0.1 },
|
||||
{ key: 'birthDate', label: 'Date de naissance', width: 0.1 },
|
||||
{ key: 'jobTitle', label: 'Emploi', width: 0.14 },
|
||||
{ key: 'qualification', label: 'Qualification', width: 0.11 },
|
||||
{ key: 'contractLabel', label: 'Contrat', width: 0.08 },
|
||||
{ key: 'entryDate', label: 'Entrée', width: 0.09 },
|
||||
{ key: 'exitDate', label: 'Sortie', width: 0.09 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Mentions obligatoires manquantes pour une personne.
|
||||
*
|
||||
* La date de sortie est exclue : son absence signifie que le salarié est
|
||||
* toujours présent, ce qui est une information et non un oubli.
|
||||
*/
|
||||
export function missingMentions(person: RupPerson): string[] {
|
||||
return RUP_COLUMNS.filter((column) => {
|
||||
if (column.key === 'exitDate') return false;
|
||||
const value = person[column.key];
|
||||
return value === null || value === '';
|
||||
}).map((column) => column.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Une contravention par salarié concerné, et non une par registre : c'est ce
|
||||
* qui justifie de compter les personnes plutôt que les champs.
|
||||
*/
|
||||
export function peopleWithGaps(people: RupPerson[]): number {
|
||||
return people.filter((person) => missingMentions(person).length > 0).length;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import { counterView, type LedgerEntry } from '@/domain/absences/ledger';
|
||||
import { monthDates, monthLabel, type Month } from '@/domain/planning/month';
|
||||
@@ -71,6 +73,103 @@ export function statusLabel(status: AbsenceRequest['status']): string {
|
||||
return STATUS_LABEL[status];
|
||||
}
|
||||
|
||||
export interface MemberAbsences {
|
||||
requests: AbsenceRequest[];
|
||||
counters: CounterSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Absences d'un salarié — onglet de sa fiche.
|
||||
*
|
||||
* Le tableau mensuel répond à « qui est absent ce mois-ci » ; il ne répond pas
|
||||
* à « qu'a pris ce salarié depuis son entrée ». D'où cette lecture, bornée à
|
||||
* une personne et non à un mois.
|
||||
*
|
||||
* Rend `null` plutôt que de lever quand le dossier consulté n'est pas le sien
|
||||
* et que la capacité manque : l'onglet se retire, le reste de la fiche tient.
|
||||
*/
|
||||
export const listMemberAbsences = cache(async function listMemberAbsences(
|
||||
membershipId: string,
|
||||
): Promise<MemberAbsences | null> {
|
||||
return query('timeoff.view_own', async (db, actor) => {
|
||||
if (
|
||||
membershipId !== actor.membershipId &&
|
||||
!can(actor, 'timeoff.view_others')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canSeeMedical = can(actor, 'members.documents.view');
|
||||
|
||||
const member = await db.membership.findUnique({
|
||||
where: { id: membershipId },
|
||||
select: {
|
||||
employeeNumber: true,
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
if (!member) return null;
|
||||
|
||||
const name =
|
||||
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim();
|
||||
|
||||
const timeOffs = await db.timeOff.findMany({
|
||||
where: { membershipId },
|
||||
include: { absenceType: true },
|
||||
orderBy: { startDate: 'desc' },
|
||||
});
|
||||
|
||||
const counters = await db.counter.findMany({
|
||||
where: { membershipId },
|
||||
include: { operations: true },
|
||||
});
|
||||
|
||||
return {
|
||||
requests: timeOffs.map((entry) => ({
|
||||
id: entry.id,
|
||||
membershipId: entry.membershipId,
|
||||
name,
|
||||
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,
|
||||
})),
|
||||
counters: counters.map((counter) => {
|
||||
const entries: LedgerEntry[] = counter.operations.map((operation) => ({
|
||||
kind: operation.kind,
|
||||
quantity: Number(operation.quantity.toString()),
|
||||
unit: operation.unit,
|
||||
effectiveDate: operation.effectiveDate.toISOString().slice(0, 10),
|
||||
}));
|
||||
const view = counterView(entries, 0);
|
||||
return {
|
||||
membershipId: counter.membershipId,
|
||||
name,
|
||||
counterType: counter.counterType,
|
||||
accrued: view.accrued,
|
||||
taken: view.taken,
|
||||
balance: view.balance,
|
||||
projected: view.projected,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
export async function getAbsenceBoard(month: Month): Promise<AbsenceBoard> {
|
||||
return query('timeoff.view_own', async (db, actor) => {
|
||||
const canSeeOthers = can(actor, 'timeoff.view_others');
|
||||
@@ -223,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[],
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
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,18 +28,56 @@ 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(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Crée un salarié.
|
||||
* Contrat posé au moment de l'embauche.
|
||||
*
|
||||
* Facultatif dans le formulaire, mais tout ou rien une fois entamé : un
|
||||
* établissement sans date de début produirait un contrat qu'aucune règle ne
|
||||
* sait valider.
|
||||
*/
|
||||
const hiringContractInput = z.object({
|
||||
locationId: z.string().min(1, 'Établissement requis'),
|
||||
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(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Crée un salarié, et l'embauche s'il y a lieu.
|
||||
*
|
||||
* Un salarié **sans compte utilisateur** doit rester créable : la plupart des
|
||||
* équipes de vente ne se connectent jamais à l'outil, et exiger une adresse
|
||||
* électronique les rendrait impossibles à planifier ou à déclarer.
|
||||
*
|
||||
* Le contrat et l'équipe sont posés dans la **même transaction** que le
|
||||
* dossier. Créés séparément, un incident au milieu laisserait un salarié sans
|
||||
* contrat — c'est-à-dire un dossier qu'aucun écran ne permettait de rattraper.
|
||||
*/
|
||||
export async function createEmployeeAction(
|
||||
_previous: ActionState,
|
||||
@@ -46,32 +85,130 @@ 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é.
|
||||
const wantsContract = formData.get('withContract') === 'on';
|
||||
let contract: z.infer<typeof hiringContractInput> | null = null;
|
||||
let contractEnd: Date | null = null;
|
||||
let forfaitAgreedAt: Date | null = null;
|
||||
|
||||
if (wantsContract) {
|
||||
const parsedContract = hiringContractInput.safeParse({
|
||||
locationId: formData.get('locationId') ?? '',
|
||||
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,
|
||||
forfaitDaysPerYear: formData.get('forfaitDaysPerYear') || undefined,
|
||||
forfaitAgreementRef: formData.get('forfaitAgreementRef') ?? '',
|
||||
forfaitAgreedAt: formData.get('forfaitAgreedAt') ?? '',
|
||||
});
|
||||
|
||||
if (!parsedContract.success) {
|
||||
return {
|
||||
error:
|
||||
parsedContract.error.issues[0]?.message ?? 'Contrat invalide',
|
||||
};
|
||||
}
|
||||
|
||||
contract = parsedContract.data;
|
||||
contractEnd = contract.endDate ? new Date(contract.endDate) : null;
|
||||
forfaitAgreedAt = contract.forfaitAgreedAt
|
||||
? new Date(contract.forfaitAgreedAt)
|
||||
: null;
|
||||
|
||||
const issues = validateContract({
|
||||
startDate: contract.startDate,
|
||||
endDate: contractEnd,
|
||||
workTimeArrangement: contract.workTimeArrangement,
|
||||
weeklyHours: contract.weeklyHours,
|
||||
forfaitDaysPerYear: contract.forfaitDaysPerYear ?? null,
|
||||
forfaitAgreementRef: contract.forfaitAgreementRef || null,
|
||||
forfaitAgreedAt,
|
||||
});
|
||||
|
||||
if (issues.length > 0) {
|
||||
return { error: issues[0]?.message ?? 'Contrat invalide' };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('members.create', async (db, actor) => {
|
||||
// Poser un contrat est une capacité distincte de celle de créer un
|
||||
// dossier : un gestionnaire d'annuaire n'engage pas l'entreprise.
|
||||
if (contract && !can(actor, 'members.contract.create')) {
|
||||
throw new AuthorizationError('members.contract.create');
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -80,10 +217,89 @@ 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,
|
||||
});
|
||||
|
||||
if (contract) {
|
||||
const location = await db.location.findUnique({
|
||||
where: { id: contract.locationId },
|
||||
});
|
||||
if (!location) {
|
||||
throw new ValidationError('Établissement introuvable.');
|
||||
}
|
||||
|
||||
const openedContract = await db.userContract.create({
|
||||
data: {
|
||||
membershipId: created.id,
|
||||
locationId: location.id,
|
||||
contractType: contract.contractType,
|
||||
startDate: contract.startDate,
|
||||
startTime: contract.startTime,
|
||||
endDate: contractEnd,
|
||||
workTimeArrangement: contract.workTimeArrangement,
|
||||
weeklyHours: contract.weeklyHours,
|
||||
forfaitDaysPerYear: contract.forfaitDaysPerYear ?? null,
|
||||
forfaitAgreementRef: contract.forfaitAgreementRef || null,
|
||||
forfaitAgreedAt,
|
||||
} as never,
|
||||
});
|
||||
|
||||
// Sans équipe, le salarié n'apparaît sur aucune grille : le planning
|
||||
// s'ordonne par équipe, et un rattachement omis se paie à la première
|
||||
// semaine à couvrir.
|
||||
if (contract.teamId) {
|
||||
const team = await db.team.findUnique({
|
||||
where: { id: contract.teamId },
|
||||
select: { id: true, locationId: true },
|
||||
});
|
||||
if (!team || team.locationId !== location.id) {
|
||||
throw new ValidationError(
|
||||
'Cette équipe n’appartient pas à l’établissement choisi.',
|
||||
);
|
||||
}
|
||||
await db.teamMember.create({
|
||||
data: { teamId: team.id, membershipId: created.id } as never,
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
entityType: 'UserContract',
|
||||
entityId: openedContract.id,
|
||||
after: {
|
||||
contractType: openedContract.contractType,
|
||||
startDate: openedContract.startDate.toISOString(),
|
||||
workTimeArrangement: openedContract.workTimeArrangement,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'membership.create',
|
||||
@@ -92,13 +308,19 @@ export async function createEmployeeAction(
|
||||
after: {
|
||||
employeeNumber: created.employeeNumber,
|
||||
hasAccount: Boolean(parsed.data.email),
|
||||
withContract: Boolean(contract),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de créer un salarié." };
|
||||
return {
|
||||
error:
|
||||
error.permission === 'members.contract.create'
|
||||
? "Vous n'avez pas le droit d'ouvrir un contrat. Créez le salarié sans contrat, ou faites-le poser."
|
||||
: "Vous n'avez pas le droit de créer un salarié.",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -329,3 +551,87 @@ export async function createAmendmentAction(
|
||||
if (membershipId) revalidatePath(`/equipe/${membershipId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const endContractInput = z.object({
|
||||
contractId: z.string().min(1),
|
||||
endDate: z.coerce.date(),
|
||||
endReason: z.string().trim().min(1, 'Motif de fin requis').max(300),
|
||||
});
|
||||
|
||||
/**
|
||||
* Termine un contrat.
|
||||
*
|
||||
* Le contrat n'est pas supprimé : il est **daté et clos**. Un contrôle porte
|
||||
* sur une période révolue, et effacer un contrat terminé effacerait la preuve
|
||||
* que le salarié a travaillé — ainsi que la base de son solde de tout compte.
|
||||
*/
|
||||
export async function endContractAction(
|
||||
_previous: ActionState,
|
||||
formData: FormData,
|
||||
): Promise<ActionState> {
|
||||
const parsed = endContractInput.safeParse({
|
||||
contractId: formData.get('contractId'),
|
||||
endDate: formData.get('endDate'),
|
||||
endReason: formData.get('endReason'),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
let membershipId = '';
|
||||
|
||||
try {
|
||||
await mutate('members.contract.edit', async (db, actor) => {
|
||||
const contract = await db.userContract.findUnique({
|
||||
where: { id: parsed.data.contractId },
|
||||
});
|
||||
if (!contract) throw new AuthorizationError('members.contract.edit');
|
||||
membershipId = contract.membershipId;
|
||||
|
||||
if (contract.status === 'ENDED') {
|
||||
throw new ValidationError('Ce contrat est déjà terminé.');
|
||||
}
|
||||
if (parsed.data.endDate < contract.startDate) {
|
||||
throw new ValidationError(
|
||||
'La fin du contrat précède son début.',
|
||||
);
|
||||
}
|
||||
|
||||
await db.userContract.update({
|
||||
where: { id: contract.id },
|
||||
data: {
|
||||
endDate: parsed.data.endDate,
|
||||
endReason: parsed.data.endReason,
|
||||
status: 'ENDED',
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'contract.end',
|
||||
entityType: 'UserContract',
|
||||
entityId: contract.id,
|
||||
before: { status: contract.status, endDate: contract.endDate?.toISOString() ?? null },
|
||||
after: {
|
||||
status: 'ENDED',
|
||||
endDate: parsed.data.endDate.toISOString(),
|
||||
},
|
||||
reason: parsed.data.endReason,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de terminer un contrat." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (membershipId) {
|
||||
revalidatePath(`/equipe/${membershipId}`);
|
||||
revalidatePath('/equipe');
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Rattachement et périmètre.
|
||||
*
|
||||
* Deux capacités distinctes, parce que ce sont deux décisions distinctes :
|
||||
*
|
||||
* - le **périmètre** dit ce qu'une personne voit du compte. L'élargir revient à
|
||||
* lui donner accès à des dossiers et des plannings qu'elle n'avait pas :
|
||||
* c'est distribuer des droits, donc `settings.roles.manage` ;
|
||||
* - le **rattachement** dit sur quelle grille elle apparaît. C'est de
|
||||
* l'organisation du travail, donc `settings.teams.manage`.
|
||||
*
|
||||
* L'établissement du contrat n'est modifiable ni par l'une ni par l'autre : il
|
||||
* est porté par le contrat, et ne se change que par avenant.
|
||||
*/
|
||||
|
||||
export interface PlacementActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
class ValidationError extends Error {}
|
||||
|
||||
const scopeInput = z.object({
|
||||
membershipId: z.string().min(1),
|
||||
allLocations: z.boolean(),
|
||||
locationIds: z.array(z.string().min(1)),
|
||||
});
|
||||
|
||||
export async function updateScopeAction(
|
||||
_previous: PlacementActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlacementActionState> {
|
||||
const parsed = scopeInput.safeParse({
|
||||
membershipId: formData.get('membershipId') ?? '',
|
||||
allLocations: formData.get('allLocations') === 'on',
|
||||
locationIds: formData.getAll('locationIds').map(String),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const { membershipId, allLocations, locationIds } = parsed.data;
|
||||
|
||||
if (!allLocations && locationIds.length === 0) {
|
||||
return {
|
||||
error:
|
||||
'Sans accès généralisé, désignez au moins un établissement — sinon cette personne ne verrait plus rien.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.roles.manage', async (db, actor) => {
|
||||
const membership = await db.membership.findUnique({
|
||||
where: { id: membershipId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!membership) throw new ValidationError('Salarié introuvable.');
|
||||
|
||||
const known = await db.location.findMany({
|
||||
where: { id: { in: locationIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (known.length !== locationIds.length) {
|
||||
throw new ValidationError('Établissement inconnu.');
|
||||
}
|
||||
|
||||
const before = await db.membershipScope.findMany({
|
||||
where: { membershipId },
|
||||
select: { allLocations: true, locationId: true },
|
||||
});
|
||||
|
||||
// Remplacement et non fusion : un périmètre qui ne fait que grandir ne
|
||||
// se réduit jamais, et le retrait d'un établissement resterait sans
|
||||
// effet — l'exact contraire de ce que l'écran promet.
|
||||
await db.membershipScope.deleteMany({ where: { membershipId } });
|
||||
|
||||
if (allLocations) {
|
||||
await db.membershipScope.create({
|
||||
data: { membershipId, allLocations: true } as never,
|
||||
});
|
||||
} else {
|
||||
for (const locationId of locationIds) {
|
||||
await db.membershipScope.create({
|
||||
data: { membershipId, allLocations: false, locationId } as never,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'membership.scope.update',
|
||||
entityType: 'Membership',
|
||||
entityId: membershipId,
|
||||
before: { scopes: before },
|
||||
after: { allLocations, locationIds },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) {
|
||||
return {
|
||||
error:
|
||||
'Modifier un périmètre revient à distribuer des droits : la capacité « Gérer les rôles » est requise.',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath(`/equipe/${membershipId}/planification`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const teamsInput = z.object({
|
||||
membershipId: z.string().min(1),
|
||||
teamIds: z.array(z.string().min(1)),
|
||||
primaryTeamId: z.string().trim(),
|
||||
});
|
||||
|
||||
export async function updateTeamsAction(
|
||||
_previous: PlacementActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlacementActionState> {
|
||||
const parsed = teamsInput.safeParse({
|
||||
membershipId: formData.get('membershipId') ?? '',
|
||||
teamIds: formData.getAll('teamIds').map(String),
|
||||
primaryTeamId: formData.get('primaryTeamId') ?? '',
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const { membershipId, teamIds, primaryTeamId } = parsed.data;
|
||||
|
||||
if (primaryTeamId && !teamIds.includes(primaryTeamId)) {
|
||||
return {
|
||||
error: 'L’équipe principale doit figurer parmi les équipes cochées.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.teams.manage', async (db, actor) => {
|
||||
const known = await db.team.findMany({
|
||||
where: { id: { in: teamIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (known.length !== teamIds.length) {
|
||||
throw new ValidationError('Équipe inconnue.');
|
||||
}
|
||||
|
||||
const before = await db.teamMember.findMany({
|
||||
where: { membershipId },
|
||||
select: { teamId: true, isPrimary: true },
|
||||
});
|
||||
|
||||
await db.teamMember.deleteMany({ where: { membershipId } });
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
await db.teamMember.create({
|
||||
data: {
|
||||
teamId,
|
||||
membershipId,
|
||||
// Une seule principale : c'est elle qui décide de la colonne où la
|
||||
// personne apparaît quand la grille n'en montre qu'une.
|
||||
isPrimary: teamId === (primaryTeamId || teamIds[0]),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'membership.teams.update',
|
||||
entityType: 'Membership',
|
||||
entityId: membershipId,
|
||||
before: { teams: before },
|
||||
after: { teamIds, primaryTeamId: primaryTeamId || teamIds[0] || null },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) {
|
||||
return {
|
||||
error: 'La capacité « Gérer les équipes » est requise.',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath(`/equipe/${membershipId}/planification`);
|
||||
revalidatePath('/equipe');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError, can } from '@/domain/access/authorize';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
import { encryptOptional } from '@/server/crypto';
|
||||
|
||||
/**
|
||||
* Saisie du dossier personnel — matrice n° 1 et registre unique du personnel.
|
||||
*
|
||||
* Deux points d'écriture et non un seul. L'état civil et les coordonnées se
|
||||
* modifient avec `members.edit` ; le NIR, l'IBAN et le BIC exigent en plus de
|
||||
* pouvoir les **lire**. Les fondre dans une seule action laisserait un profil
|
||||
* habilité à modifier mais pas à lire renvoyer des champs vides — et effacer
|
||||
* ainsi ce qui ne lui avait jamais été montré.
|
||||
*/
|
||||
|
||||
export interface ProfileActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
class ValidationError extends Error {}
|
||||
|
||||
/** Un champ laissé vide vaut « non renseigné », pas la chaîne vide. */
|
||||
const optionalText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.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()
|
||||
.max(180)
|
||||
.refine(
|
||||
(value) => value === '' || z.string().email().safeParse(value).success,
|
||||
'Adresse électronique invalide',
|
||||
)
|
||||
.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),
|
||||
emergencyContactName: optionalText(120),
|
||||
emergencyContactPhone: optionalText(30),
|
||||
});
|
||||
|
||||
export async function updateProfileAction(
|
||||
_previous: ProfileActionState,
|
||||
formData: FormData,
|
||||
): Promise<ProfileActionState> {
|
||||
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',
|
||||
'emergencyContactName',
|
||||
'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' };
|
||||
}
|
||||
|
||||
const { membershipId, birthDate, ...rest } = parsed.data;
|
||||
|
||||
// Une date de naissance postérieure à aujourd'hui n'est pas une faute de
|
||||
// frappe anodine : elle fausse l'âge, et l'âge commande l'emploi des mineurs.
|
||||
let parsedBirthDate: Date | null = null;
|
||||
if (birthDate) {
|
||||
const candidate = new Date(`${birthDate}T00:00:00Z`);
|
||||
if (Number.isNaN(candidate.getTime()) || candidate > new Date()) {
|
||||
return { error: 'Date de naissance invalide.' };
|
||||
}
|
||||
parsedBirthDate = candidate;
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('members.edit', async (db, actor) => {
|
||||
const existing = await db.employeeProfile.findUnique({
|
||||
where: { membershipId },
|
||||
select: { firstName: true, lastName: true },
|
||||
});
|
||||
if (!existing) throw new ValidationError('Dossier introuvable.');
|
||||
|
||||
await db.employeeProfile.update({
|
||||
where: { membershipId },
|
||||
data: { ...rest, birthDate: parsedBirthDate } as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'membership.profile.update',
|
||||
entityType: 'EmployeeProfile',
|
||||
entityId: membershipId,
|
||||
before: existing,
|
||||
after: { firstName: rest.firstName, lastName: rest.lastName },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: 'Vous n’avez pas le droit de modifier ce dossier.' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/equipe');
|
||||
revalidatePath(`/equipe/${membershipId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const sensitiveInput = z.object({
|
||||
membershipId: z.string().min(1),
|
||||
socialSecurityNumber: optionalText(20),
|
||||
iban: optionalText(40),
|
||||
bic: optionalText(15),
|
||||
});
|
||||
|
||||
/**
|
||||
* Écrit les trois champs chiffrés au repos.
|
||||
*
|
||||
* Le NIR est contrôlé sur sa forme — quinze chiffres — et non sur sa clé : un
|
||||
* refus fondé sur un calcul que le saisisseur ne peut pas refaire à la main
|
||||
* bloquerait des dossiers valides sans jamais dire lesquels.
|
||||
*/
|
||||
export async function updateSensitiveAction(
|
||||
_previous: ProfileActionState,
|
||||
formData: FormData,
|
||||
): Promise<ProfileActionState> {
|
||||
const parsed = sensitiveInput.safeParse({
|
||||
membershipId: formData.get('membershipId') ?? '',
|
||||
socialSecurityNumber: formData.get('socialSecurityNumber') ?? '',
|
||||
iban: formData.get('iban') ?? '',
|
||||
bic: formData.get('bic') ?? '',
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const { membershipId } = parsed.data;
|
||||
const nir = parsed.data.socialSecurityNumber?.replace(/\s/g, '') ?? null;
|
||||
const iban = parsed.data.iban?.replace(/\s/g, '').toUpperCase() ?? null;
|
||||
const bic = parsed.data.bic?.replace(/\s/g, '').toUpperCase() ?? null;
|
||||
|
||||
if (nir && !/^\d{15}$/.test(nir)) {
|
||||
return { error: 'Le numéro de sécurité sociale compte quinze chiffres.' };
|
||||
}
|
||||
if (iban && !/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(iban)) {
|
||||
return { error: 'IBAN invalide.' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('members.edit', async (db, actor) => {
|
||||
// La capacité de lecture est exigée pour écrire : sans elle, le
|
||||
// formulaire n'aurait pas pu afficher la valeur en place, et l'envoyer
|
||||
// reviendrait à écraser à l'aveugle.
|
||||
if (!can(actor, 'members.documents.view')) {
|
||||
throw new AuthorizationError('members.documents.view');
|
||||
}
|
||||
|
||||
await db.employeeProfile.update({
|
||||
where: { membershipId },
|
||||
data: {
|
||||
socialSecurityNumberEnc: encryptOptional(nir),
|
||||
ibanEnc: encryptOptional(iban),
|
||||
bicEnc: encryptOptional(bic),
|
||||
} as never,
|
||||
});
|
||||
|
||||
// Le journal ne retient que **ce qui a été renseigné**, jamais la valeur :
|
||||
// il se relit, s'exporte et se conserve longtemps.
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'membership.profile.sensitive.update',
|
||||
entityType: 'EmployeeProfile',
|
||||
entityId: membershipId,
|
||||
after: {
|
||||
socialSecurityNumber: nir ? 'renseigné' : 'effacé',
|
||||
iban: iban ? 'renseigné' : 'effacé',
|
||||
bic: bic ? 'renseigné' : 'effacé',
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) {
|
||||
return {
|
||||
error: 'Vous n’avez pas le droit de modifier ces données protégées.',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath(`/equipe/${membershipId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
+520
-34
@@ -1,3 +1,5 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import {
|
||||
invitationState,
|
||||
@@ -21,6 +23,8 @@ export interface EmployeeListRow {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string | null;
|
||||
/** Téléphone du dossier : la colonne « Mobile » de l'annuaire. */
|
||||
phone: string | null;
|
||||
status: string;
|
||||
hasAccount: boolean;
|
||||
roleName: string;
|
||||
@@ -32,6 +36,36 @@ export interface EmployeeListRow {
|
||||
forfaitJours: boolean;
|
||||
} | null;
|
||||
locationName: string | null;
|
||||
teamName: string | null;
|
||||
invitationState: InvitationState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Critères de l'annuaire.
|
||||
*
|
||||
* Tous facultatifs, et tous appliqués **en base** plutôt qu'après lecture :
|
||||
* un effectif de plusieurs centaines de dossiers ne se filtre pas en mémoire à
|
||||
* chaque affichage.
|
||||
*/
|
||||
export interface EmployeeFilters {
|
||||
/** Prénom, nom ou matricule. */
|
||||
q?: string;
|
||||
locationId?: string;
|
||||
roleId?: string;
|
||||
contractType?: string;
|
||||
/** `active` par défaut : un archivé n'a rien à faire dans l'effectif courant. */
|
||||
presence?: 'active' | 'archived' | 'all';
|
||||
sort?: 'name' | 'number';
|
||||
}
|
||||
|
||||
export interface EmployeeDirectory {
|
||||
rows: EmployeeListRow[];
|
||||
/** De quoi peupler les listes déroulantes, sans seconde requête à l'écran. */
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
roles: Array<{ id: string; name: string }>;
|
||||
contractTypes: Array<{ value: string; label: string }>;
|
||||
/** Effectif total, avant filtres : « 12 sur 87 » se lit autrement que « 12 ». */
|
||||
total: number;
|
||||
}
|
||||
|
||||
const CONTRACT_LABELS: Record<string, string> = {
|
||||
@@ -50,22 +84,104 @@ export function contractLabel(type: string): string {
|
||||
return CONTRACT_LABELS[type] ?? type;
|
||||
}
|
||||
|
||||
export async function listEmployees(): Promise<EmployeeListRow[]> {
|
||||
export async function listEmployees(
|
||||
filters: EmployeeFilters = {},
|
||||
): Promise<EmployeeDirectory> {
|
||||
return query('members.view', async (db) => {
|
||||
const memberships = await db.membership.findMany({
|
||||
where: { archivedAt: null },
|
||||
include: {
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
user: { select: { email: true } },
|
||||
role: { select: { name: true } },
|
||||
contracts: {
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { startDate: 'desc' },
|
||||
take: 1,
|
||||
const now = new Date();
|
||||
const search = filters.q?.trim();
|
||||
|
||||
// Un archivé n'appartient plus à l'effectif : il faut le demander.
|
||||
const presence =
|
||||
filters.presence === 'all'
|
||||
? {}
|
||||
: filters.presence === 'archived'
|
||||
? { archivedAt: { not: null } }
|
||||
: { archivedAt: null };
|
||||
|
||||
// L'établissement et le type de contrat sont portés par le contrat en
|
||||
// cours, pas par le membership : le filtre passe donc par la relation.
|
||||
const contractFilter =
|
||||
filters.locationId || filters.contractType
|
||||
? {
|
||||
contracts: {
|
||||
some: {
|
||||
status: 'ACTIVE' as const,
|
||||
...(filters.locationId
|
||||
? { locationId: filters.locationId }
|
||||
: {}),
|
||||
...(filters.contractType
|
||||
? { contractType: filters.contractType as never }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
const searchFilter = search
|
||||
? {
|
||||
OR: [
|
||||
{ employeeNumber: { contains: search, mode: 'insensitive' as const } },
|
||||
{
|
||||
profile: {
|
||||
is: {
|
||||
OR: [
|
||||
{ firstName: { contains: search, mode: 'insensitive' as const } },
|
||||
{ lastName: { contains: search, mode: 'insensitive' as const } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const where = {
|
||||
...presence,
|
||||
...contractFilter,
|
||||
...searchFilter,
|
||||
...(filters.roleId ? { roleId: filters.roleId } : {}),
|
||||
};
|
||||
|
||||
const [memberships, total, locations, roles] = await Promise.all([
|
||||
db.membership.findMany({
|
||||
where,
|
||||
include: {
|
||||
profile: {
|
||||
select: { firstName: true, lastName: true, phone: true },
|
||||
},
|
||||
user: { select: { email: true } },
|
||||
role: { select: { name: true } },
|
||||
teams: {
|
||||
select: {
|
||||
isPrimary: true,
|
||||
team: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
contracts: {
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { startDate: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
invitations: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: { expiresAt: true, acceptedAt: true, revokedAt: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { employeeNumber: 'asc' },
|
||||
});
|
||||
orderBy: { employeeNumber: 'asc' },
|
||||
}),
|
||||
db.membership.count({ where: { archivedAt: null } }),
|
||||
db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
db.role.findMany({
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const locationIds = [
|
||||
...new Set(
|
||||
@@ -74,20 +190,28 @@ export async function listEmployees(): Promise<EmployeeListRow[]> {
|
||||
.map((contract) => contract.locationId),
|
||||
),
|
||||
];
|
||||
const locations = await db.location.findMany({
|
||||
const contractLocations = await db.location.findMany({
|
||||
where: { id: { in: locationIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const locationNames = new Map(locations.map((l) => [l.id, l.name]));
|
||||
const locationNames = new Map(
|
||||
contractLocations.map((entry) => [entry.id, entry.name]),
|
||||
);
|
||||
|
||||
return memberships.map((membership) => {
|
||||
const rows: EmployeeListRow[] = memberships.map((membership) => {
|
||||
const contract = membership.contracts[0];
|
||||
const team =
|
||||
membership.teams.find((entry) => entry.isPrimary) ??
|
||||
membership.teams[0];
|
||||
const invitation = membership.invitations[0];
|
||||
|
||||
return {
|
||||
id: membership.id,
|
||||
employeeNumber: membership.employeeNumber,
|
||||
firstName: membership.profile?.firstName ?? '',
|
||||
lastName: membership.profile?.lastName ?? membership.employeeNumber,
|
||||
email: membership.user?.email ?? null,
|
||||
phone: membership.profile?.phone ?? null,
|
||||
status: membership.status,
|
||||
hasAccount: membership.userId !== null,
|
||||
roleName: membership.role.name,
|
||||
@@ -103,37 +227,113 @@ export async function listEmployees(): Promise<EmployeeListRow[]> {
|
||||
locationName: contract
|
||||
? (locationNames.get(contract.locationId) ?? null)
|
||||
: null,
|
||||
teamName: team?.team.name ?? null,
|
||||
invitationState: invitation ? invitationState(invitation, now) : null,
|
||||
};
|
||||
});
|
||||
|
||||
// Le tri par nom se fait ici : trier en base sur le nom du dossier
|
||||
// demanderait une jointure ordonnée, et « Étienne » se range après
|
||||
// « Etchegaray » pour Postgres, avant pour un lecteur français.
|
||||
if (filters.sort !== 'number') {
|
||||
rows.sort((a, b) =>
|
||||
`${a.lastName} ${a.firstName}`.localeCompare(
|
||||
`${b.lastName} ${b.firstName}`,
|
||||
'fr',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
locations,
|
||||
roles,
|
||||
contractTypes: Object.entries(CONTRACT_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface EmployeeDetail extends EmployeeListRow {
|
||||
profile: {
|
||||
birthDate: Date | null;
|
||||
city: string | null;
|
||||
phone: string | null;
|
||||
personalEmail: string | null;
|
||||
/** Chiffré au repos, déchiffré seulement pour qui a le droit de le lire. */
|
||||
/**
|
||||
* Dossier personnel tel qu'il est saisi.
|
||||
*
|
||||
* Les trois champs chiffrés sont regroupés à part : ils ne sont pas seulement
|
||||
* masqués à l'affichage quand la capacité manque, ils sont **absents** de la
|
||||
* réponse, et l'écran de saisie qui les porte n'est alors pas rendu du tout.
|
||||
* Un formulaire affiché vide renverrait des champs vides, et effacerait ce
|
||||
* 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;
|
||||
/** Chiffrés au repos, déchiffrés seulement pour qui a le droit de les lire. */
|
||||
sensitive: {
|
||||
socialSecurityNumber: string | null;
|
||||
iban: string | null;
|
||||
bic: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface EmployeeDetail extends EmployeeListRow {
|
||||
/** Ce que porte le bandeau de la fiche, au-dessus des onglets. */
|
||||
headline: {
|
||||
jobTitle: string | null;
|
||||
lineManagerName: string | null;
|
||||
contractEnd: Date | null;
|
||||
};
|
||||
profile: EmployeeProfileDetail | null;
|
||||
contracts: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
startDate: Date;
|
||||
endDate: Date | null;
|
||||
trialEndDate: Date | null;
|
||||
weeklyHours: string;
|
||||
forfaitJours: boolean;
|
||||
forfaitDaysPerYear: string | null;
|
||||
forfaitAgreementRef: string | null;
|
||||
isModulated: boolean;
|
||||
classification: string | null;
|
||||
coefficient: string | null;
|
||||
locationName: string | null;
|
||||
status: string;
|
||||
endReason: string | null;
|
||||
/** Absent quand `members.salary.view` manque. */
|
||||
monthlySalary: string | null;
|
||||
amendments: Array<{ id: string; effectiveDate: Date; reason: string | null }>;
|
||||
amendments: Array<{
|
||||
id: string;
|
||||
effectiveDate: Date;
|
||||
reason: string | null;
|
||||
changes: unknown;
|
||||
}>;
|
||||
}>;
|
||||
canSeeSalary: boolean;
|
||||
canInvite: boolean;
|
||||
canEdit: boolean;
|
||||
/** Dernière invitation émise, quel que soit son sort. */
|
||||
invitation: {
|
||||
state: InvitationState;
|
||||
@@ -143,7 +343,14 @@ export interface EmployeeDetail extends EmployeeListRow {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
|
||||
/**
|
||||
* Mémorisé le temps d'une requête : la fiche est désormais un gabarit à
|
||||
* onglets, et le gabarit comme l'onglet ont besoin du même dossier. Sans cela,
|
||||
* chaque affichage le lirait deux fois.
|
||||
*/
|
||||
export const getEmployee = cache(async function getEmployee(
|
||||
id: string,
|
||||
): Promise<EmployeeDetail | null> {
|
||||
return query('members.view', async (db, actor) => {
|
||||
const canSeeSalary = can(actor, 'members.salary.view');
|
||||
const canSeeDocuments = can(actor, 'members.documents.view');
|
||||
@@ -154,6 +361,9 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
|
||||
user: { select: { email: true } },
|
||||
role: { select: { name: true } },
|
||||
profile: true,
|
||||
teams: {
|
||||
select: { isPrimary: true, team: { select: { name: true } } },
|
||||
},
|
||||
contracts: {
|
||||
orderBy: { startDate: 'desc' },
|
||||
include: {
|
||||
@@ -168,13 +378,45 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
|
||||
const active = membership.contracts.find(
|
||||
(contract) => contract.status === 'ACTIVE',
|
||||
);
|
||||
|
||||
// Tous les établissements portés par l'historique, et pas seulement celui
|
||||
// du contrat en cours : un contrat terminé ailleurs reste à lire.
|
||||
const contractLocations = await db.location.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [
|
||||
...new Set(membership.contracts.map((entry) => entry.locationId)),
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const locationNames = new Map(
|
||||
contractLocations.map((entry) => [entry.id, entry.name]),
|
||||
);
|
||||
const location = active
|
||||
? await db.location.findUnique({
|
||||
where: { id: active.locationId },
|
||||
? { name: locationNames.get(active.locationId) ?? null }
|
||||
: null;
|
||||
|
||||
// L'emploi est porté par le contrat, pas par le dossier : il change par
|
||||
// avenant, et c'est l'avenant qui fait foi devant l'inspection.
|
||||
const jobTitle = active?.jobTitleId
|
||||
? await db.jobTitle.findUnique({
|
||||
where: { id: active.jobTitleId },
|
||||
select: { name: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
const lineManager = membership.lineManagerId
|
||||
? await db.membership.findUnique({
|
||||
where: { id: membership.lineManagerId },
|
||||
select: {
|
||||
employeeNumber: true,
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
// La plus récente, pas la seule en attente : « invitation expirée le 3 »
|
||||
// est une information utile, et la masquer laisserait croire qu'aucune
|
||||
// n'a jamais été envoyée.
|
||||
@@ -196,10 +438,19 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
|
||||
firstName: membership.profile?.firstName ?? '',
|
||||
lastName: membership.profile?.lastName ?? membership.employeeNumber,
|
||||
email: membership.user?.email ?? null,
|
||||
phone: membership.profile?.phone ?? null,
|
||||
status: membership.status,
|
||||
hasAccount: membership.userId !== null,
|
||||
roleName: membership.role.name,
|
||||
locationName: location?.name ?? null,
|
||||
teamName:
|
||||
(
|
||||
membership.teams.find((entry) => entry.isPrimary) ??
|
||||
membership.teams[0]
|
||||
)?.team.name ?? null,
|
||||
invitationState: lastInvitation
|
||||
? invitationState(lastInvitation, new Date())
|
||||
: null,
|
||||
contract: active
|
||||
? {
|
||||
type: active.contractType,
|
||||
@@ -209,18 +460,47 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
|
||||
forfaitJours: active.workTimeArrangement === 'FORFAIT_JOURS',
|
||||
}
|
||||
: null,
|
||||
headline: {
|
||||
jobTitle: jobTitle?.name ?? null,
|
||||
lineManagerName: lineManager
|
||||
? `${lineManager.profile?.firstName ?? ''} ${lineManager.profile?.lastName ?? lineManager.employeeNumber}`.trim()
|
||||
: null,
|
||||
contractEnd: active?.endDate ?? null,
|
||||
},
|
||||
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,
|
||||
// Le NIR et l'IBAN ne sont déchiffrés que pour un profil habilité.
|
||||
socialSecurityNumber: canSeeDocuments
|
||||
? decryptOptional(membership.profile.socialSecurityNumberEnc)
|
||||
: null,
|
||||
iban: canSeeDocuments
|
||||
? decryptOptional(membership.profile.ibanEnc)
|
||||
emergencyContactName: membership.profile.emergencyContactName,
|
||||
emergencyContactPhone: membership.profile.emergencyContactPhone,
|
||||
// Le NIR, l'IBAN et le BIC ne sont déchiffrés que pour un profil
|
||||
// habilité — sinon le bloc entier reste absent de la réponse.
|
||||
sensitive: canSeeDocuments
|
||||
? {
|
||||
socialSecurityNumber: decryptOptional(
|
||||
membership.profile.socialSecurityNumberEnc,
|
||||
),
|
||||
iban: decryptOptional(membership.profile.ibanEnc),
|
||||
bic: decryptOptional(membership.profile.bicEnc),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
@@ -230,10 +510,17 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
|
||||
label: contractLabel(contract.contractType),
|
||||
startDate: contract.startDate,
|
||||
endDate: contract.endDate,
|
||||
trialEndDate: contract.trialEndDate,
|
||||
weeklyHours: contract.weeklyHours.toString(),
|
||||
forfaitJours: contract.workTimeArrangement === 'FORFAIT_JOURS',
|
||||
forfaitDaysPerYear: contract.forfaitDaysPerYear?.toString() ?? null,
|
||||
forfaitAgreementRef: contract.forfaitAgreementRef,
|
||||
isModulated: contract.isModulated,
|
||||
classification: contract.classification,
|
||||
coefficient: contract.coefficient,
|
||||
locationName: locationNames.get(contract.locationId) ?? null,
|
||||
status: contract.status,
|
||||
endReason: contract.endReason,
|
||||
monthlySalary: canSeeSalary
|
||||
? (contract.monthlySalary?.toString() ?? null)
|
||||
: null,
|
||||
@@ -241,10 +528,12 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
|
||||
id: amendment.id,
|
||||
effectiveDate: amendment.effectiveDate,
|
||||
reason: amendment.reason,
|
||||
changes: amendment.changes,
|
||||
})),
|
||||
})),
|
||||
canSeeSalary,
|
||||
canInvite: can(actor, 'members.invite'),
|
||||
canEdit: can(actor, 'members.edit'),
|
||||
invitation: lastInvitation
|
||||
? {
|
||||
state: invitationState(lastInvitation, new Date()),
|
||||
@@ -255,4 +544,201 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
|
||||
: null,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Établissements où poser un contrat.
|
||||
*
|
||||
* Une liste à part de celle des réglages : ouvrir un contrat n'exige pas
|
||||
* d'administrer les établissements, et emprunter `settings.access` fermerait
|
||||
* la création à un gestionnaire de paie qui n'a rien à y administrer.
|
||||
*/
|
||||
export interface ContractLocation {
|
||||
id: string;
|
||||
name: string;
|
||||
teams: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export const listContractLocations = cache(
|
||||
async function listContractLocations(): Promise<ContractLocation[]> {
|
||||
return query('members.contract.create', async (db) =>
|
||||
db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
teams: {
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
contractLocationName: string | null;
|
||||
teams: Array<{ id: string; name: string; locationName: string; isPrimary: boolean }>;
|
||||
/** Vrai quand le périmètre couvre tout le compte, ouvertures futures comprises. */
|
||||
allLocations: boolean;
|
||||
scopedLocations: string[];
|
||||
scopedTeams: string[];
|
||||
/** Les mêmes, par identifiant : ce que les formulaires cochent. */
|
||||
scopedLocationIds: string[];
|
||||
teamIds: string[];
|
||||
primaryTeamId: string | null;
|
||||
}
|
||||
|
||||
export const getMemberPlacement = cache(async function getMemberPlacement(
|
||||
id: string,
|
||||
): Promise<MemberPlacement | null> {
|
||||
return query('members.view', async (db) => {
|
||||
const membership = await db.membership.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
contracts: {
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { startDate: 'desc' },
|
||||
take: 1,
|
||||
select: { locationId: true },
|
||||
},
|
||||
teams: {
|
||||
select: {
|
||||
isPrimary: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
location: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scopes: {
|
||||
select: {
|
||||
allLocations: true,
|
||||
locationId: true,
|
||||
location: { select: { name: true } },
|
||||
team: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) return null;
|
||||
|
||||
const contractLocationId = membership.contracts[0]?.locationId;
|
||||
const contractLocation = contractLocationId
|
||||
? await db.location.findUnique({
|
||||
where: { id: contractLocationId },
|
||||
select: { name: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
contractLocationName: contractLocation?.name ?? null,
|
||||
teams: membership.teams.map((member) => ({
|
||||
id: member.team.id,
|
||||
name: member.team.name,
|
||||
locationName: member.team.location.name,
|
||||
isPrimary: member.isPrimary,
|
||||
})),
|
||||
allLocations: membership.scopes.some((scope) => scope.allLocations),
|
||||
scopedLocations: [
|
||||
...new Set(
|
||||
membership.scopes
|
||||
.map((scope) => scope.location?.name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
),
|
||||
],
|
||||
scopedTeams: [
|
||||
...new Set(
|
||||
membership.scopes
|
||||
.map((scope) => scope.team?.name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
),
|
||||
],
|
||||
scopedLocationIds: [
|
||||
...new Set(
|
||||
membership.scopes
|
||||
.map((scope) => scope.locationId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
],
|
||||
teamIds: membership.teams.map((member) => member.team.id),
|
||||
primaryTeamId:
|
||||
membership.teams.find((member) => member.isPrimary)?.team.id ?? null,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** Établissements et équipes où rattacher quelqu'un. */
|
||||
export const listPlacementOptions = cache(
|
||||
async function listPlacementOptions(): Promise<ContractLocation[]> {
|
||||
// `members.view` suffit : ces noms sont déjà affichés par l'annuaire, et
|
||||
// exiger davantage fermerait l'écran à qui a le droit de le lire.
|
||||
return query('members.view', async (db) =>
|
||||
db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
teams: {
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'server-only';
|
||||
|
||||
import { PDFDocument, StandardFonts, rgb, type PDFFont } from 'pdf-lib';
|
||||
|
||||
import { RUP_COLUMNS, type RupPerson } from '@/domain/legal/rup';
|
||||
import type { RupData } from '@/server/employees/rup';
|
||||
|
||||
/**
|
||||
* Rendu du registre unique du personnel.
|
||||
*
|
||||
* Aucune forme n'est imposée par la loi : ce qui compte est que les mentions y
|
||||
* soient, lisibles, et que le document puisse être présenté tel quel en cas de
|
||||
* contrôle. D'où un tableau paysage sobre plutôt qu'une mise en page.
|
||||
*
|
||||
* Helvetica et l'encodage WinAnsi : les accents français y sont, et une police
|
||||
* embarquée alourdirait le fichier sans rien apporter à un tableau.
|
||||
*/
|
||||
|
||||
const A4_LANDSCAPE: [number, number] = [841.89, 595.28];
|
||||
const MARGIN = 32;
|
||||
const ROW_HEIGHT = 18;
|
||||
const FONT_SIZE = 8;
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'short' });
|
||||
|
||||
function cell(person: RupPerson, key: keyof RupPerson): string {
|
||||
const value = person[key];
|
||||
if (value === null || value === '') return '—';
|
||||
if (value instanceof Date) return dateFormat.format(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Coupe au lieu de déborder : une colonne qui empiète sur la suivante rend
|
||||
* les deux illisibles. */
|
||||
function fit(text: string, font: PDFFont, size: number, width: number): string {
|
||||
if (font.widthOfTextAtSize(text, size) <= width) return text;
|
||||
let cut = text;
|
||||
while (cut.length > 1 && font.widthOfTextAtSize(`${cut}…`, size) > width) {
|
||||
cut = cut.slice(0, -1);
|
||||
}
|
||||
return `${cut}…`;
|
||||
}
|
||||
|
||||
export async function renderRegisterPdf(data: RupData): Promise<Uint8Array> {
|
||||
const pdf = await PDFDocument.create();
|
||||
pdf.setTitle(`Registre unique du personnel — ${data.locationName}`);
|
||||
pdf.setCreator('PlanFlow');
|
||||
|
||||
const regular = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const usable = A4_LANDSCAPE[0] - MARGIN * 2;
|
||||
const widths = RUP_COLUMNS.map((column) => column.width * usable);
|
||||
const ink = rgb(0.11, 0.11, 0.11);
|
||||
const faint = rgb(0.62, 0.62, 0.62);
|
||||
const rule = rgb(0.85, 0.85, 0.85);
|
||||
|
||||
const printedOn = dateFormat.format(new Date());
|
||||
let page = pdf.addPage(A4_LANDSCAPE);
|
||||
let y = A4_LANDSCAPE[1] - MARGIN;
|
||||
|
||||
const header = () => {
|
||||
page.drawText('Registre unique du personnel', {
|
||||
x: MARGIN,
|
||||
y: y - 12,
|
||||
size: 14,
|
||||
font: bold,
|
||||
color: ink,
|
||||
});
|
||||
page.drawText(
|
||||
[
|
||||
data.locationName,
|
||||
data.siret ? `SIRET ${data.siret}` : null,
|
||||
`édité le ${printedOn}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
{ x: MARGIN, y: y - 28, size: 9, font: regular, color: faint },
|
||||
);
|
||||
y -= 48;
|
||||
|
||||
let x = MARGIN;
|
||||
RUP_COLUMNS.forEach((column, index) => {
|
||||
page.drawText(column.label, {
|
||||
x,
|
||||
y: y - 10,
|
||||
size: FONT_SIZE,
|
||||
font: bold,
|
||||
color: ink,
|
||||
});
|
||||
x += widths[index] as number;
|
||||
});
|
||||
y -= 16;
|
||||
page.drawLine({
|
||||
start: { x: MARGIN, y },
|
||||
end: { x: MARGIN + usable, y },
|
||||
thickness: 0.7,
|
||||
color: rule,
|
||||
});
|
||||
y -= 4;
|
||||
};
|
||||
|
||||
header();
|
||||
|
||||
for (const person of data.people) {
|
||||
// Nouvelle page avant d'écrire, pas après : une ligne à moitié sous la
|
||||
// marge est pire qu'une page de plus.
|
||||
if (y - ROW_HEIGHT < MARGIN + 24) {
|
||||
page = pdf.addPage(A4_LANDSCAPE);
|
||||
y = A4_LANDSCAPE[1] - MARGIN;
|
||||
header();
|
||||
}
|
||||
|
||||
let x = MARGIN;
|
||||
RUP_COLUMNS.forEach((column, index) => {
|
||||
const width = widths[index] as number;
|
||||
const value = cell(person, column.key);
|
||||
page.drawText(fit(value, regular, FONT_SIZE, width - 6), {
|
||||
x,
|
||||
y: y - 12,
|
||||
size: FONT_SIZE,
|
||||
font: regular,
|
||||
color: value === '—' ? faint : ink,
|
||||
});
|
||||
x += width;
|
||||
});
|
||||
|
||||
y -= ROW_HEIGHT;
|
||||
page.drawLine({
|
||||
start: { x: MARGIN, y: y + 4 },
|
||||
end: { x: MARGIN + usable, y: y + 4 },
|
||||
thickness: 0.4,
|
||||
color: rule,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.people.length === 0) {
|
||||
page.drawText('Aucun salarié rattaché à cet établissement.', {
|
||||
x: MARGIN,
|
||||
y: y - 12,
|
||||
size: 9,
|
||||
font: regular,
|
||||
color: faint,
|
||||
});
|
||||
}
|
||||
|
||||
// Mention de pied : un registre incomplet reste un registre, mais celui qui
|
||||
// le présente doit savoir ce qui lui manque avant l'inspection.
|
||||
const pages = pdf.getPages();
|
||||
pages.forEach((current, index) => {
|
||||
current.drawText(
|
||||
[
|
||||
`Page ${index + 1} sur ${pages.length}`,
|
||||
`${data.people.length} ligne${data.people.length > 1 ? 's' : ''}`,
|
||||
data.incomplete > 0
|
||||
? `${data.incomplete} dossier${data.incomplete > 1 ? 's' : ''} incomplet${data.incomplete > 1 ? 's' : ''}`
|
||||
: 'Mentions complètes',
|
||||
].join(' · '),
|
||||
{ x: MARGIN, y: MARGIN - 12, size: 7, font: regular, color: faint },
|
||||
);
|
||||
});
|
||||
|
||||
return pdf.save();
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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';
|
||||
import { contractLabel } from '@/server/employees/queries';
|
||||
|
||||
/**
|
||||
* Registre unique du personnel — lecture.
|
||||
*
|
||||
* Le registre porte **tous** ceux qui ont été employés dans l'établissement,
|
||||
* y compris les partis : c'est précisément l'historique que l'inspection vient
|
||||
* chercher. Filtrer sur les présents en ferait un trombinoscope.
|
||||
*/
|
||||
|
||||
export interface RupData {
|
||||
locationId: string;
|
||||
locationName: string;
|
||||
siret: string | null;
|
||||
people: RupPerson[];
|
||||
/** Nombre de salariés à qui il manque au moins une mention obligatoire. */
|
||||
incomplete: number;
|
||||
}
|
||||
|
||||
export const getRegisterData = cache(async function getRegisterData(
|
||||
locationId: string,
|
||||
): Promise<RupData | null> {
|
||||
return query('members.register.export', async (db) => {
|
||||
const location = await db.location.findUnique({
|
||||
where: { id: locationId },
|
||||
select: { id: true, name: true, siret: true },
|
||||
});
|
||||
if (!location) return null;
|
||||
|
||||
const contracts = await db.userContract.findMany({
|
||||
where: { locationId },
|
||||
orderBy: { startDate: 'asc' },
|
||||
include: {
|
||||
membership: {
|
||||
select: {
|
||||
profile: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
birthName: true,
|
||||
gender: true,
|
||||
nationality: true,
|
||||
birthDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const jobTitleIds = [
|
||||
...new Set(
|
||||
contracts
|
||||
.map((contract) => contract.jobTitleId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
const jobTitles = await db.jobTitle.findMany({
|
||||
where: { id: { in: jobTitleIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const jobTitleNames = new Map(
|
||||
jobTitles.map((entry) => [entry.id, entry.name]),
|
||||
);
|
||||
|
||||
// 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) => ({
|
||||
// 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 ?? '',
|
||||
sex: genderShort(contract.membership.profile?.gender ?? null),
|
||||
nationality: contract.membership.profile?.nationality ?? null,
|
||||
birthDate: contract.membership.profile?.birthDate ?? null,
|
||||
jobTitle: contract.jobTitleId
|
||||
? (jobTitleNames.get(contract.jobTitleId) ?? null)
|
||||
: null,
|
||||
qualification: contract.classification,
|
||||
contractLabel: contractLabel(contract.contractType),
|
||||
entryDate: contract.startDate,
|
||||
exitDate: contract.endDate,
|
||||
}));
|
||||
|
||||
return {
|
||||
locationId: location.id,
|
||||
locationName: location.name,
|
||||
siret: location.siret,
|
||||
people,
|
||||
incomplete: peopleWithGaps(people),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** Établissements pour lesquels un registre peut être tiré. */
|
||||
export const listRegisterLocations = cache(
|
||||
async function listRegisterLocations(): Promise<
|
||||
Array<{ id: string; name: string; incomplete: number; total: number }>
|
||||
> {
|
||||
return query('members.register.export', async (db) => {
|
||||
const locations = await db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
// Le décompte des manques est calculé ici pour que l'écran puisse
|
||||
// avertir **avant** le téléchargement : découvrir un registre incomplet
|
||||
// en l'ouvrant, c'est le découvrir devant l'inspection.
|
||||
return Promise.all(
|
||||
locations.map(async (location) => {
|
||||
const contracts = await db.userContract.findMany({
|
||||
where: { locationId: location.id },
|
||||
select: {
|
||||
classification: true,
|
||||
jobTitleId: true,
|
||||
contractType: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
membership: {
|
||||
select: {
|
||||
profile: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
birthName: true,
|
||||
gender: true,
|
||||
nationality: true,
|
||||
birthDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const people: RupPerson[] = contracts.map((contract) => ({
|
||||
lastName:
|
||||
contract.membership.profile?.birthName ??
|
||||
contract.membership.profile?.lastName ??
|
||||
'',
|
||||
firstName: contract.membership.profile?.firstName ?? '',
|
||||
sex: genderShort(contract.membership.profile?.gender ?? null),
|
||||
nationality: contract.membership.profile?.nationality ?? null,
|
||||
birthDate: contract.membership.profile?.birthDate ?? null,
|
||||
jobTitle: contract.jobTitleId ? 'x' : null,
|
||||
qualification: contract.classification,
|
||||
contractLabel: contractLabel(contract.contractType),
|
||||
entryDate: contract.startDate,
|
||||
exitDate: contract.endDate,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
incomplete: peopleWithGaps(people),
|
||||
total: people.length,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
@@ -113,14 +113,22 @@ test('une pièce sans politique n’est jamais purgée', async ({ page }) => {
|
||||
// compte, et une pièce partagée verrait son sort décidé par un autre test.
|
||||
const suffix = `${Date.now()}-garde`;
|
||||
await page.goto('/equipe');
|
||||
const create = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
// 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: '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}`);
|
||||
await create.getByRole('button', { name: 'Ajouter' }).click();
|
||||
// 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: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(`Garde${suffix}`) }).click();
|
||||
await page.goto(`${page.url()}/documents`);
|
||||
const upload = page.locator('form').filter({ hasText: 'Déposer' });
|
||||
await upload.getByLabel('Catégorie').selectOption('OTHER');
|
||||
await upload
|
||||
@@ -163,14 +171,22 @@ test('une pièce échue est effectivement effacée', async ({ page }) => {
|
||||
await expect(page.getByText('Durée enregistrée.')).toBeVisible();
|
||||
|
||||
await page.goto('/equipe');
|
||||
const create = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
// 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: '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}`);
|
||||
await create.getByRole('button', { name: 'Ajouter' }).click();
|
||||
// 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: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(`Purge${suffix}`) }).click();
|
||||
await page.goto(`${page.url()}/documents`);
|
||||
const dossier = page.url();
|
||||
const upload = page.locator('form').filter({ hasText: 'Déposer' });
|
||||
await upload.getByLabel('Catégorie').selectOption('REGISTER');
|
||||
|
||||
@@ -27,15 +27,26 @@ async function createEmployee(page: Page, tag: string) {
|
||||
const lastName = `Dossier${suffix}`;
|
||||
|
||||
await page.goto('/equipe');
|
||||
const form = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
// 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: '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}`);
|
||||
await form.getByRole('button', { name: 'Ajouter' }).click();
|
||||
// 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: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(lastName) }).click();
|
||||
await expect(page.getByRole('heading', { name: new RegExp(lastName) })).toBeVisible();
|
||||
|
||||
// La fiche s'ouvre sur les informations personnelles : les pièces sont un
|
||||
// onglet, et une route à part entière.
|
||||
await page.goto(`${page.url()}/documents`);
|
||||
return { lastName, url: page.url() };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Fiche salarié — onglets et saisie du dossier.
|
||||
*
|
||||
* Le dossier personnel est la première chose qu'on remplit après une embauche,
|
||||
* et la dernière qu'on relit avant une déclaration. Ce parcours éprouve donc ce
|
||||
* que voit un gestionnaire : les onglets tiennent leur route, et ce qui est
|
||||
* saisi est encore là au rechargement.
|
||||
*/
|
||||
|
||||
async function createEmployee(page: Page) {
|
||||
const suffix = `${Date.now()}-fiche`;
|
||||
const lastName = `Fiche${suffix}`;
|
||||
|
||||
await page.goto('/equipe');
|
||||
// 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: 'Informations salarié' });
|
||||
await form.getByLabel('Prénom').fill('Awa');
|
||||
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: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(lastName) }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(lastName) }),
|
||||
).toBeVisible();
|
||||
|
||||
return { lastName, url: page.url() };
|
||||
}
|
||||
|
||||
test('les onglets de la fiche sont des routes', async ({ page }) => {
|
||||
const employee = await createEmployee(page);
|
||||
|
||||
// Le bandeau ne bouge pas d'un onglet à l'autre : c'est ce qui dit de qui on
|
||||
// parle pendant qu'on navigue.
|
||||
for (const [label, segment] of [
|
||||
['Contrats', '/contrats'],
|
||||
['Planification et accès', '/planification'],
|
||||
['Congés et Absences', '/absences'],
|
||||
['Documents', '/documents'],
|
||||
] as const) {
|
||||
await page.getByRole('link', { name: label, exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`${segment}$`));
|
||||
await expect(
|
||||
page.getByRole('heading', { name: new RegExp(employee.lastName) }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// Rouvrir l'onglet directement par son adresse doit donner le même écran :
|
||||
// une fiche s'envoie par lien.
|
||||
await page.goto(`${employee.url}/contrats`);
|
||||
await expect(page.getByText('Tous les contrats et avenants')).toBeVisible();
|
||||
});
|
||||
|
||||
test('le dossier personnel se saisit et se conserve', async ({ page }) => {
|
||||
const employee = await createEmployee(page);
|
||||
|
||||
await expect(page.getByText('Non renseigné').first()).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Modifier les informations personnelles' })
|
||||
.click();
|
||||
|
||||
await page.getByLabel('Date de naissance').fill('1988-03-12');
|
||||
await page.getByLabel('Lieu de naissance').fill('Nancy');
|
||||
await page.getByLabel('Nationalité').fill('France');
|
||||
await page.getByLabel('Téléphone mobile').fill('+33 6 12 34 56 78');
|
||||
await page.getByLabel('Ville').fill('Frouard');
|
||||
await page.getByRole('button', { name: 'Enregistrer les modifications' }).click();
|
||||
|
||||
await expect(page.getByText('Nancy')).toBeVisible();
|
||||
|
||||
// Le rechargement est la seule preuve qui vaille : un écran qui affiche ce
|
||||
// qu'on vient de taper ne dit rien de ce qui a été écrit.
|
||||
await page.goto(employee.url);
|
||||
await expect(page.getByText('Nancy')).toBeVisible();
|
||||
await expect(page.getByText('Frouard')).toBeVisible();
|
||||
});
|
||||
|
||||
test('une date de naissance à venir est refusée', async ({ page }) => {
|
||||
await createEmployee(page);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Modifier les informations personnelles' })
|
||||
.click();
|
||||
await page.getByLabel('Date de naissance').fill('2999-01-01');
|
||||
await page.getByRole('button', { name: 'Enregistrer les modifications' }).click();
|
||||
|
||||
await expect(page.getByText('Date de naissance invalide.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('une embauche pose le contrat en même temps que le dossier', async ({
|
||||
page,
|
||||
}) => {
|
||||
const suffix = `${Date.now()}-embauche`;
|
||||
const lastName = `Embauche${suffix}`;
|
||||
|
||||
await page.goto('/equipe');
|
||||
// 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: 'Informations salarié' });
|
||||
await form.getByLabel('Prénom').fill('Sofia');
|
||||
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
|
||||
// aucune grille et ne se déclare pas.
|
||||
await expect(
|
||||
form.getByLabel('Ouvrir un contrat maintenant'),
|
||||
).toBeChecked();
|
||||
await form.getByLabel('Début du contrat').fill('2026-01-05');
|
||||
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
|
||||
// « Sans contrat », celui-ci porte son établissement.
|
||||
await expect(
|
||||
page.getByRole('row', { name: new RegExp(lastName) }),
|
||||
).not.toContainText('Sans contrat');
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(lastName) }).click();
|
||||
// Le bandeau porte le contrat : c'est ce qui distingue un dossier embauché
|
||||
// d'un dossier ouvert.
|
||||
await expect(page.getByText('Type de contrat')).toBeVisible();
|
||||
await page.getByRole('link', { name: 'Contrats', exact: true }).click();
|
||||
await expect(page.getByText('35 heures hebdomadaires')).toBeVisible();
|
||||
});
|
||||
@@ -21,12 +21,19 @@ async function createEmployee(page: Page, tag: string) {
|
||||
const email = `sacha.${suffix}@exemple.test`;
|
||||
|
||||
await page.goto('/equipe');
|
||||
const form = page.locator('form').filter({ hasText: 'Ajouter' });
|
||||
// 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: '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);
|
||||
await form.getByRole('button', { name: 'Ajouter' }).click();
|
||||
// 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: 'Enregistrer' }).click();
|
||||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||||
|
||||
const row = page.getByRole('link', { name: new RegExp(lastName) });
|
||||
@@ -37,6 +44,10 @@ async function createEmployee(page: Page, tag: string) {
|
||||
page.getByRole('heading', { name: `${firstName} ${lastName}` }),
|
||||
).toBeVisible();
|
||||
|
||||
// L'accès applicatif est porté par l'onglet « Documents », avec les pièces du
|
||||
// dossier : la fiche s'ouvre ailleurs.
|
||||
await page.goto(`${page.url()}/documents`);
|
||||
|
||||
return { firstName, lastName, email, url: page.url() };
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user