diff --git a/prisma/migrations/20260811120000_document_templates/migration.sql b/prisma/migrations/20260811120000_document_templates/migration.sql new file mode 100644 index 0000000..f0e9641 --- /dev/null +++ b/prisma/migrations/20260811120000_document_templates/migration.sql @@ -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()); diff --git a/prisma/migrations/20260811130000_account_preferences/migration.sql b/prisma/migrations/20260811130000_account_preferences/migration.sql new file mode 100644 index 0000000..7b96f74 --- /dev/null +++ b/prisma/migrations/20260811130000_account_preferences/migration.sql @@ -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()); diff --git a/prisma/migrations/20260811140000_print_productivity/migration.sql b/prisma/migrations/20260811140000_print_productivity/migration.sql new file mode 100644 index 0000000..3e71299 --- /dev/null +++ b/prisma/migrations/20260811140000_print_productivity/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 970e028..d9f48e3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,14 @@ model Account { name String siren String? apeCode String? + /// Siège social. Il figure sur les documents édités et au registre unique du + /// personnel ; il n'est pas déduit de l'établissement, qui peut être ailleurs. + addressLine String? + postalCode String? + city String? + country String @default("FR") + /// Fuseau du compte. Un établissement peut le surcharger (Location.timezone). + timezone String @default("Europe/Paris") collectiveAgreementId String? /// Surcharges d'accord d'entreprise (PLAN.md §6.3). Vide aujourd'hui : /// l'organisation auditée n'a pas d'accord d'entreprise. @@ -34,8 +42,91 @@ model Account { jobTitles JobTitle[] labels Label[] absenceTypes AbsenceType[] + documentTemplates DocumentTemplate[] legalConfig LegalConfigEntry[] installation Installation? + preferences AccountPreferences? +} + +/// Préférences du compte — PLAN.md §9, réglages « préférences » et « paie ». +/// +/// Trois blocs typés plutôt qu'un sac de clés-valeurs. Ce n'est pas de la +/// rigueur pour la rigueur : `smoothOvertimeMonthly` change le **calcul** des +/// heures supplémentaires, et un réglage qui change un calcul doit être lisible +/// dans le schéma, migrable, et impossible à renseigner de travers. Un JSON +/// libre rendrait indétectable une clé mal orthographiée — la préférence +/// paraîtrait active et ne le serait pas. +/// +/// Une ligne par compte, créée à la demande : l'absence de ligne vaut « tous +/// les défauts », et les défauts sont ceux du modèle. +model AccountPreferences { + accountId String @id + + // --- Plannings --------------------------------------------------------- + /// Compter un repas par créneau sans saisie. + defaultMealPerShift Boolean @default(false) + /// Pauses payées : elles cessent alors d'être déduites du temps de travail. + paidBreaks Boolean @default(false) + /// Interdire au salarié de modifier son repas. + lockEmployeeMeals Boolean @default(false) + /// Le salarié voit son total d'heures planifiées et réelles de la semaine. + employeesSeeOwnTotals Boolean @default(true) + /// Le salarié voit le planning des autres membres de son équipe. + employeesSeeTeamPlanning Boolean @default(false) + /// Masquer les compteurs — et les alertes — des salariés au forfait jours. + /// Un forfait jours ne se compte pas en heures : afficher un écart + /// hebdomadaire sur ces lignes produit une alerte qui n'a pas de sens. + hideForfaitCounters Boolean @default(true) + /// Heure à partir de laquelle un créneau est « du soir », en minutes depuis + /// minuit. 1200 = 20:00. + eveningShiftStartMinutes Int @default(1200) + + // --- Droits ------------------------------------------------------------ + employeesEditOwnProfile Boolean @default(true) + employeesSeeOwnTimesheets Boolean @default(true) + /// Annuaire ouvert : téléphone et adresse e-mail de toute l'équipe. + employeesSeeTeamContacts Boolean @default(false) + /// Un salarié créé par un manager est planifiable sans validation. + managerCreatedArePlannable Boolean @default(false) + directorsSharePeople Boolean @default(false) + directorsAccessHrDashboard Boolean @default(true) + directorsSeePaidLeave Boolean @default(true) + managersSeePaidLeave Boolean @default(false) + + // --- Paie -------------------------------------------------------------- + /// Décompter les heures supplémentaires au mois plutôt qu'à la semaine. + /// + /// **Risque juridique.** Le décompte hebdomadaire est le principe (art. + /// L3121-28) ; lisser au mois suppose un accord d'aménagement du temps de + /// travail. Faux par défaut, et l'écran le dit. + smoothOvertimeMonthly Boolean @default(false) + /// Inclure repos et absences dans les heures normales. + includeRestInNormalHours Boolean @default(false) + /// Générer le matricule à la création plutôt que de le saisir. + autoEmployeeNumber Boolean @default(false) + + // --- Impression -------------------------------------------------------- + /// Paysage par défaut : une semaine tient en largeur, pas en hauteur. + printLandscape Boolean @default(true) + /// Densité : « large » lisible à distance, « compact » pour un gros effectif. + printDensity String @default("large") + printContractTotals Boolean @default(true) + /// Créneaux des autres équipes, pour situer une journée d'ensemble. + printOtherTeams Boolean @default(false) + printSunday Boolean @default(true) + /// Colonne d'émargement — une signature manuscrite ne vaut pas décompte du + /// temps de travail, mais reste demandée pour l'affichage en salle. + printSignatureColumn Boolean @default(true) + + // --- Productivité ------------------------------------------------------ + /// Objectif de chiffre d'affaires par heure travaillée, en euros. + /// Nul tant qu'aucun objectif n'est fixé : zéro serait un objectif, pas une + /// absence d'objectif. + productivityTargetPerHour Decimal? @db.Decimal(10, 2) + + updatedAt DateTime @updatedAt + + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) } /// Marqueur d'installation de l'instance — une ligne, jamais deux. @@ -792,6 +883,33 @@ model DailyNote { /// pouvoir reproduire à l'identique une paie antérieure après un changement de /// règle. Une mise à jour en place rendrait cette reproduction impossible, et /// l'impossibilité ne se découvrirait qu'au premier contrôle. +/// Modèle de document — PLAN.md §4.7 et §9. +/// +/// `bodyHtml` porte le corps avec ses variables sous la forme `{{cle}}`. +/// `availableFields` déclare les variables offertes à la rédaction : elles sont +/// **scopées par établissement**, si bien qu'un modèle rendu pour un salarié +/// résout ses variables dans le contexte de l'établissement de son contrat. Une +/// même attestation ne porte donc pas la même raison sociale d'un magasin à +/// l'autre. +/// +/// Archivé plutôt que supprimé : un document généré garde `templateId`, et +/// perdre le modèle rendrait inexplicable la forme d'une pièce déjà remise. +model DocumentTemplate { + id String @id @default(cuid()) + accountId String + name String + bodyHtml String + availableFields Json + archivedAt DateTime? + createdAt DateTime @default(now()) + + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + documents Document[] + + @@unique([accountId, name]) + @@index([accountId]) +} + /// Pièce du dossier salarié — PLAN.md §4.7. /// /// Le contenu vit sur disque, **chiffré**, jamais dans la base : une pièce @@ -811,6 +929,8 @@ model Document { /// Empreinte du clair, pour détecter une altération du fichier stocké. checksum String /// Donnée de santé : sa lecture est journalisée (§3.6). + /// Modèle dont la pièce est issue. Nul pour un fichier déposé à la main. + templateId String? isSensitive Boolean @default(false) /// Échéance de conservation. Nulle tant qu'aucune politique ne l'a fixée : /// le plan interdit d'appliquer une durée par défaut (§12.5). @@ -820,7 +940,8 @@ model Document { deletedAt DateTime? deletedBy String? - membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade) + membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade) + template DocumentTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull) @@index([accountId]) @@index([membershipId]) diff --git a/prisma/seed-data/silae-frouard.ts b/prisma/seed-data/silae-frouard.ts new file mode 100644 index 0000000..ab8ae38 --- /dev/null +++ b/prisma/seed-data/silae-frouard.ts @@ -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 = { + // 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 = { + 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' }, +]; diff --git a/prisma/seed.ts b/prisma/seed.ts index b143160..4150b2b 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -22,6 +22,11 @@ import { } from '../src/domain/compliance/idcc1517'; import { frenchHolidays } from '../src/domain/absences/holidays'; import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements'; +import { + ABSENCE_CODES, + ELEMENT_CODES, + INCOMPLETE_IN_DOSSIER, +} from './seed-data/silae-frouard'; import { evaluateSchedule } from '../src/server/compliance/evaluate'; import { withTenant } from '../src/server/tenant'; @@ -586,9 +591,10 @@ async function main() { console.log('→ Types d’absence'); - // `silaeCode` reste **null** : les codes AB-* du dossier sont connus, leur - // signification ne l'est pas. Les associer ici reviendrait à deviner à quel - // type d'absence correspond AB-300, ce qui produirait une paie fausse. + // `silaeCode` vient du paramétrage Silae du dossier, relevé le 11 août 2026 + // (voir `seed-data/silae-frouard.ts`). Il n'a rien d'universel : la + // correspondance entre AB-300 et « congés payés » appartient à ce dossier, et + // un autre client du même cabinet peut la numéroter autrement. const absenceTypes = [ { code: 'CP', name: 'Congés payés', colorKey: 'cp', isPaid: true, social: false, notice: 30 }, { code: 'RTT', name: 'RTT', colorKey: 'rtt', isPaid: true, social: false, notice: 7 }, @@ -600,12 +606,17 @@ async function main() { for (const type of absenceTypes) { await prisma.absenceType.upsert({ where: { accountId_code: { accountId: account.id, code: type.code } }, - update: { name: type.name, colorKey: type.colorKey }, + update: { + name: type.name, + colorKey: type.colorKey, + silaeCode: ABSENCE_CODES[type.code] ?? null, + }, create: { accountId: account.id, code: type.code, name: type.name, colorKey: type.colorKey, + silaeCode: ABSENCE_CODES[type.code] ?? null, isPaid: type.isPaid, countsAsWorkTime: false, affectsPaidLeaveAccrual: !type.social, @@ -709,12 +720,18 @@ async function main() { console.log(` ${demoAbsences.length} absences`); console.log('→ Correspondances Silae'); - // Semées **non confirmées**, y compris quand le code se lit dans son libellé - // (`EV-HDimanche`). Proposer n'est pas confirmer : seul le gestionnaire de - // paie sait si le code est le bon dans ce dossier, et l'export refuse de - // tourner tant qu'il ne l'a pas dit. + // Les codes viennent du paramétrage du dossier, actif et coché côté Combo : + // ils sont donc semés **confirmés**. Un code déduit d'un libellé le serait + // non confirmé — proposer n'est pas confirmer — mais ceux-ci sont relevés + // dans la configuration réelle, pas devinés. + // + // Ce qui n'a pas de code au dossier reste absent plutôt que semé à vide : + // l'export refuse alors de tourner en listant les manques, ce qui est le + // comportement voulu (§8). + let seededMappings = 0; for (const definition of PAYROLL_ELEMENT_DEFINITIONS) { - if (!definition.suggestedCode) continue; + const code = ELEMENT_CODES[definition.key] ?? definition.suggestedCode; + if (!code) continue; const existing = await prisma.silaeCodeMapping.findFirst({ where: { accountId: account.id, sourceKey: definition.key }, }); @@ -724,16 +741,29 @@ async function main() { data: { accountId: account.id, sourceKey: definition.key, - silaeCode: definition.suggestedCode, + silaeCode: code, label: definition.label, kind: definition.kind, - confirmed: false, + confirmed: Boolean(ELEMENT_CODES[definition.key]), }, }); + seededMappings += 1; } - console.log( - ` ${PAYROLL_ELEMENT_DEFINITIONS.filter((d) => d.suggestedCode).length} proposées, aucune confirmée`, + const missingElements = PAYROLL_ELEMENT_DEFINITIONS.filter( + (definition) => + !ELEMENT_CODES[definition.key] && !definition.suggestedCode, ); + console.log(` ${seededMappings} correspondances`); + if (missingElements.length > 0) { + console.log( + ` ⚠ sans code au dossier : ${missingElements.map((d) => d.label).join(', ')}`, + ); + } + if (INCOMPLETE_IN_DOSSIER.length > 0) { + console.log( + ` ⚠ ${INCOMPLETE_IN_DOSSIER.length} rubriques incomplètes côté Silae (préfixe « AB- » sans numéro) : ${INCOMPLETE_IN_DOSSIER.map((row) => row.label).join(', ')}`, + ); + } console.log('→ Matricules Silae'); // Fictifs, à la forme observée dans le dossier : cinq chiffres cadrés à zéro. diff --git a/src/app/(app)/absences/a-traiter/page.tsx b/src/app/(app)/absences/a-traiter/page.tsx new file mode 100644 index 0000000..7104904 --- /dev/null +++ b/src/app/(app)/absences/a-traiter/page.tsx @@ -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 ( + + 1 ? 's' : ''} en attente, la plus ancienne en premier` + } + /> + + + +

+ 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. +

+
+ ); +} diff --git a/src/app/(app)/absences/calendrier/page.tsx b/src/app/(app)/absences/calendrier/page.tsx new file mode 100644 index 0000000..2b5cb81 --- /dev/null +++ b/src/app/(app)/absences/calendrier/page.tsx @@ -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 = { + PAID_LEAVE: 'Congés payés', + RTT: 'RTT', + COMPENSATORY_REST: 'Repos compensateur', + MODULATION: 'Modulation', + OVERTIME: 'Heures supplémentaires', +}; + +const STATUS_TONES: Record = { + 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 ( + + 1 ? 's' : ''} sur le mois`} + actions={ + <> + + ← Mois précédent + + + Mois suivant → + + + } + /> + + + +
+

+ Demandes en attente + {board.pending.length > 0 ? ` · ${board.pending.length}` : ''} +

+ + {board.pending.length === 0 ? ( +

+ Aucune demande à traiter. +

+ ) : ( +
    + {board.pending.map((request) => ( +
  • +
    +

    + {request.name} · {request.typeLabel} +

    +

    + du {frenchDate(request.startDate)} au{' '} + {frenchDate(request.endDate)} ·{' '} + {request.days} jour + {request.days > 1 ? 's' : ''} décompté + {request.days > 1 ? 's' : ''} +

    + {request.comment ? ( +

    + « {request.comment} » +

    + ) : null} +
    + + {board.canDecide ? ( + + ) : ( + + )} +
  • + ))} +
+ )} +
+ + + +
+

+ Absences du mois +

+ {board.inMonth.length === 0 ? ( +

+ Aucune absence sur cette période. +

+ ) : ( +
    + {board.inMonth.map((request) => ( +
  • + + {statusLabel(request.status)} + + {request.name} + {request.typeLabel} + + {frenchDate(request.startDate)} → {frenchDate(request.endDate)} + + + {request.days} j + + {request.status === 'ACCEPTED' ? ( + + ) : null} +
  • + ))} +
+ )} +
+ +
+

+ Compteurs +

+ {board.counters.length === 0 ? ( +

+ Aucun compteur ouvert. Un compteur se crée à la première écriture — + acceptation d’un congé ou ajustement manuel. +

+ ) : ( + + + + + + + + + + + + {board.counters.map((counter) => ( + + + + + + + + ))} + +
+ Salarié + + Compteur + + Acquis + + Pris + + Solde +
{counter.name} + {COUNTER_LABELS[counter.counterType] ?? counter.counterType} + + {counter.accrued} + + {counter.taken} + + {counter.balance} +
+ )} +

+ 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. +

+
+
+ ); +} + +/** Calendrier du mois, une colonne par jour. */ +function MonthCalendar({ + board, +}: { + board: Awaited>; +}) { + 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 ( +
+ + + + + + {board.days.map((date) => ( + + ))} + + + + {people.map((membershipId) => { + const own = accepted.filter( + (request) => request.membershipId === membershipId, + ); + const covered = new Map(); + for (const request of own) { + for (const date of datesBetween( + request.startDate, + request.endDate, + )) { + covered.set(date, request); + } + } + + return ( + + + {board.days.map((date) => { + const request = covered.get(date); + return ( + + ); + })} + + ); + })} + +
+ Calendrier des absences, {board.label} +
+ Salarié + = 6 + ? 'bg-surface-3 text-ink-2' + : 'text-ink-1', + )} + > + {Number(date.slice(8, 10))} +
+ {own[0]?.name ?? ''} + = 6 && 'bg-surface-2', + )} + title={ + request + ? `${request.typeLabel} — ${statusLabel(request.status)}` + : undefined + } + > + {request ? ( + + + {request.typeLabel} le {date} + + + ) : null} +
+
+ ); +} + +function frenchDate(isoDate: string): string { + return isoDate.split('-').reverse().join('/'); +} diff --git a/src/app/(app)/absences/expirees/page.tsx b/src/app/(app)/absences/expirees/page.tsx new file mode 100644 index 0000000..5be11ac --- /dev/null +++ b/src/app/(app)/absences/expirees/page.tsx @@ -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 ( + + + + + +

+ 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. +

+
+ ); +} diff --git a/src/app/(app)/absences/traitees/page.tsx b/src/app/(app)/absences/traitees/page.tsx new file mode 100644 index 0000000..2a2a113 --- /dev/null +++ b/src/app/(app)/absences/traitees/page.tsx @@ -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 ( + + {label} + + ); + }; + + return ( + + + + + {tab(null, 'Toutes les absences')} + {tab('ss', 'Uniquement Sécurité sociale')} + + } + /> + +

+ 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. +

+
+ ); +} diff --git a/src/app/(app)/conges/page.tsx b/src/app/(app)/conges/page.tsx index 7ea91f6..79e6015 100644 --- a/src/app/(app)/conges/page.tsx +++ b/src/app/(app)/conges/page.tsx @@ -1,332 +1,22 @@ -import Link from 'next/link'; +import { permanentRedirect } from 'next/navigation'; -import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons'; -import { RequestForm } from '@/components/absences/RequestForm'; -import { PageBody, PageHeader } from '@/components/shell/PageHeader'; -import { Badge, type Tone } from '@/components/ui/Badge'; -import { datesBetween, isoWeekday } from '@/domain/absences/count'; -import { monthOf, parseMonthParam } from '@/domain/planning/month'; -import { cx } from '@/lib/cx'; -import { getAbsenceBoard, statusLabel } from '@/server/absences/queries'; - -export const metadata = { title: 'Calendrier des absences · PlanFlow' }; - -interface PageProps { - searchParams: Promise<{ mois?: string }>; -} - -const COUNTER_LABELS: Record = { - PAID_LEAVE: 'Congés payés', - RTT: 'RTT', - COMPENSATORY_REST: 'Repos compensateur', - MODULATION: 'Modulation', - OVERTIME: 'Heures supplémentaires', -}; - -const STATUS_TONES: Record = { - 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 ( - - 1 ? 's' : ''} sur le mois`} - actions={ - <> - - ← Mois précédent - - - Mois suivant → - - - } - /> - - - -
-

