Merge branch 'claude/referentiels-wp02'

Dix lots : référentiels, absences par état, vues de planning manquantes,
journal d'activité, analyses RH, modèles de documents et RGPD, politiques de
RTT, codes Silae du dossier, compte et préférences, correction du bandeau de
compteurs.

Trois migrations à appliquer avant démarrage : modèles de documents,
préférences de compte, impression et productivité.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 13:20:49 +02:00
commit 8033553222
71 files changed
+7961 -372

No files matched your search

@@ -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);
+122 -1
View File
@@ -19,6 +19,14 @@ model Account {
name String name String
siren String? siren String?
apeCode 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? collectiveAgreementId String?
/// Surcharges d'accord d'entreprise (PLAN.md §6.3). Vide aujourd'hui : /// Surcharges d'accord d'entreprise (PLAN.md §6.3). Vide aujourd'hui :
/// l'organisation auditée n'a pas d'accord d'entreprise. /// l'organisation auditée n'a pas d'accord d'entreprise.
@@ -34,8 +42,91 @@ model Account {
jobTitles JobTitle[] jobTitles JobTitle[]
labels Label[] labels Label[]
absenceTypes AbsenceType[] absenceTypes AbsenceType[]
documentTemplates DocumentTemplate[]
legalConfig LegalConfigEntry[] legalConfig LegalConfigEntry[]
installation Installation? 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. /// Marqueur d'installation de l'instance — une ligne, jamais deux.
@@ -792,6 +883,33 @@ model DailyNote {
/// pouvoir reproduire à l'identique une paie antérieure après un changement de /// 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 /// règle. Une mise à jour en place rendrait cette reproduction impossible, et
/// l'impossibilité ne se découvrirait qu'au premier contrôle. /// 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. /// Pièce du dossier salarié — PLAN.md §4.7.
/// ///
/// Le contenu vit sur disque, **chiffré**, jamais dans la base : une pièce /// Le contenu vit sur disque, **chiffré**, jamais dans la base : une pièce
@@ -811,6 +929,8 @@ model Document {
/// Empreinte du clair, pour détecter une altération du fichier stocké. /// Empreinte du clair, pour détecter une altération du fichier stocké.
checksum String checksum String
/// Donnée de santé : sa lecture est journalisée (§3.6). /// 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) isSensitive Boolean @default(false)
/// Échéance de conservation. Nulle tant qu'aucune politique ne l'a fixée : /// É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). /// le plan interdit d'appliquer une durée par défaut (§12.5).
@@ -820,7 +940,8 @@ model Document {
deletedAt DateTime? deletedAt DateTime?
deletedBy String? 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([accountId])
@@index([membershipId]) @@index([membershipId])
+147
View File
@@ -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
View File
@@ -22,6 +22,11 @@ import {
} from '../src/domain/compliance/idcc1517'; } from '../src/domain/compliance/idcc1517';
import { frenchHolidays } from '../src/domain/absences/holidays'; import { frenchHolidays } from '../src/domain/absences/holidays';
import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements'; 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 { evaluateSchedule } from '../src/server/compliance/evaluate';
import { withTenant } from '../src/server/tenant'; import { withTenant } from '../src/server/tenant';
@@ -586,9 +591,10 @@ async function main() {
console.log('→ Types d’absence'); console.log('→ Types d’absence');
// `silaeCode` reste **null** : les codes AB-* du dossier sont connus, leur // `silaeCode` vient du paramétrage Silae du dossier, relevé le 11 août 2026
// signification ne l'est pas. Les associer ici reviendrait à deviner à quel // (voir `seed-data/silae-frouard.ts`). Il n'a rien d'universel : la
// type d'absence correspond AB-300, ce qui produirait une paie fausse. // 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 = [ const absenceTypes = [
{ code: 'CP', name: 'Congés payés', colorKey: 'cp', isPaid: true, social: false, notice: 30 }, { 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 }, { code: 'RTT', name: 'RTT', colorKey: 'rtt', isPaid: true, social: false, notice: 7 },
@@ -600,12 +606,17 @@ async function main() {
for (const type of absenceTypes) { for (const type of absenceTypes) {
await prisma.absenceType.upsert({ await prisma.absenceType.upsert({
where: { accountId_code: { accountId: account.id, code: type.code } }, 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: { create: {
accountId: account.id, accountId: account.id,
code: type.code, code: type.code,
name: type.name, name: type.name,
colorKey: type.colorKey, colorKey: type.colorKey,
silaeCode: ABSENCE_CODES[type.code] ?? null,
isPaid: type.isPaid, isPaid: type.isPaid,
countsAsWorkTime: false, countsAsWorkTime: false,
affectsPaidLeaveAccrual: !type.social, affectsPaidLeaveAccrual: !type.social,
@@ -709,12 +720,18 @@ async function main() {
console.log(` ${demoAbsences.length} absences`); console.log(` ${demoAbsences.length} absences`);
console.log('→ Correspondances Silae'); console.log('→ Correspondances Silae');
// Semées **non confirmées**, y compris quand le code se lit dans son libellé // Les codes viennent du paramétrage du dossier, actif et coché côté Combo :
// (`EV-HDimanche`). Proposer n'est pas confirmer : seul le gestionnaire de // ils sont donc semés **confirmés**. Un code déduit d'un libellé le serait
// paie sait si le code est le bon dans ce dossier, et l'export refuse de // non confirmé — proposer n'est pas confirmer — mais ceux-ci sont relevés
// tourner tant qu'il ne l'a pas dit. // 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) { 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({ const existing = await prisma.silaeCodeMapping.findFirst({
where: { accountId: account.id, sourceKey: definition.key }, where: { accountId: account.id, sourceKey: definition.key },
}); });
@@ -724,16 +741,29 @@ async function main() {
data: { data: {
accountId: account.id, accountId: account.id,
sourceKey: definition.key, sourceKey: definition.key,
silaeCode: definition.suggestedCode, silaeCode: code,
label: definition.label, label: definition.label,
kind: definition.kind, kind: definition.kind,
confirmed: false, confirmed: Boolean(ELEMENT_CODES[definition.key]),
}, },
}); });
seededMappings += 1;
} }
console.log( const missingElements = PAYROLL_ELEMENT_DEFINITIONS.filter(
` ${PAYROLL_ELEMENT_DEFINITIONS.filter((d) => d.suggestedCode).length} proposées, aucune confirmée`, (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'); console.log('→ Matricules Silae');
// Fictifs, à la forme observée dans le dossier : cinq chiffres cadrés à zéro. // Fictifs, à la forme observée dans le dossier : cinq chiffres cadrés à zéro.
+36
View File
@@ -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>
);
}
+332
View File
@@ -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('/');
}
+33
View File
@@ -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>
);
}
+64
View File
@@ -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
View File
@@ -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'; * Ancienne adresse du calendrier des absences.
import { PageBody, PageHeader } from '@/components/shell/PageHeader'; *
import { Badge, type Tone } from '@/components/ui/Badge'; * Les absences vivent désormais sous `/absences`, avec une route par état
import { datesBetween, isoWeekday } from '@/domain/absences/count'; * (§9). Le calendrier a déménagé plutôt que d'être dupliqué : deux écrans
import { monthOf, parseMonthParam } from '@/domain/planning/month'; * lisant le même modèle finissent toujours par diverger d'une correction.
import { cx } from '@/lib/cx'; *
import { getAbsenceBoard, statusLabel } from '@/server/absences/queries'; * La redirection est **permanente** et conserve le mois demandé : un lien de
* planning partagé dans un message continue d'ouvrir la bonne page.
export const metadata = { title: 'Calendrier des absences · PlanFlow' }; */
export default async function CongesRedirect({
interface PageProps { searchParams,
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,
}: { }: {
board: Awaited<ReturnType<typeof getAbsenceBoard>>; searchParams: Promise<{ mois?: string }>;
}) { }) {
const accepted = board.inMonth.filter( const { mois } = await searchParams;
(request) => request.status === 'ACCEPTED' || request.status === 'PENDING', permanentRedirect(
); mois ? `/absences/calendrier?mois=${mois}` : '/absences/calendrier',
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('/');
}
+1 -1
View File
@@ -276,7 +276,7 @@ function EmployeeHome() {
</li> </li>
<li> <li>
<Link <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" 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"> <span className="text-sm font-medium text-ink-1">
+219
View File
@@ -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>
);
}
+259
View File
@@ -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>
);
}
+197
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+145
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+184
View File
@@ -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>
);
}
+105
View File
@@ -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)}`;
}
+100
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+155
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+15 -5
View File
@@ -27,9 +27,16 @@ function Cell({ label, value }: { label: string; value: string }) {
} }
/** /**
* Les cinq compteurs d'une ligne salarié : contrat, planifié, absences, écart, * Les cinq compteurs d'une ligne salarié — PLAN.md §7.4 : **heures
* dimanches et repos. Rendus depuis `computeWeekCounters`, jamais recalculés * contractuelles, planifié, absences, écart, repos compensateur**.
* localement. *
* 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({ export function CounterStrip({
counters, 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"> <span className="rounded-full bg-accent-soft px-1.5 py-px font-medium text-accent-soft-ink">
Forfait jours Forfait jours
</span> </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="Rep." value={String(counters.restDays)} />
<Cell label="RC" value={counters.compensatoryRestLabel} />
</div> </div>
); );
} }
@@ -71,6 +81,7 @@ export function CounterStrip({
· ·
</span> </span>
<Cell label="Planif." value={counters.plannedLabel} /> <Cell label="Planif." value={counters.plannedLabel} />
<Cell label="Abs." value={counters.absenceLabel} />
<span <span
className={cx( className={cx(
'tnum rounded-full px-1.5 py-px font-semibold', 'tnum rounded-full px-1.5 py-px font-semibold',
@@ -80,8 +91,7 @@ export function CounterStrip({
> >
{counters.deltaLabel} {counters.deltaLabel}
</span> </span>
<Cell label="Dim." value={String(counters.sundaysWorked)} /> <Cell label="RC" value={counters.compensatoryRestLabel} />
<Cell label="Rep." value={String(counters.restDays)} />
</div> </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>
);
}
+101
View File
@@ -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>
);
}
+74 -9
View File
@@ -32,9 +32,29 @@ export const NAVIGATION: NavSection[] = [
{ id: 'entrees', label: 'Entrées et sorties', href: '/?liste=entrees' }, { id: 'entrees', label: 'Entrées et sorties', href: '/?liste=entrees' },
{ id: 'profils', label: 'Profils incomplets', href: '/?liste=profils' }, { id: 'profils', label: 'Profils incomplets', href: '/?liste=profils' },
{ id: 'titres', label: 'Titres de séjour', href: '/?liste=titres' }, { id: 'titres', label: 'Titres de séjour', href: '/?liste=titres' },
// Ancre plutôt que route : `/conges` appartient à la section Congés, et // Ancre plutôt que route : le calendrier appartient à la section Congés,
// deux entrées allumées pour un même écran rendent la navigation muette. // et deux entrées allumées pour un même écran rendent la navigation
{ id: 'soldes-cp', label: 'Compteurs de congés payés', href: '/conges#compteurs' }, // 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: 'jour', label: 'Vue jour', href: '/planning/jour' },
{ id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' }, { id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' },
{ id: 'mois', label: 'Vue mois', href: '/planning/mois' }, { 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: 'besoins', label: 'Besoins non couverts' },
{ id: 'modeles', label: "Modèles d'ouverture" }, { id: 'modeles', label: "Modèles d'ouverture" },
], ],
@@ -61,9 +87,19 @@ export const NAVIGATION: NavSection[] = [
id: 'conges', id: 'conges',
label: 'Congés', label: 'Congés',
items: [ items: [
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' }, {
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente' }, id: 'calendrier',
{ id: 'politiques', label: 'Politiques de congés' }, 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: 'paie', label: 'Préparation de paie', href: '/paie' },
{ id: 'periodes', label: 'Périodes de paie', href: '/paie/periodes' }, { id: 'periodes', label: 'Périodes de paie', href: '/paie/periodes' },
{ id: 'silae', label: 'Codes Silae', href: '/paie/silae' }, { 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', id: 'reglages',
label: 'Réglages', label: 'Réglages',
items: [ items: [
{ id: 'compte', label: 'Informations du compte', href: '/reglages/compte' },
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' }, { 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: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' }, { id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
{ id: 'securite', label: 'Sécurité', href: '/reglages/securite' }, { id: 'securite', label: 'Sécurité', href: '/reglages/securite' },
@@ -90,8 +139,24 @@ export const NAVIGATION: NavSection[] = [
label: 'Durées de conservation', label: 'Durées de conservation',
href: '/reglages/conservation', href: '/reglages/conservation',
}, },
{ id: 'convention', label: 'Convention collective' }, { id: 'rgpd', label: 'RGPD', href: '/reglages/rgpd' },
{ id: 'postes', label: 'Postes et étiquettes' }, {
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' }, { id: 'roles', label: 'Rôles et permissions', href: '/reglages/roles' },
], ],
}, },
+11
View File
@@ -22,6 +22,15 @@ export interface WeekCounters {
sundaysWorked: number; sundaysWorked: number;
/** Jours de repos dans la semaine. */ /** Jours de repos dans la semaine. */
restDays: number; 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 { export interface WeekCountersView extends WeekCounters {
@@ -31,6 +40,7 @@ export interface WeekCountersView extends WeekCounters {
plannedLabel: string; plannedLabel: string;
absenceLabel: string; absenceLabel: string;
deltaLabel: string; deltaLabel: string;
compensatoryRestLabel: string;
tone: DeltaTone; tone: DeltaTone;
} }
@@ -74,6 +84,7 @@ export function computeWeekCounters(input: WeekCounters): WeekCountersView {
plannedLabel: formatMinutes(input.plannedMinutes), plannedLabel: formatMinutes(input.plannedMinutes),
absenceLabel: formatMinutes(input.absenceMinutes), absenceLabel: formatMinutes(input.absenceMinutes),
deltaLabel: formatDelta(deltaMinutes), deltaLabel: formatDelta(deltaMinutes),
compensatoryRestLabel: formatMinutes(input.compensatoryRestMinutes),
tone: deltaTone(deltaMinutes), tone: deltaTone(deltaMinutes),
}; };
} }
+118
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export class MissingTemplateValue extends Error {
constructor(public readonly keys: string[]) {
super(
`Le modèle cite des variables sans valeur : ${keys.join(', ')}. Complétez le dossier avant de générer la pièce.`,
);
this.name = 'MissingTemplateValue';
}
}
/**
* Rend un modèle avec les valeurs fournies.
*
* Lève dès qu'une variable citée n'a pas de valeur. Produire l'attestation avec
* un blanc serait pire que de refuser : le blanc se remarque à la relecture une
* fois sur deux, et la pièce part signée.
*/
export function renderTemplate(
bodyHtml: string,
values: Record<string, string | null | undefined>,
): string {
const missing = referencedFields(bodyHtml).filter(
(key) => values[key] === null || values[key] === undefined || values[key] === '',
);
if (missing.length > 0) throw new MissingTemplateValue(missing);
return bodyHtml.replace(PLACEHOLDER, (_match, key: string) =>
escapeHtml(String(values[key])),
);
}
+147
View File
@@ -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,
);
+26
View File
@@ -76,6 +76,19 @@ export interface AbsenceInput {
colorKey: string; 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. * Range les créneaux d'une semaine en lignes × sept colonnes.
* *
@@ -91,9 +104,19 @@ export function buildRows(
timeZone: string, timeZone: string,
isPublished: boolean, isPublished: boolean,
absences: AbsenceInput[] = [], absences: AbsenceInput[] = [],
rests: RestInput[] = [],
): { rows: BoardRow[]; unassignedRow: BoardRow | null } { ): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
const columnOf = new Map(weekDates.map((date, index) => [date, index])); 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[][] => const emptyDays = (): BoardShift[][] =>
Array.from({ length: 7 }, () => [] as BoardShift[]); Array.from({ length: 7 }, () => [] as BoardShift[]);
@@ -199,6 +222,8 @@ export function buildRows(
), ),
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0, sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
restDays: 7 - worked, restDays: 7 - worked,
compensatoryRestMinutes:
compensatoryByMember.get(person.membershipId) ?? 0,
}, },
} satisfies BoardRow; } satisfies BoardRow;
}); });
@@ -221,6 +246,7 @@ export function buildRows(
absenceMinutes: 0, absenceMinutes: 0,
sundaysWorked: 0, sundaysWorked: 0,
restDays: 0, restDays: 0,
compensatoryRestMinutes: 0,
}, },
} }
: null; : null;
+70
View File
@@ -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;
});
}
+210
View File
@@ -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]);
}
+4 -4
View File
@@ -229,7 +229,7 @@ export async function requestTimeOffAction(
return toState(error, "Vous n'avez pas le droit de poser cette absence."); 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 }; 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."); return toState(error, "Vous n'avez pas le droit de décider de cette demande.");
} }
revalidatePath('/conges'); revalidatePath('/absences/calendrier');
return { ok: true }; return { ok: true };
} }
@@ -384,7 +384,7 @@ export async function cancelTimeOffAction(
return toState(error, "Vous n'avez pas le droit d'annuler cette absence."); return toState(error, "Vous n'avez pas le droit d'annuler cette absence.");
} }
revalidatePath('/conges'); revalidatePath('/absences/calendrier');
return { ok: true }; return { ok: true };
} }
@@ -462,7 +462,7 @@ export async function adjustCounterAction(
return toState(error, "Vous n'avez pas le droit d'ajuster un compteur."); return toState(error, "Vous n'avez pas le droit d'ajuster un compteur.");
} }
revalidatePath('/conges'); revalidatePath('/absences/calendrier');
return { ok: true }; return { ok: true };
} }
+136
View File
@@ -322,6 +322,142 @@ export async function getAbsenceBoard(month: Month): Promise<AbsenceBoard> {
}); });
} }
/**
* File d'absences filtrée par état — PLAN.md §9, écrans `/absences/*`.
*
* Les trois files partagent une requête plutôt qu'un écran chacune : la règle
* de masquage du motif médical vaut pour les trois, et la dupliquer trois fois
* garantirait qu'une des copies finisse par diverger. Une donnée de santé qui
* ne fuit que sur l'écran « traitées » reste une fuite.
*/
export type AbsenceQueue = 'pending' | 'treated' | 'expired';
export interface QueueFilters {
/** Restreint à un type d'absence. */
typeId?: string;
/** `true` : uniquement les absences Sécurité sociale (filtre relevé à l'audit). */
socialSecurityOnly?: boolean;
}
export interface AbsenceQueueResult {
queue: AbsenceQueue;
rows: AbsenceRequest[];
absenceTypes: Array<{ id: string; code: string; name: string }>;
canDecide: boolean;
ownMembershipId: string;
/** Décomptes des trois files, pour les onglets. */
counts: Record<AbsenceQueue, number>;
}
const QUEUE_STATUSES: Record<
AbsenceQueue,
Array<AbsenceRequest['status']>
> = {
pending: ['PENDING'],
treated: ['ACCEPTED', 'DECLINED', 'CANCELLED'],
expired: ['EXPIRED'],
};
export async function getAbsenceQueue(
queue: AbsenceQueue,
filters: QueueFilters = {},
): Promise<AbsenceQueueResult> {
return query('timeoff.view_own', async (db, actor) => {
const canSeeOthers = can(actor, 'timeoff.view_others');
const canDecide = can(actor, 'timeoff.decide');
const canSeeMedical = can(actor, 'members.documents.view');
const scope = canSeeOthers ? {} : { membershipId: actor.membershipId };
const rows = await db.timeOff.findMany({
where: {
...scope,
status: { in: QUEUE_STATUSES[queue] },
...(filters.typeId ? { absenceTypeId: filters.typeId } : {}),
...(filters.socialSecurityOnly
? { absenceType: { isSocialSecurity: true } }
: {}),
},
include: { absenceType: true },
// La file à traiter se lit par ancienneté — la plus vieille demande est
// celle qui coûte le plus au salarié qui attend. Les files closes se
// lisent à l'envers : on y cherche ce qui vient d'être décidé.
orderBy:
queue === 'pending'
? { requestedAt: 'asc' }
: { startDate: 'desc' },
take: 500,
});
const memberIds = [...new Set(rows.map((entry) => entry.membershipId))];
const members = await db.membership.findMany({
where: { id: { in: memberIds } },
include: { profile: { select: { firstName: true, lastName: true } } },
});
const nameById = new Map(
members.map((member) => [
member.id,
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
]),
);
const counts = await db.timeOff.groupBy({
by: ['status'],
where: scope,
_count: { _all: true },
});
const byStatus = new Map(
counts.map((row) => [row.status, row._count._all] as const),
);
const total = (queueKey: AbsenceQueue) =>
QUEUE_STATUSES[queueKey].reduce(
(sum, status) => sum + (byStatus.get(status) ?? 0),
0,
);
const absenceTypes = await db.absenceType.findMany({
where: { archivedAt: null },
select: { id: true, code: true, name: true },
orderBy: { code: 'asc' },
});
return {
queue,
rows: rows.map((entry) => ({
id: entry.id,
membershipId: entry.membershipId,
name: nameById.get(entry.membershipId) ?? 'Salarié',
typeLabel:
entry.absenceType.isSocialSecurity && !canSeeMedical
? 'Absence'
: entry.absenceType.name,
colorKey: entry.absenceType.colorKey,
isSocialSecurity: entry.absenceType.isSocialSecurity,
startDate: entry.startDate.toISOString().slice(0, 10),
endDate: entry.endDate.toISOString().slice(0, 10),
startHalfDay: entry.startHalfDay,
endHalfDay: entry.endHalfDay,
days: Number(entry.countedDays?.toString() ?? '0'),
status: entry.status,
comment:
entry.absenceType.isSocialSecurity && !canSeeMedical
? null
: entry.comment,
decisionComment: entry.decisionComment,
requestedAt: entry.requestedAt,
})),
absenceTypes,
canDecide,
ownMembershipId: actor.membershipId,
counts: {
pending: total('pending'),
treated: total('treated'),
expired: total('expired'),
},
};
});
}
/** Absences acceptées d'une période, pour la grille de planning. */ /** Absences acceptées d'une période, pour la grille de planning. */
export async function acceptedAbsencesBetween( export async function acceptedAbsencesBetween(
membershipIds: string[], membershipIds: string[],
+232
View File
@@ -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);
}
+23
View File
@@ -147,6 +147,22 @@ export async function getWeekBoard(
schedules.map((schedule) => [schedule.teamId, schedule]), 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({ const shifts = await db.shift.findMany({
where: { where: {
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) }, weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
@@ -236,6 +252,13 @@ export async function getWeekBoard(
location.timezone, location.timezone,
isPublished, isPublished,
absenceInputs, absenceInputs,
rests
.filter((rest) => rest.weeklyScheduleId === schedule?.id)
.map((rest) => ({
membershipId: rest.membershipId,
restType: rest.restType,
minutes: rest.minutes,
})),
); );
const teamAlerts = alerts const teamAlerts = alerts
+147
View File
@@ -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,
};
});
}
+266
View File
@@ -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');
}
+385
View File
@@ -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 };
}
+306
View File
@@ -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 };
}
+208
View File
@@ -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');
}
+230
View File
@@ -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');
}
+300
View File
@@ -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');
}
+217
View File
@@ -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');
}
+9 -9
View File
@@ -55,7 +55,7 @@ function nextWeekday(weekday: number): string {
* attente » et « acceptée » bloquent. * attente » et « acceptée » bloquent.
*/ */
async function release(page: Page, marker: string, month?: string) { 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 const pending = page
.locator('section') .locator('section')
@@ -117,7 +117,7 @@ async function request(
test('le formulaire nomme le dernier jour d’absence, pas la date de fin', async ({ test('le formulaire nomme le dernier jour d’absence, pas la date de fin', async ({
page, page,
}) => { }) => {
await page.goto('/conges'); await page.goto('/absences/calendrier');
// Le libellé exact coûte moins cher qu'une régularisation de solde. // Le libellé exact coûte moins cher qu'une régularisation de solde.
await expect( await expect(
page.getByText('Dernier jour d’absence', { exact: true }), 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 }) => { 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 from = isoDate(0);
const to = isoDate(4); 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 // 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 // salarié » : les passages précédents en laissent d'autres dans le même
// mois, et certaines n'offrent plus de bouton d'annulation. // 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 const accepted = page
.locator('section') .locator('section')
.filter({ hasText: 'Absences du mois' }) .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 }) => { test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
await page.goto('/conges'); await page.goto('/absences/calendrier');
await release(page, 'Chevauchement '); 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 }) => { 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. // Un dimanche seul : jamais ouvrable, donc rien à décompter.
const sunday = nextWeekday(0); 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 }) => { 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 // 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 // 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 ({ test('une date de fin antérieure au début rappelle la règle', async ({
page, page,
}) => { }) => {
await page.goto('/conges'); await page.goto('/absences/calendrier');
const form = await request(page, { const form = await request(page, {
from: isoDate(22), 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 }) => { 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' }); const counters = page.locator('section').filter({ hasText: 'Compteurs' });
await expect(counters).toBeVisible(); await expect(counters).toBeVisible();
+1 -1
View File
@@ -52,7 +52,7 @@ test('une entrée de navigation non construite mène à un écran explicite', as
page, page,
}) => { }) => {
// La barre latérale n'affiche que la section courante : on l'ouvre d'abord. // La barre latérale n'affiche que la section courante : on l'ouvre d'abord.
await page.goto('/conges'); await page.goto('/absences/calendrier');
// « Politiques de congés » fait partie du périmètre visé mais n'est pas // « Politiques de congés » fait partie du périmètre visé mais n'est pas
// encore construit : l'entrée reste, et mène à un écran qui le dit. // encore construit : l'entrée reste, et mène à un écran qui le dit.
await page.getByRole('link', { name: 'Politiques de congés' }).click(); await page.getByRole('link', { name: 'Politiques de congés' }).click();
+3
View File
@@ -50,6 +50,7 @@ describe('computeWeekCounters', () => {
absenceMinutes: h(18), absenceMinutes: h(18),
sundaysWorked: 0, sundaysWorked: 0,
restDays: 5, restDays: 5,
compensatoryRestMinutes: 0,
}); });
expect(counters.deltaMinutes).toBe(0); expect(counters.deltaMinutes).toBe(0);
@@ -64,6 +65,7 @@ describe('computeWeekCounters', () => {
absenceMinutes: 0, absenceMinutes: 0,
sundaysWorked: 2, sundaysWorked: 2,
restDays: 2, restDays: 2,
compensatoryRestMinutes: 0,
}); });
expect(counters.deltaLabel).toBe('+1 h 30'); expect(counters.deltaLabel).toBe('+1 h 30');
@@ -77,6 +79,7 @@ describe('computeWeekCounters', () => {
absenceMinutes: 0, absenceMinutes: 0,
sundaysWorked: 0, sundaysWorked: 0,
restDays: 2, restDays: 2,
compensatoryRestMinutes: 0,
}); });
expect(counters.deltaLabel).toBe('−1 h 45'); expect(counters.deltaLabel).toBe('−1 h 45');
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import {
MissingTemplateValue,
referencedFields,
renderTemplate,
TEMPLATE_FIELD_KEYS,
unknownFields,
} from '@/domain/documents/template';
describe('referencedFields', () => {
it('relève les variables citées, sans doublon', () => {
const body = '<p>{{salarie.nom}} — {{salarie.nom}} {{contrat.debut}}</p>';
expect(referencedFields(body)).toEqual(['salarie.nom', 'contrat.debut']);
});
it('tolère les espaces dans les accolades', () => {
expect(referencedFields('{{ salarie.nom }}')).toEqual(['salarie.nom']);
});
});
describe('unknownFields', () => {
it('signale une variable que l’application ne sait pas résoudre', () => {
// Le cas qui compte : la faute de frappe. Sans ce contrôle, le modèle
// s'enregistre et échoue au moment de générer la pièce, devant le salarié.
expect(unknownFields('{{salarie.salire}}')).toEqual(['salarie.salire']);
});
it('accepte toutes les variables du catalogue', () => {
const body = TEMPLATE_FIELD_KEYS.map((key) => `{{${key}}}`).join(' ');
expect(unknownFields(body)).toEqual([]);
});
});
describe('renderTemplate', () => {
it('remplace les variables par leur valeur', () => {
const out = renderTemplate('<p>{{salarie.nom}}</p>', {
'salarie.nom': 'Ferrand',
});
expect(out).toBe('<p>Ferrand</p>');
});
it('échappe la valeur, jamais le gabarit', () => {
// Le corps est rédigé par un administrateur et reste de l'HTML ; la valeur
// vient du dossier et pourrait porter un chevron.
const out = renderTemplate('<p><strong>{{salarie.nom}}</strong></p>', {
'salarie.nom': '<script>alert(1)</script>',
});
expect(out).toBe(
'<p><strong>&lt;script&gt;alert(1)&lt;/script&gt;</strong></p>',
);
});
it('refuse de rendre une variable sans valeur', () => {
// Une attestation trouée est un document faux, pas un document incomplet :
// le blanc se remarque une fois sur deux, et la pièce part signée.
expect(() =>
renderTemplate('{{salarie.nom}} {{contrat.debut}}', {
'salarie.nom': 'Ferrand',
}),
).toThrow(MissingTemplateValue);
});
it('traite la chaîne vide comme une valeur manquante', () => {
expect(() =>
renderTemplate('{{salarie.nom}}', { 'salarie.nom': '' }),
).toThrow(MissingTemplateValue);
});
it('nomme les variables manquantes dans l’erreur', () => {
try {
renderTemplate('{{salarie.nom}} {{contrat.debut}}', {});
expect.unreachable('le rendu aurait dû échouer');
} catch (error) {
expect(error).toBeInstanceOf(MissingTemplateValue);
expect((error as MissingTemplateValue).keys).toEqual([
'salarie.nom',
'contrat.debut',
]);
}
});
});
+5 -2
View File
@@ -21,7 +21,9 @@ describe('matches', () => {
}); });
it('écarte les liens d’ancre', () => { it('écarte les liens d’ancre', () => {
expect(matches('/conges#attente', '/conges')).toBe(false); expect(
matches('/absences/calendrier#compteurs', '/absences/calendrier'),
).toBe(false);
}); });
}); });
@@ -61,7 +63,8 @@ describe('sectionForPath', () => {
it('trouve la section portant la route courante', () => { it('trouve la section portant la route courante', () => {
expect(sectionForPath('/planning/semaine').id).toBe('plannings'); expect(sectionForPath('/planning/semaine').id).toBe('plannings');
expect(sectionForPath('/equipe/camille-ferrand').id).toBe('equipe'); expect(sectionForPath('/equipe/camille-ferrand').id).toBe('equipe');
expect(sectionForPath('/conges').id).toBe('conges'); expect(sectionForPath('/absences/calendrier').id).toBe('conges');
expect(sectionForPath('/absences/a-traiter').id).toBe('conges');
}); });
it('retombe sur la première section pour une route inconnue', () => { it('retombe sur la première section pour une route inconnue', () => {
+22
View File
@@ -194,6 +194,28 @@ describe('buildRows', () => {
'14:00–19:00', '14:00–19:00',
]); ]);
}); });
it('ne compte au RC que le repos compensateur', () => {
// Le repos hebdomadaire est un droit déjà pris ; le compensateur est une
// contrepartie due. Les additionner ferait disparaître la dette dans un
// total qui ne veut rien dire.
const { rows } = buildRows([person()], [], DATES, TZ, false, [], [
{ membershipId: 'm1', restType: 'COMPENSATORY_REST', minutes: 420 },
{ membershipId: 'm1', restType: 'WEEKLY_REST', minutes: 2100 },
]);
expect(rows[0]?.counters.compensatoryRestMinutes).toBe(420);
});
it('tolère un repos sans durée', () => {
// `Rest.minutes` est nullable : un repos posé sans durée vaut zéro minute,
// pas NaN — un NaN se propagerait au bandeau et à l'export.
const { rows } = buildRows([person()], [], DATES, TZ, false, [], [
{ membershipId: 'm1', restType: 'COMPENSATORY_REST', minutes: null },
]);
expect(rows[0]?.counters.compensatoryRestMinutes).toBe(0);
});
}); });
describe('shiftState', () => { describe('shiftState', () => {
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import type { BoardRow } from '@/domain/planning/board';
import {
absenceOnDay,
dayState,
headcountByDay,
} from '@/domain/planning/presence';
function row(overrides: Partial<BoardRow> = {}): BoardRow {
return {
membershipId: 'm1',
firstName: 'Camille',
lastName: 'Ferrand',
job: 'Vendeuse',
forfaitJours: false,
counters: {
contractMinutes: 0,
plannedMinutes: 0,
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
compensatoryRestMinutes: 0,
},
days: [[], [], [], [], [], [], []],
absences: [],
unassigned: false,
...overrides,
};
}
const shift: BoardRow['days'][number][number] = {
id: 's1',
poste: 'cai',
time: '09:00–17:00',
minutes: 480,
breakMinutes: 30,
state: 'published',
note: null,
};
describe('dayState', () => {
it('dit « repos » sur une case vide', () => {
expect(dayState(row(), 0)).toBe('off');
});
it('dit « présent » quand la case porte un créneau', () => {
const days: BoardRow['days'] = [[shift], [], [], [], [], [], []];
expect(dayState(row({ days }), 0)).toBe('present');
});
it('fait primer l’absence sur le créneau resté au planning', () => {
// Le cas qui compte : le congé est validé après la construction de la
// semaine, les créneaux n'ont pas été retirés. Afficher « présent »
// enverrait un responsable chercher quelqu'un qui est chez lui.
const days: BoardRow['days'] = [[shift], [], [], [], [], [], []];
const absences = [
{ label: 'Congé payé', colorKey: 'neutral', startDay: 0, span: 2 },
];
expect(dayState(row({ days, absences }), 0)).toBe('absent');
});
});
describe('absenceOnDay', () => {
const absences = [
{ label: 'Congé payé', colorKey: 'neutral', startDay: 2, span: 3 },
];
it('couvre le premier et le dernier jour de la plage', () => {
// La borne haute est le dernier jour d'absence, pas le jour de reprise :
// `startDay + span` est donc exclu.
expect(absenceOnDay(row({ absences }), 2)?.label).toBe('Congé payé');
expect(absenceOnDay(row({ absences }), 4)?.label).toBe('Congé payé');
});
it('ne déborde ni avant ni après', () => {
expect(absenceOnDay(row({ absences }), 1)).toBeUndefined();
expect(absenceOnDay(row({ absences }), 5)).toBeUndefined();
});
});
describe('headcountByDay', () => {
it('compte présents, absents et repos sur chaque colonne', () => {
const present = row({
membershipId: 'a',
days: [[shift], [shift], [], [], [], [], []],
});
const absent = row({
membershipId: 'b',
absences: [
{ label: 'Maladie', colorKey: 'neutral', startDay: 0, span: 1 },
],
});
const tally = headcountByDay([present, absent], 7);
expect(tally[0]).toEqual({ present: 1, absent: 1, off: 0 });
expect(tally[1]).toEqual({ present: 1, absent: 0, off: 1 });
expect(tally[6]).toEqual({ present: 0, absent: 0, off: 2 });
});
it('écarte la ligne des besoins non couverts', () => {
// Elle ne porte personne : la compter en repos gonflerait un effectif qui
// n'existe pas.
const unassigned = row({ membershipId: null, unassigned: true });
expect(headcountByDay([unassigned], 7)[0]).toEqual({
present: 0,
absent: 0,
off: 0,
});
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
minutesToTime,
PREFERENCE_KEYS,
PREFERENCE_TOGGLES,
timeToMinutes,
togglesOf,
} from '@/domain/settings/preferences';
describe('catalogue de préférences', () => {
it('n’a pas deux fois la même clé', () => {
expect(new Set(PREFERENCE_KEYS).size).toBe(PREFERENCE_KEYS.length);
});
it('range chaque préférence dans un seul groupe', () => {
// Les groupes se déduisent du catalogue plutôt que d'être listés ici : une
// liste tenue à la main laisserait un groupe entier sans écran, et le test
// passerait quand même.
const groups = [
...new Set(PREFERENCE_TOGGLES.map((toggle) => toggle.group)),
];
const grouped = groups.flatMap((group) => togglesOf(group));
expect(grouped).toHaveLength(PREFERENCE_TOGGLES.length);
});
it('avertit sur les réglages qui changent un calcul', () => {
// Un interrupteur qui déplace des heures entre normales et majorées ne doit
// pas se présenter comme un réglage d'affichage.
for (const key of [
'smoothOvertimeMonthly',
'includeRestInNormalHours',
'paidBreaks',
]) {
const toggle = PREFERENCE_TOGGLES.find((entry) => entry.key === key);
expect(toggle?.warning, `${key} sans avertissement`).toBeTruthy();
}
});
});
describe('heure de bascule du soir', () => {
it('convertit dans les deux sens', () => {
expect(minutesToTime(1200)).toBe('20:00');
expect(timeToMinutes('20:00')).toBe(1200);
expect(minutesToTime(0)).toBe('00:00');
expect(timeToMinutes('00:00')).toBe(0);
});
it('refuse une heure invalide plutôt que de la ramener à zéro', () => {
// Silencieusement ramenée à minuit, une saisie fautive ferait passer tous
// les créneaux pour des créneaux du soir.
expect(timeToMinutes('24:00')).toBeNull();
expect(timeToMinutes('20:60')).toBeNull();
expect(timeToMinutes('8:00')).toBeNull();
expect(timeToMinutes('')).toBeNull();
});
});