- Demandes en attente - {board.pending.length > 0 ? ` · ${board.pending.length}` : ''} -

- - {board.pending.length === 0 ? ( -

- Aucune demande à traiter. -

- ) : ( -
    - {board.pending.map((request) => ( -
  • -
    -

    - {request.name} · {request.typeLabel} -

    -

    - du {frenchDate(request.startDate)} au{' '} - {frenchDate(request.endDate)} ·{' '} - {request.days} jour - {request.days > 1 ? 's' : ''} décompté - {request.days > 1 ? 's' : ''} -

    - {request.comment ? ( -

    - « {request.comment} » -

    - ) : null} -
    - - {board.canDecide ? ( - - ) : ( - - )} -
  • - ))} -
- )} -
- - - -
-

- Absences du mois -

- {board.inMonth.length === 0 ? ( -

- Aucune absence sur cette période. -

- ) : ( -
    - {board.inMonth.map((request) => ( -
  • - - {statusLabel(request.status)} - - {request.name} - {request.typeLabel} - - {frenchDate(request.startDate)} → {frenchDate(request.endDate)} - - - {request.days} j - - {request.status === 'ACCEPTED' ? ( - - ) : null} -
  • - ))} -
- )} -
- -
-

- Compteurs -

- {board.counters.length === 0 ? ( -

- Aucun compteur ouvert. Un compteur se crée à la première écriture — - acceptation d’un congé ou ajustement manuel. -

- ) : ( - - - - - - - - - - - - {board.counters.map((counter) => ( - - - - - - - - ))} - -
- Salarié - - Compteur - - Acquis - - Pris - - Solde -
{counter.name} - {COUNTER_LABELS[counter.counterType] ?? counter.counterType} - - {counter.accrued} - - {counter.taken} - - {counter.balance} -
- )} -

- 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. -

-
-
- ); -} - -/** Calendrier du mois, une colonne par jour. */ -function MonthCalendar({ - board, +/** + * Ancienne adresse du calendrier des absences. + * + * Les absences vivent désormais sous `/absences`, avec une route par état + * (§9). Le calendrier a déménagé plutôt que d'être dupliqué : deux écrans + * lisant le même modèle finissent toujours par diverger d'une correction. + * + * La redirection est **permanente** et conserve le mois demandé : un lien de + * planning partagé dans un message continue d'ouvrir la bonne page. + */ +export default async function CongesRedirect({ + searchParams, }: { - board: Awaited>; + searchParams: Promise<{ mois?: string }>; }) { - const accepted = board.inMonth.filter( - (request) => request.status === 'ACCEPTED' || request.status === 'PENDING', - ); - const people = [...new Set(accepted.map((request) => request.membershipId))]; - - if (people.length === 0) return null; - - return ( -
- - - - - - {board.days.map((date) => ( - - ))} - - - - {people.map((membershipId) => { - const own = accepted.filter( - (request) => request.membershipId === membershipId, - ); - const covered = new Map(); - for (const request of own) { - for (const date of datesBetween( - request.startDate, - request.endDate, - )) { - covered.set(date, request); - } - } - - return ( - - - {board.days.map((date) => { - const request = covered.get(date); - return ( - - ); - })} - - ); - })} - -
- Calendrier des absences, {board.label} -
- Salarié - = 6 - ? 'bg-surface-3 text-ink-2' - : 'text-ink-1', - )} - > - {Number(date.slice(8, 10))} -
- {own[0]?.name ?? ''} - = 6 && 'bg-surface-2', - )} - title={ - request - ? `${request.typeLabel} — ${statusLabel(request.status)}` - : undefined - } - > - {request ? ( - - - {request.typeLabel} le {date} - - - ) : null} -
-
+ const { mois } = await searchParams; + permanentRedirect( + mois ? `/absences/calendrier?mois=${mois}` : '/absences/calendrier', ); } - -function frenchDate(isoDate: string): string { - return isoDate.split('-').reverse().join('/'); -} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index e3597b8..ace6472 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -276,7 +276,7 @@ function EmployeeHome() {
  • diff --git a/src/app/(app)/planning/impression/page.tsx b/src/app/(app)/planning/impression/page.tsx new file mode 100644 index 0000000..f44f815 --- /dev/null +++ b/src/app/(app)/planning/impression/page.tsx @@ -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 ( +
  • + {shift.time} + {shift.poste.toUpperCase()} + {shift.breakMinutes > 0 ? ( + pause {shift.breakMinutes} min + ) : null} +
  • + ); +} + +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 ( + + + + ); + } + + // 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 ( + +
    + + + Retour à la grille + + + + } + /> +
    + +

    + {board.location.name} — semaine {week.isoWeek}, {board.label} +

    + + {hiddenDrafts > 0 && seeUnpublished ? ( +

    + {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. +

    + ) : null} + + {sections.length === 0 ? ( + + + + ) : null} + + {sections.map((section) => ( + + Publié + ) : ( + Brouillon + ) + } + /> + +
    + + + + + {board.headings.map((heading) => ( + + ))} + + + + {[ + ...section.rows, + ...(section.unassignedRow ? [section.unassignedRow] : []), + ].map((row) => ( + + + + {board.dates.map((date, index) => { + const absence = absenceOnDay(row, index); + const shifts = row.days[index] ?? []; + + return ( + + ); + })} + + ))} + +
    + Salarié + + {heading} +
    + {displayName(row)} + + {row.unassigned ? 'À pourvoir' : row.job} + + + {absence ? ( + + {absence.label} + + ) : shifts.length === 0 ? ( + — + ) : ( +
      + {shifts.map((shift) => ( + + ))} +
    + )} +
    +
    +
    + ))} + +

    + É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. +

    +
    + ); +} diff --git a/src/app/(app)/planning/presences/page.tsx b/src/app/(app)/planning/presences/page.tsx new file mode 100644 index 0000000..11d02db --- /dev/null +++ b/src/app/(app)/planning/presences/page.tsx @@ -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 = { + present: 'ok', + absent: 'warn', + off: 'neutral', +}; + +const STATE_LABEL: Record = { + 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 ( + + + + ); + } + + 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 ( + + + + ← Semaine précédente + + + Semaine suivante → + + + } + /> + + + +
    + + + + + {board.headings.map((heading) => ( + + ))} + + + + + + {perDay.map((day) => ( + + ))} + + + + {perDay.map((day) => ( + + ))} + + + + {perDay.map((day) => ( + + ))} + + +
    + Jour + + {heading} +
    Présents + {day.present} +
    Absents 0 ? 'text-warn-soft-ink' : 'text-ink-3', + )} + > + {day.absent} +
    Repos + {day.off} +
    +
    +
    + + {sections.length === 0 ? ( + + + + ) : null} + + {sections.map((section) => ( + + + {section.status === 'PUBLISHED' ? 'Publié' : 'Non publié'} + + } + /> + + {section.rows.length === 0 ? ( + + ) : ( +
    + + + + + {board.headings.map((heading) => ( + + ))} + + + + {section.rows.map((row) => ( + + + + {board.dates.map((date, index) => { + const state = dayState(row, index); + const absence = absenceOnDay(row, index); + return ( + + ); + })} + + ))} + +
    + Salarié + + {heading} +
    + {row.membershipId ? ( + + {displayName(row)} + + ) : ( + displayName(row) + )} + + {row.job} + + + + {absence ? absence.label : STATE_LABEL[state]} + +
    +
    + )} +
    + ))} + +

    + 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. +

    +
    + ); +} diff --git a/src/app/(app)/rapports/activite/page.tsx b/src/app/(app)/rapports/activite/page.tsx new file mode 100644 index 0000000..3bd3575 --- /dev/null +++ b/src/app/(app)/rapports/activite/page.tsx @@ -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) => ( + + {label} + + ); + + return ( + + 1 ? 's' : ''} sur la période`} + /> + + + 200 dernières affichées + ) : null + } + /> + +
    +
    + {chip(withParam('domaine', undefined), 'Tous les domaines', !params.domaine)} + {page.domains.map((domain) => + chip( + withParam('domaine', domain), + domain, + params.domaine === domain, + ), + )} +
    + +
    + {chip(withParam('objet', undefined), 'Tous les objets', !params.objet)} + {page.entityTypes.map((type) => + chip(withParam('objet', type), type, params.objet === type), + )} +
    + +
    + {params.domaine ? ( + + ) : null} + {params.objet ? ( + + ) : null} + + + +
    +
    +
    + + + + + {page.entries.length === 0 ? ( + + ) : ( +
    + + + + {['Horodatage', 'Auteur', 'Action', 'Objet', 'Champs', 'Justification'].map( + (heading) => ( + + ), + )} + + + + {page.entries.map((entry) => ( + + + + + + + + + ))} + +
    + {heading} +
    + {stamp.format(entry.occurredAt)} + {entry.actorName}{entry.action} + {entry.entityType} + + {entry.entityId} + + + {entry.changedFields.length === 0 + ? '—' + : entry.changedFields.join(', ')} + + {entry.reason ?? '—'} +
    +
    + )} +
    + +

    + 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 nom 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. +

    +
    + ); +} diff --git a/src/app/(app)/reglages/compte/page.tsx b/src/app/(app)/reglages/compte/page.tsx new file mode 100644 index 0000000..f878c2a --- /dev/null +++ b/src/app/(app)/reglages/compte/page.tsx @@ -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 ( + + + + {account === null ? ( + + + + ) : ( + + + + + )} + +

    + Le siège n’est pas un établissement : il porte l’identité juridique, là + où{' '} + + les établissements + {' '} + 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. +

    +
    + ); +} diff --git a/src/app/(app)/reglages/convention/AgreementForm.tsx b/src/app/(app)/reglages/convention/AgreementForm.tsx new file mode 100644 index 0000000..5bbc28a --- /dev/null +++ b/src/app/(app)/reglages/convention/AgreementForm.tsx @@ -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 ( +
    + + {title} + + {note ?

    {note}

    : null} +
    {children}
    +
    + ); +} + +function Area({ + label, + name, + defaultValue, + hint, +}: { + label: string; + name: string; + defaultValue: string; + hint: string; +}) { + return ( +