diff --git a/prisma/migrations/20260807230722_employee_records_and_contracts/migration.sql b/prisma/migrations/20260807230722_employee_records_and_contracts/migration.sql new file mode 100644 index 0000000..cf4c02c --- /dev/null +++ b/prisma/migrations/20260807230722_employee_records_and_contracts/migration.sql @@ -0,0 +1,157 @@ +-- CreateEnum +CREATE TYPE "ContractType" AS ENUM ('APPRENTISSAGE', 'CDD', 'CDI', 'DIRIGEANT_ASSIMILE_SALARIE', 'DIRIGEANT_NON_SALARIE', 'EXTRA', 'INTERIM', 'STAGIAIRE', 'SAISONNIER'); + +-- CreateEnum +CREATE TYPE "ContractStatus" AS ENUM ('DRAFT', 'ACTIVE', 'ENDED'); + +-- CreateEnum +CREATE TYPE "WorkTimeArrangement" AS ENUM ('HOURLY', 'FORFAIT_JOURS'); + +-- CreateTable +CREATE TABLE "EmployeeProfile" ( + "membershipId" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "birthDate" DATE, + "birthPlace" TEXT, + "nationality" TEXT, + "addressLine1" TEXT, + "postalCode" TEXT, + "city" TEXT, + "country" TEXT, + "phone" TEXT, + "personalEmail" TEXT, + "socialSecurityNumberEnc" BYTEA, + "ibanEnc" BYTEA, + "bicEnc" BYTEA, + "emergencyContactName" TEXT, + "emergencyContactPhone" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EmployeeProfile_pkey" PRIMARY KEY ("membershipId") +); + +-- CreateTable +CREATE TABLE "WorkPermit" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "membershipId" TEXT NOT NULL, + "permitType" TEXT NOT NULL, + "reference" TEXT NOT NULL, + "issuedAt" DATE, + "expiresAt" DATE NOT NULL, + + CONSTRAINT "WorkPermit_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserContract" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "membershipId" TEXT NOT NULL, + "locationId" TEXT NOT NULL, + "contractType" "ContractType" NOT NULL, + "startDate" DATE NOT NULL, + "endDate" DATE, + "trialEndDate" DATE, + "workTimeArrangement" "WorkTimeArrangement" NOT NULL DEFAULT 'HOURLY', + "weeklyHours" DECIMAL(5,2) NOT NULL DEFAULT 35, + "forfaitDaysPerYear" DECIMAL(5,1), + "forfaitAgreementRef" TEXT, + "forfaitAgreedAt" TIMESTAMP(3), + "isModulated" BOOLEAN NOT NULL DEFAULT false, + "hourlyRate" DECIMAL(10,4), + "monthlySalary" DECIMAL(10,2), + "jobTitleId" TEXT, + "classification" TEXT, + "coefficient" TEXT, + "status" "ContractStatus" NOT NULL DEFAULT 'ACTIVE', + "endReason" TEXT, + "version" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserContract_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Amendment" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "userContractId" TEXT NOT NULL, + "effectiveDate" DATE NOT NULL, + "changes" JSONB NOT NULL, + "reason" TEXT, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Amendment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ForfaitDayEntry" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "userContractId" TEXT NOT NULL, + "localDate" DATE NOT NULL, + "quantity" DECIMAL(2,1) NOT NULL, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ForfaitDayEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WorkloadReview" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "userContractId" TEXT NOT NULL, + "heldAt" DATE NOT NULL, + "summary" TEXT NOT NULL, + "actions" TEXT, + + CONSTRAINT "WorkloadReview_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "EmployeeProfile_accountId_idx" ON "EmployeeProfile"("accountId"); + +-- CreateIndex +CREATE INDEX "WorkPermit_accountId_idx" ON "WorkPermit"("accountId"); + +-- CreateIndex +CREATE INDEX "WorkPermit_membershipId_idx" ON "WorkPermit"("membershipId"); + +-- CreateIndex +CREATE INDEX "UserContract_accountId_idx" ON "UserContract"("accountId"); + +-- CreateIndex +CREATE INDEX "UserContract_membershipId_idx" ON "UserContract"("membershipId"); + +-- CreateIndex +CREATE INDEX "Amendment_accountId_idx" ON "Amendment"("accountId"); + +-- CreateIndex +CREATE INDEX "Amendment_userContractId_idx" ON "Amendment"("userContractId"); + +-- CreateIndex +CREATE INDEX "ForfaitDayEntry_accountId_idx" ON "ForfaitDayEntry"("accountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ForfaitDayEntry_userContractId_localDate_key" ON "ForfaitDayEntry"("userContractId", "localDate"); + +-- CreateIndex +CREATE INDEX "WorkloadReview_accountId_idx" ON "WorkloadReview"("accountId"); + +-- CreateIndex +CREATE INDEX "WorkloadReview_userContractId_idx" ON "WorkloadReview"("userContractId"); + +-- AddForeignKey +ALTER TABLE "EmployeeProfile" ADD CONSTRAINT "EmployeeProfile_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkPermit" ADD CONSTRAINT "WorkPermit_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserContract" ADD CONSTRAINT "UserContract_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Amendment" ADD CONSTRAINT "Amendment_userContractId_fkey" FOREIGN KEY ("userContractId") REFERENCES "UserContract"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260807231000_rls_employee_records/migration.sql b/prisma/migrations/20260807231000_rls_employee_records/migration.sql new file mode 100644 index 0000000..649fd2f --- /dev/null +++ b/prisma/migrations/20260807231000_rls_employee_records/migration.sql @@ -0,0 +1,27 @@ +-- Étend l'isolation aux tables du dossier salarié. +-- +-- Le test « toute table portant accountId est protégée » échoue sans ceci : +-- c'est lui qui transforme cet oubli en échec de CI plutôt qu'en fuite. + +DO $$ +DECLARE + t text; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'EmployeeProfile', 'WorkPermit', 'UserContract', 'Amendment', + 'ForfaitDayEntry', 'WorkloadReview' + ] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t); + EXECUTE format( + 'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())', + t + ); + EXECUTE format( + 'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())', + t + ); + END LOOP; +END; +$$; diff --git a/prisma/migrations/20260807231543_employee_names_on_profile/migration.sql b/prisma/migrations/20260807231543_employee_names_on_profile/migration.sql new file mode 100644 index 0000000..99443dc --- /dev/null +++ b/prisma/migrations/20260807231543_employee_names_on_profile/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - Added the required column `firstName` to the `EmployeeProfile` table without a default value. This is not possible if the table is not empty. + - Added the required column `lastName` to the `EmployeeProfile` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "EmployeeProfile" ADD COLUMN "firstName" TEXT, +ADD COLUMN "lastName" TEXT; + +-- Reprise : le nom existait sur le compte utilisateur, il descend au dossier. +UPDATE "EmployeeProfile" p +SET "firstName" = COALESCE(u."firstName", 'Prénom'), + "lastName" = COALESCE(u."lastName", 'À compléter') +FROM "Membership" m +LEFT JOIN "User" u ON u.id = m."userId" +WHERE m.id = p."membershipId"; + +UPDATE "EmployeeProfile" SET "firstName" = 'Prénom' WHERE "firstName" IS NULL; +UPDATE "EmployeeProfile" SET "lastName" = 'À compléter' WHERE "lastName" IS NULL; + +ALTER TABLE "EmployeeProfile" ALTER COLUMN "firstName" SET NOT NULL; +ALTER TABLE "EmployeeProfile" ALTER COLUMN "lastName" SET NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c93333f..f11f68c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -119,6 +119,9 @@ model Membership { scopes MembershipScope[] invitations Invitation[] auditLogs AuditLog[] + profile EmployeeProfile? + contracts UserContract[] + workPermits WorkPermit[] @@unique([accountId, employeeNumber]) @@index([accountId]) @@ -377,3 +380,168 @@ model LegalConfigEntry { @@unique([accountId, domain, key, effectiveFrom]) @@index([accountId]) } + +// ============================================================================ +// Dossier salarié et contrats — PLAN.md §4.3, WP-03 +// ============================================================================ + +/// Dossier personnel. NIR, IBAN et BIC sont chiffrés au repos (PLAN.md §3.6) : +/// une sauvegarde volée ne doit pas suffire à les lire. +model EmployeeProfile { + membershipId String @id + accountId String + + /// Nom d'état civil, porté par le dossier et non par le compte utilisateur : + /// la plupart des salariés n'ont pas de compte, et le registre unique du + /// personnel exige leur nom. + firstName String + lastName String + + birthDate DateTime? @db.Date + birthPlace String? + nationality String? + addressLine1 String? + postalCode String? + city String? + country String? + phone String? + personalEmail String? + + socialSecurityNumberEnc Bytes? + ibanEnc Bytes? + bicEnc Bytes? + + emergencyContactName String? + emergencyContactPhone String? + + updatedAt DateTime @updatedAt + + membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) + + @@index([accountId]) +} + +/// Titre de séjour et son échéance. Le tableau de bord RH surveille les +/// expirations : un titre périmé interdit l'emploi. +model WorkPermit { + id String @id @default(cuid()) + accountId String + membershipId String + permitType String + reference String + issuedAt DateTime? @db.Date + expiresAt DateTime @db.Date + + membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) + + @@index([accountId]) + @@index([membershipId]) +} + +model UserContract { + id String @id @default(cuid()) + accountId String + membershipId String + locationId String + + contractType ContractType + startDate DateTime @db.Date + endDate DateTime? @db.Date + trialEndDate DateTime? @db.Date + + /// Organisation du temps. Le forfait jours exclut le décompte horaire (§6.4). + workTimeArrangement WorkTimeArrangement @default(HOURLY) + weeklyHours Decimal @default(35) @db.Decimal(5, 2) + forfaitDaysPerYear Decimal? @db.Decimal(5, 1) + /// Convention individuelle écrite. Sans elle le forfait est inopposable : + /// l'activation est refusée. + forfaitAgreementRef String? + forfaitAgreedAt DateTime? + isModulated Boolean @default(false) + + hourlyRate Decimal? @db.Decimal(10, 4) + monthlySalary Decimal? @db.Decimal(10, 2) + jobTitleId String? + classification String? + coefficient String? + + status ContractStatus @default(ACTIVE) + endReason String? + version Int @default(0) + createdAt DateTime @default(now()) + + membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) + amendments Amendment[] + + @@index([accountId]) + @@index([membershipId]) +} + +/// Liste exhaustive relevée dans le filtre « Tous les types de contrats ». +enum ContractType { + APPRENTISSAGE + CDD + CDI + DIRIGEANT_ASSIMILE_SALARIE + DIRIGEANT_NON_SALARIE + EXTRA + INTERIM + STAGIAIRE + SAISONNIER +} + +enum ContractStatus { + DRAFT + ACTIVE + ENDED +} + +enum WorkTimeArrangement { + HOURLY + FORFAIT_JOURS +} + +/// Avenant. Conserve l'historique plutôt que d'écraser le contrat : un contrôle +/// demande l'état du contrat au moment des faits, pas son état actuel. +model Amendment { + id String @id @default(cuid()) + accountId String + userContractId String + effectiveDate DateTime @db.Date + changes Json + reason String? + createdBy String + createdAt DateTime @default(now()) + + contract UserContract @relation(fields: [userContractId], references: [id], onDelete: Cascade) + + @@index([accountId]) + @@index([userContractId]) +} + +/// Décompte des jours d'un salarié au forfait. Conservation 3 ans (§12.5). +model ForfaitDayEntry { + id String @id @default(cuid()) + accountId String + userContractId String + localDate DateTime @db.Date + quantity Decimal @db.Decimal(2, 1) + createdBy String + createdAt DateTime @default(now()) + + @@unique([userContractId, localDate]) + @@index([accountId]) +} + +/// Entretien annuel de charge, obligatoire au forfait jours (matrice n° 7). +model WorkloadReview { + id String @id @default(cuid()) + accountId String + userContractId String + heldAt DateTime @db.Date + summary String + actions String? + + @@index([accountId]) + @@index([userContractId]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 614fc46..04bf277 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -181,6 +181,65 @@ async function main() { console.log(` ${person.email} — ${person.role}`); } + console.log('→ Contrats et dossiers'); + const contractSpecs = [ + { number: 'E0001', type: 'CDI', hours: 39, forfait: false, location: 'loc-nantes' }, + { number: 'E0002', type: 'CDI', hours: 0, forfait: true, location: 'loc-nantes' }, + { number: 'E0003', type: 'CDI', hours: 35, forfait: false, location: 'loc-rennes' }, + { number: 'E0004', type: 'CDD', hours: 24, forfait: false, location: 'loc-nantes' }, + ] as const; + + for (const spec of contractSpecs) { + const membership = await prisma.membership.findUnique({ + where: { + accountId_employeeNumber: { + accountId: account.id, + employeeNumber: spec.number, + }, + }, + }); + if (!membership) continue; + + const holder = membership.userId + ? await prisma.user.findUnique({ where: { id: membership.userId } }) + : null; + + await prisma.employeeProfile.upsert({ + where: { membershipId: membership.id }, + update: {}, + create: { + membershipId: membership.id, + accountId: account.id, + firstName: holder?.firstName ?? 'Prénom', + lastName: holder?.lastName ?? 'À compléter', + city: 'Nantes', + phone: '00 00 00 00 00', + }, + }); + + const existing = await prisma.userContract.findFirst({ + where: { membershipId: membership.id }, + }); + if (existing) continue; + + await prisma.userContract.create({ + data: { + accountId: account.id, + membershipId: membership.id, + locationId: spec.location, + contractType: spec.type, + startDate: new Date('2024-01-08'), + workTimeArrangement: spec.forfait ? 'FORFAIT_JOURS' : 'HOURLY', + weeklyHours: spec.forfait ? 0 : spec.hours, + forfaitDaysPerYear: spec.forfait ? 218 : null, + forfaitAgreementRef: spec.forfait ? 'CONV-2024-002' : null, + forfaitAgreedAt: spec.forfait ? new Date('2024-01-05') : null, + monthlySalary: 2100, + }, + }); + } + console.log(` ${contractSpecs.length} contrats`); + console.log('→ Durées de conservation'); const retention = [ ['Shift', 12, 'creation', 'Décompte des horaires : 1 an minimum (matrice n° 21).'], diff --git a/src/app/(app)/equipe/AddEmployeeForm.tsx b/src/app/(app)/equipe/AddEmployeeForm.tsx new file mode 100644 index 0000000..b23668d --- /dev/null +++ b/src/app/(app)/equipe/AddEmployeeForm.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useActionState } from 'react'; + +import { Field, FormError, SubmitButton } from '@/components/ui/Form'; +import { + createEmployeeAction, + type ActionState, +} from '@/server/employees/actions'; + +export function AddEmployeeForm() { + const [state, formAction] = useActionState( + createEmployeeAction, + {}, + ); + + return ( +
+
+ + + +
+ + + + {state.error} + +
+ Ajouter + {state.ok ? ( + Salarié ajouté. + ) : null} +
+ + ); +} diff --git a/src/app/(app)/equipe/[id]/page.tsx b/src/app/(app)/equipe/[id]/page.tsx index a20dbd1..aadfdde 100644 --- a/src/app/(app)/equipe/[id]/page.tsx +++ b/src/app/(app)/equipe/[id]/page.tsx @@ -2,20 +2,12 @@ import { notFound } from 'next/navigation'; import { PageBody, PageHeader } from '@/components/shell/PageHeader'; import { Badge } from '@/components/ui/Badge'; -import { Button } from '@/components/ui/Button'; -import { Card, CardHeader } from '@/components/ui/Card'; -import { POSTE_LABELS, posteShort, posteTokens } from '@/lib/design/postes'; -import { EMPLOYEES, findEmployee } from '@/lib/demo/equipe'; -import { - FICHE_COUNTERS, - FICHE_DOCUMENTS, - FICHE_SHIFTS, -} from '@/lib/demo/fiche'; -import { fullName, initials } from '@/lib/demo/types'; +import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; +import { getEmployee } from '@/server/employees/queries'; -export function generateStaticParams() { - return EMPLOYEES.map((employee) => ({ id: employee.id })); -} +export const dynamic = 'force-dynamic'; + +const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }); export default async function FichePage({ params, @@ -23,20 +15,24 @@ export default async function FichePage({ params: Promise<{ id: string }>; }) { const { id } = await params; - const employee = findEmployee(id); + const employee = await getEmployee(id); if (!employee) notFound(); + const active = employee.contracts.find( + (contract) => contract.status === 'ACTIVE', + ); + return ( - - - - } + title={`${employee.firstName} ${employee.lastName}`} + subtitle={[ + employee.contract?.label, + employee.locationName, + `matricule ${employee.employeeNumber}`, + ] + .filter(Boolean) + .join(' · ')} />
@@ -44,104 +40,127 @@ export default async function FichePage({ aria-hidden className="flex size-11 flex-none items-center justify-center rounded-full bg-surface-3 text-sm font-semibold text-ink-2" > - {initials(employee)} + {employee.firstName.charAt(0)} + {employee.lastName.charAt(0)}
-

{employee.contract}

-

- Poste principal · {POSTE_LABELS[employee.poste]} +

+ {employee.email ?? 'Aucun compte applicatif'}

+

Rôle · {employee.roleName}

- {employee.forfaitJours ? ( - Forfait jours · 218 j + {active?.forfaitJours ? ( + + Forfait jours · {active.forfaitDaysPerYear ?? '—'} j + + ) : active ? ( + {active.weeklyHours} h hebdomadaires + ) : null} + {!employee.hasAccount ? ( + Sans accès applicatif ) : null} -
    - {FICHE_COUNTERS.map((counter) => ( -
  • - - {counter.label} - - - {counter.value} - -
  • - ))} -
-
- - - Voir le planning - - } + + {employee.contracts.length}} + /> + {employee.contracts.length === 0 ? ( + + ) : (
    - {FICHE_SHIFTS.map((shift, index) => { - const tokens = posteTokens(shift.poste); - return ( -
  • - - {shift.day} - - - {posteShort(shift.poste)} - - {shift.time} - - {shift.worked} - - {shift.state} -
  • - ); - })} -
-
- - - -
    - {FICHE_DOCUMENTS.map((document) => ( + {employee.contracts.map((contract) => (
  • - - - {document.label} +
    + {contract.label} + {contract.forfaitJours ? ( + Forfait jours + ) : ( + {contract.weeklyHours} h + )} + + {dateFormat.format(contract.startDate)} + {contract.endDate + ? ` → ${dateFormat.format(contract.endDate)}` + : ' → en cours'} - - {document.date} - - - {document.state} + + {employee.canSeeSalary && contract.monthlySalary ? ( + + {contract.monthlySalary} € brut + + ) : null} + + {contract.status === 'ACTIVE' ? 'En cours' : 'Terminé'} + +
    + + {contract.amendments.length > 0 ? ( +
      + {contract.amendments.map((amendment) => ( +
    • + + {dateFormat.format(amendment.effectiveDate)} + {' '} + — avenant{amendment.reason ? ` · ${amendment.reason}` : ''} +
    • + ))} +
    + ) : null}
  • ))}
+ )} +
+ + {employee.profile ? ( + + +
+
+
Téléphone
+
{employee.profile.phone ?? '—'}
+
+
+
Ville
+
{employee.profile.city ?? '—'}
+
+
+
+ Numéro de sécurité sociale +
+
+ {/* Non chargé quand la capacité manque : un champ absent de la + réponse ne peut fuiter ni par le HTML ni par un journal. */} + {employee.profile.socialSecurityNumber ?? ( + Accès non autorisé + )} +
+
+
+
IBAN
+
+ {employee.profile.iban ?? ( + Accès non autorisé + )} +
+
+
-
+ ) : null}
); } diff --git a/src/app/(app)/equipe/page.tsx b/src/app/(app)/equipe/page.tsx index ad06049..c07a9e1 100644 --- a/src/app/(app)/equipe/page.tsx +++ b/src/app/(app)/equipe/page.tsx @@ -2,117 +2,128 @@ import Link from 'next/link'; import { PageBody, PageHeader } from '@/components/shell/PageHeader'; import { Badge, type Tone } from '@/components/ui/Badge'; -import { Button } from '@/components/ui/Button'; -import { POSTE_LABELS, posteShort, posteTokens } from '@/lib/design/postes'; -import { EMPLOYEES } from '@/lib/demo/equipe'; -import { fullName, initials } from '@/lib/demo/types'; +import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; +import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm'; +import { listEmployees } from '@/server/employees/queries'; export const metadata = { title: 'Équipe · PlanFlow' }; +export const dynamic = 'force-dynamic'; -const STATUS: Record = { - actif: { label: 'Actif', tone: 'ok' }, - essai: { label: 'Période d’essai', tone: 'info' }, - sortie: { label: 'Sortie prévue', tone: 'warn' }, +const STATUS_TONES: Record = { + ACTIVE: { label: 'Actif', tone: 'ok' }, + INVITED: { label: 'Invitation en attente', tone: 'info' }, + ARCHIVED: { label: 'Archivé', tone: 'neutral' }, }; -export default function EquipePage() { - const actifs = EMPLOYEES.filter((e) => e.status !== 'sortie').length; - const essai = EMPLOYEES.filter((e) => e.status === 'essai').length; +const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }); + +export default async function EquipePage() { + const employees = await listEmployees(); + const withoutAccount = employees.filter((e) => !e.hasAccount).length; return ( - - - - } + subtitle={`${employees.length} salarié${employees.length > 1 ? 's' : ''}${ + withoutAccount > 0 ? ` · ${withoutAccount} sans compte applicatif` : '' + }`} /> -
- - - - - - - - - - - - {EMPLOYEES.map((employee) => { - const tokens = posteTokens(employee.poste); - const status = STATUS[employee.status] ?? STATUS.actif!; - return ( - - - - - - + + + {employees.length === 0 ? ( + + ) : ( +
+
- Collaborateur - - Poste principal - - Contrat - - Entrée - - Statut -
- - - {initials(employee)} - - - - {fullName(employee)} - - - {employee.job} - - - - - - - {posteShort(employee.poste)} - - {POSTE_LABELS[employee.poste]} - - - {employee.contract} - - {employee.since} - - {status.label} -
+ + + + + + + + - ); - })} - -
CollaborateurMatriculeContratÉtablissementRôleStatut
-
+ + + {employees.map((employee) => { + const status = + STATUS_TONES[employee.status] ?? STATUS_TONES.ACTIVE!; + return ( + + + + + {employee.firstName.charAt(0)} + {employee.lastName.charAt(0)} + + + + {employee.firstName} {employee.lastName} + + + {employee.email ?? 'Sans compte applicatif'} + + + + + + {employee.employeeNumber} + + + {employee.contract ? ( + + {employee.contract.label} + {employee.contract.forfaitJours ? ( + Forfait jours + ) : null} + + depuis le{' '} + {dateFormat.format(employee.contract.since)} + + + ) : ( + Sans contrat + )} + + + {employee.locationName ?? '—'} + + + {employee.roleName} + + + {status.label} + + + ); + })} + + + + )} + + + + +
+ +
+
); } diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts index acf14ad..d8a633b 100644 --- a/src/components/shell/navigation.ts +++ b/src/components/shell/navigation.ts @@ -49,7 +49,6 @@ export const NAVIGATION: NavSection[] = [ label: 'Équipe', items: [ { id: 'membres', label: 'Membres', href: '/equipe' }, - { id: 'fiche', label: 'Fiche salarié', href: '/equipe/camille-ferrand' }, { id: 'contrats', label: 'Modifications de contrat' }, ], }, diff --git a/src/domain/contracts/rules.ts b/src/domain/contracts/rules.ts new file mode 100644 index 0000000..2fc3e9e --- /dev/null +++ b/src/domain/contracts/rules.ts @@ -0,0 +1,112 @@ +/** + * Règles de cohérence des contrats. + * + * Fonctions pures : elles s'appliquent aussi bien à la validation d'un + * formulaire qu'au contrôle en transaction, et se testent sans base. + */ + +export interface ContractPeriod { + id?: string; + startDate: Date; + /** `null` = contrat sans terme (CDI en cours). */ + endDate: Date | null; +} + +/** + * Deux périodes se chevauchent-elles ? + * + * Un contrat sans terme court indéfiniment : il chevauche donc toute période + * qui commence après lui. C'est le cas que l'on oublie en comparant bêtement + * deux couples de dates. + */ +export function periodsOverlap(a: ContractPeriod, b: ContractPeriod): boolean { + const aStart = a.startDate.getTime(); + const bStart = b.startDate.getTime(); + const aEnd = a.endDate?.getTime() ?? Number.POSITIVE_INFINITY; + const bEnd = b.endDate?.getTime() ?? Number.POSITIVE_INFINITY; + + return aStart <= bEnd && bStart <= aEnd; +} + +/** Contrats existants qui empêcheraient la création de `candidate`. */ +export function findOverlaps( + candidate: ContractPeriod, + existing: ContractPeriod[], +): ContractPeriod[] { + return existing.filter( + (period) => period.id !== candidate.id && periodsOverlap(candidate, period), + ); +} + +export interface ContractValidationInput extends ContractPeriod { + workTimeArrangement: 'HOURLY' | 'FORFAIT_JOURS'; + weeklyHours: number; + forfaitDaysPerYear?: number | null; + forfaitAgreementRef?: string | null; + forfaitAgreedAt?: Date | null; +} + +export interface ValidationIssue { + field: string; + message: string; +} + +/** Plafond conventionnel IDCC 1517, journée de solidarité incluse. */ +export const FORFAIT_JOURS_CAP = 218; + +export function validateContract( + input: ContractValidationInput, +): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + if (input.endDate && input.endDate < input.startDate) { + issues.push({ + field: 'endDate', + message: 'La fin du contrat précède son début.', + }); + } + + if (input.workTimeArrangement === 'FORFAIT_JOURS') { + // Sans convention individuelle écrite et accord du salarié, le forfait est + // inopposable : l'activer produirait un décompte en jours sans base légale, + // et supprimerait au passage tout contrôle de durée hebdomadaire. + if (!input.forfaitAgreementRef?.trim()) { + issues.push({ + field: 'forfaitAgreementRef', + message: + 'Le forfait jours exige une convention individuelle écrite (référence du document).', + }); + } + if (!input.forfaitAgreedAt) { + issues.push({ + field: 'forfaitAgreedAt', + message: 'Le forfait jours exige la date d’accord du salarié.', + }); + } + + const days = input.forfaitDaysPerYear ?? 0; + if (days <= 0) { + issues.push({ + field: 'forfaitDaysPerYear', + message: 'Indiquez le nombre de jours du forfait.', + }); + } else if (days > FORFAIT_JOURS_CAP) { + issues.push({ + field: 'forfaitDaysPerYear', + message: `Le plafond conventionnel est de ${FORFAIT_JOURS_CAP} jours, journée de solidarité incluse.`, + }); + } + } else if (input.weeklyHours <= 0) { + issues.push({ + field: 'weeklyHours', + message: 'La durée hebdomadaire doit être positive.', + }); + } + + return issues; +} + +/** Un contrat au forfait ne se planifie pas en heures (PLAN.md §6.4). */ +export function isHourScheduled(arrangement: string): boolean { + return arrangement === 'HOURLY'; +} diff --git a/src/server/crypto.ts b/src/server/crypto.ts index 232db08..feddfef 100644 --- a/src/server/crypto.ts +++ b/src/server/crypto.ts @@ -38,7 +38,13 @@ export function encrypt(plaintext: string): Buffer { return Buffer.concat([iv, cipher.getAuthTag(), encrypted]); } -export function decrypt(payload: Buffer): string { +export function decrypt(payload: Uint8Array): string { + // Prisma 7 renvoie les colonnes `Bytes` en Uint8Array, pas en Buffer. + const buffer = Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength); + return decryptBuffer(buffer); +} + +function decryptBuffer(payload: Buffer): string { if (payload.length < IV_LENGTH + TAG_LENGTH) { throw new Error('Chiffré invalide : trop court pour contenir iv et tag'); } @@ -60,7 +66,9 @@ export function encryptOptional(value: string | null | undefined): Buffer | null return value ? encrypt(value) : null; } -export function decryptOptional(payload: Buffer | null | undefined): string | null { +export function decryptOptional( + payload: Uint8Array | null | undefined, +): string | null { return payload ? decrypt(payload) : null; } diff --git a/src/server/employees/actions.ts b/src/server/employees/actions.ts new file mode 100644 index 0000000..7e25208 --- /dev/null +++ b/src/server/employees/actions.ts @@ -0,0 +1,331 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +import { AuthorizationError } from '@/domain/access/authorize'; +import { findOverlaps, validateContract } from '@/domain/contracts/rules'; +import { recordAudit } from '@/server/audit'; +import { mutate } from '@/server/context'; + +export interface ActionState { + error?: string; + ok?: boolean; +} + +const CONTRACT_TYPES = [ + 'APPRENTISSAGE', + 'CDD', + 'CDI', + 'DIRIGEANT_ASSIMILE_SALARIE', + 'DIRIGEANT_NON_SALARIE', + 'EXTRA', + 'INTERIM', + 'STAGIAIRE', + 'SAISONNIER', +] as const; + +const employeeInput = z.object({ + firstName: z.string().trim().min(1, 'Prénom requis').max(80), + lastName: z.string().trim().min(1, 'Nom requis').max(80), + employeeNumber: z.string().trim().min(1, 'Matricule requis').max(40), + /** Vide = salarié géré sans accès applicatif. */ + email: z.string().trim().email('Adresse invalide').or(z.literal('')), +}); + +/** + * Crée un salarié. + * + * Un salarié **sans compte utilisateur** doit rester créable : la plupart des + * équipes de vente ne se connectent jamais à l'outil, et exiger une adresse + * électronique les rendrait impossibles à planifier ou à déclarer. + */ +export async function createEmployeeAction( + _previous: ActionState, + formData: FormData, +): Promise { + const parsed = employeeInput.safeParse({ + firstName: formData.get('firstName'), + lastName: formData.get('lastName'), + employeeNumber: formData.get('employeeNumber'), + email: formData.get('email') ?? '', + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; + } + + try { + await mutate('members.create', async (db, actor) => { + const role = await db.role.findFirst({ where: { key: 'employee' } }); + if (!role) throw new Error('Rôle « employee » introuvable.'); + + const existing = await db.membership.findFirst({ + where: { employeeNumber: parsed.data.employeeNumber }, + }); + if (existing) { + throw new ValidationError('Ce matricule est déjà utilisé.'); + } + + const created = await db.membership.create({ + data: { + roleId: role.id, + employeeNumber: parsed.data.employeeNumber, + status: parsed.data.email ? 'INVITED' : 'ACTIVE', + } as never, + }); + + await db.employeeProfile.create({ + data: { + membershipId: created.id, + firstName: parsed.data.firstName, + lastName: parsed.data.lastName, + personalEmail: parsed.data.email || null, + } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'membership.create', + entityType: 'Membership', + entityId: created.id, + after: { + employeeNumber: created.employeeNumber, + hasAccount: Boolean(parsed.data.email), + }, + }); + }); + } catch (error) { + if (error instanceof ValidationError) return { error: error.message }; + if (error instanceof AuthorizationError) { + return { error: "Vous n'avez pas le droit de créer un salarié." }; + } + throw error; + } + + revalidatePath('/equipe'); + return { ok: true }; +} + +class ValidationError extends Error {} + +const contractInput = z.object({ + membershipId: z.string().min(1), + locationId: z.string().min(1), + contractType: z.enum(CONTRACT_TYPES), + startDate: z.coerce.date(), + endDate: z.string().trim().optional(), + workTimeArrangement: z.enum(['HOURLY', 'FORFAIT_JOURS']), + weeklyHours: z.coerce.number().min(0).max(60), + forfaitDaysPerYear: z.coerce.number().min(0).max(400).optional(), + forfaitAgreementRef: z.string().trim().optional(), + forfaitAgreedAt: z.string().trim().optional(), +}); + +export async function createContractAction( + _previous: ActionState, + formData: FormData, +): Promise { + const parsed = contractInput.safeParse({ + membershipId: formData.get('membershipId'), + locationId: formData.get('locationId'), + contractType: formData.get('contractType'), + startDate: formData.get('startDate'), + endDate: formData.get('endDate') ?? '', + workTimeArrangement: formData.get('workTimeArrangement') ?? 'HOURLY', + weeklyHours: formData.get('weeklyHours') ?? 35, + forfaitDaysPerYear: formData.get('forfaitDaysPerYear') || undefined, + forfaitAgreementRef: formData.get('forfaitAgreementRef') ?? '', + forfaitAgreedAt: formData.get('forfaitAgreedAt') ?? '', + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; + } + + const endDate = parsed.data.endDate ? new Date(parsed.data.endDate) : null; + const forfaitAgreedAt = parsed.data.forfaitAgreedAt + ? new Date(parsed.data.forfaitAgreedAt) + : null; + + const issues = validateContract({ + startDate: parsed.data.startDate, + endDate, + workTimeArrangement: parsed.data.workTimeArrangement, + weeklyHours: parsed.data.weeklyHours, + forfaitDaysPerYear: parsed.data.forfaitDaysPerYear ?? null, + forfaitAgreementRef: parsed.data.forfaitAgreementRef ?? null, + forfaitAgreedAt, + }); + + if (issues.length > 0) { + return { error: issues[0]?.message ?? 'Contrat invalide' }; + } + + try { + await mutate( + 'members.contract.create', + async (db, actor) => { + const location = await db.location.findUnique({ + where: { id: parsed.data.locationId }, + }); + if (!location) throw new AuthorizationError('members.contract.create'); + + const existing = await db.userContract.findMany({ + where: { + membershipId: parsed.data.membershipId, + status: { in: ['ACTIVE', 'DRAFT'] }, + }, + select: { id: true, startDate: true, endDate: true }, + }); + + // Deux contrats actifs qui se chevauchent produiraient un salarié + // compté deux fois en paie. Le contrôle est fait ici, en transaction, + // et pas seulement dans le formulaire. + const overlaps = findOverlaps( + { startDate: parsed.data.startDate, endDate }, + existing, + ); + if (overlaps.length > 0) { + throw new ValidationError( + 'Un contrat actif couvre déjà cette période pour ce salarié.', + ); + } + + const created = await db.userContract.create({ + data: { + membershipId: parsed.data.membershipId, + locationId: location.id, + contractType: parsed.data.contractType, + startDate: parsed.data.startDate, + endDate, + workTimeArrangement: parsed.data.workTimeArrangement, + weeklyHours: parsed.data.weeklyHours, + forfaitDaysPerYear: parsed.data.forfaitDaysPerYear ?? null, + forfaitAgreementRef: parsed.data.forfaitAgreementRef || null, + forfaitAgreedAt, + } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'contract.create', + entityType: 'UserContract', + entityId: created.id, + after: { + contractType: created.contractType, + startDate: created.startDate.toISOString(), + workTimeArrangement: created.workTimeArrangement, + }, + }); + }, + { locationId: parsed.data.locationId }, + ); + } catch (error) { + if (error instanceof ValidationError) return { error: error.message }; + if (error instanceof AuthorizationError) { + return { error: "Vous n'avez pas le droit de créer un contrat." }; + } + throw error; + } + + revalidatePath(`/equipe/${parsed.data.membershipId}`); + revalidatePath('/equipe'); + return { ok: true }; +} + +const amendmentInput = z.object({ + contractId: z.string().min(1), + effectiveDate: z.coerce.date(), + reason: z.string().trim().min(1, 'Motif requis').max(300), + weeklyHours: z.coerce.number().min(0).max(60).optional(), +}); + +/** + * Enregistre un avenant. + * + * L'avenant **s'ajoute**, il ne remplace pas : un contrôle demande l'état du + * contrat au moment des faits, pas son état actuel. Le contrat porte la valeur + * courante, l'avenant garde la trace du passage. + */ +export async function createAmendmentAction( + _previous: ActionState, + formData: FormData, +): Promise { + const parsed = amendmentInput.safeParse({ + contractId: formData.get('contractId'), + effectiveDate: formData.get('effectiveDate'), + reason: formData.get('reason'), + weeklyHours: formData.get('weeklyHours') || undefined, + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; + } + + let membershipId = ''; + + try { + await mutate('members.contract.edit', async (db, actor) => { + const contract = await db.userContract.findUnique({ + where: { id: parsed.data.contractId }, + }); + if (!contract) throw new AuthorizationError('members.contract.edit'); + membershipId = contract.membershipId; + + const changes: Record = {}; + if ( + parsed.data.weeklyHours !== undefined && + Number(contract.weeklyHours) !== parsed.data.weeklyHours + ) { + changes.weeklyHours = { + before: contract.weeklyHours.toString(), + after: parsed.data.weeklyHours, + }; + } + + if (Object.keys(changes).length === 0) { + throw new ValidationError('Aucune modification à enregistrer.'); + } + + const amendment = await db.amendment.create({ + data: { + userContractId: contract.id, + effectiveDate: parsed.data.effectiveDate, + changes, + reason: parsed.data.reason, + createdBy: actor.membershipId, + } as never, + }); + + if (parsed.data.weeklyHours !== undefined) { + await db.userContract.update({ + where: { id: contract.id }, + data: { + weeklyHours: parsed.data.weeklyHours, + version: { increment: 1 }, + }, + }); + } + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'contract.amend', + entityType: 'UserContract', + entityId: contract.id, + before: { weeklyHours: contract.weeklyHours.toString() }, + after: { amendmentId: amendment.id, changes }, + reason: parsed.data.reason, + }); + }); + } catch (error) { + if (error instanceof ValidationError) return { error: error.message }; + if (error instanceof AuthorizationError) { + return { error: "Vous n'avez pas le droit de modifier un contrat." }; + } + throw error; + } + + if (membershipId) revalidatePath(`/equipe/${membershipId}`); + return { ok: true }; +} diff --git a/src/server/employees/queries.ts b/src/server/employees/queries.ts new file mode 100644 index 0000000..3b5e629 --- /dev/null +++ b/src/server/employees/queries.ts @@ -0,0 +1,222 @@ +import { can } from '@/domain/access/authorize'; +import { decryptOptional } from '@/server/crypto'; +import { query } from '@/server/context'; + +/** + * Lecture des dossiers salariés. + * + * `members.salary.view` ne masque pas seulement l'affichage : la rémunération + * n'est **pas chargée** quand la capacité manque. Un champ absent de la réponse + * ne peut pas fuiter par le HTML, un journal ou une erreur — contrairement à un + * champ chargé puis caché à l'écran. + */ + +export interface EmployeeListRow { + id: string; + employeeNumber: string; + firstName: string; + lastName: string; + email: string | null; + status: string; + hasAccount: boolean; + roleName: string; + contract: { + type: string; + label: string; + since: Date; + trialEndDate: Date | null; + forfaitJours: boolean; + } | null; + locationName: string | null; +} + +const CONTRACT_LABELS: Record = { + APPRENTISSAGE: 'Apprentissage', + CDD: 'CDD', + CDI: 'CDI', + DIRIGEANT_ASSIMILE_SALARIE: 'Dirigeant assimilé salarié', + DIRIGEANT_NON_SALARIE: 'Dirigeant non salarié', + EXTRA: 'Extra', + INTERIM: 'Intérim', + STAGIAIRE: 'Stagiaire', + SAISONNIER: 'Saisonnier', +}; + +export function contractLabel(type: string): string { + return CONTRACT_LABELS[type] ?? type; +} + +export async function listEmployees(): Promise { + return query('members.view', async (db) => { + const memberships = await db.membership.findMany({ + where: { archivedAt: null }, + include: { + profile: { select: { firstName: true, lastName: true } }, + user: { select: { email: true } }, + role: { select: { name: true } }, + contracts: { + where: { status: 'ACTIVE' }, + orderBy: { startDate: 'desc' }, + take: 1, + }, + }, + orderBy: { employeeNumber: 'asc' }, + }); + + const locationIds = [ + ...new Set( + memberships + .flatMap((membership) => membership.contracts) + .map((contract) => contract.locationId), + ), + ]; + const locations = await db.location.findMany({ + where: { id: { in: locationIds } }, + select: { id: true, name: true }, + }); + const locationNames = new Map(locations.map((l) => [l.id, l.name])); + + return memberships.map((membership) => { + const contract = membership.contracts[0]; + return { + id: membership.id, + employeeNumber: membership.employeeNumber, + firstName: membership.profile?.firstName ?? '', + lastName: membership.profile?.lastName ?? membership.employeeNumber, + email: membership.user?.email ?? null, + status: membership.status, + hasAccount: membership.userId !== null, + roleName: membership.role.name, + contract: contract + ? { + type: contract.contractType, + label: contractLabel(contract.contractType), + since: contract.startDate, + trialEndDate: contract.trialEndDate, + forfaitJours: contract.workTimeArrangement === 'FORFAIT_JOURS', + } + : null, + locationName: contract + ? (locationNames.get(contract.locationId) ?? null) + : null, + }; + }); + }); +} + +export interface EmployeeDetail extends EmployeeListRow { + profile: { + birthDate: Date | null; + city: string | null; + phone: string | null; + personalEmail: string | null; + /** Chiffré au repos, déchiffré seulement pour qui a le droit de le lire. */ + socialSecurityNumber: string | null; + iban: string | null; + } | null; + contracts: Array<{ + id: string; + type: string; + label: string; + startDate: Date; + endDate: Date | null; + weeklyHours: string; + forfaitJours: boolean; + forfaitDaysPerYear: string | null; + status: string; + /** Absent quand `members.salary.view` manque. */ + monthlySalary: string | null; + amendments: Array<{ id: string; effectiveDate: Date; reason: string | null }>; + }>; + canSeeSalary: boolean; +} + +export async function getEmployee(id: string): Promise { + return query('members.view', async (db, actor) => { + const canSeeSalary = can(actor, 'members.salary.view'); + const canSeeDocuments = can(actor, 'members.documents.view'); + + const membership = await db.membership.findUnique({ + where: { id }, + include: { + user: { select: { email: true } }, + role: { select: { name: true } }, + profile: true, + contracts: { + orderBy: { startDate: 'desc' }, + include: { + amendments: { orderBy: { effectiveDate: 'desc' } }, + }, + }, + }, + }); + + if (!membership) return null; + + const active = membership.contracts.find( + (contract) => contract.status === 'ACTIVE', + ); + const location = active + ? await db.location.findUnique({ + where: { id: active.locationId }, + select: { name: true }, + }) + : null; + + return { + id: membership.id, + employeeNumber: membership.employeeNumber, + firstName: membership.profile?.firstName ?? '', + lastName: membership.profile?.lastName ?? membership.employeeNumber, + email: membership.user?.email ?? null, + status: membership.status, + hasAccount: membership.userId !== null, + roleName: membership.role.name, + locationName: location?.name ?? null, + contract: active + ? { + type: active.contractType, + label: contractLabel(active.contractType), + since: active.startDate, + trialEndDate: active.trialEndDate, + forfaitJours: active.workTimeArrangement === 'FORFAIT_JOURS', + } + : null, + profile: membership.profile + ? { + birthDate: membership.profile.birthDate, + city: membership.profile.city, + phone: membership.profile.phone, + personalEmail: membership.profile.personalEmail, + // Le NIR et l'IBAN ne sont déchiffrés que pour un profil habilité. + socialSecurityNumber: canSeeDocuments + ? decryptOptional(membership.profile.socialSecurityNumberEnc) + : null, + iban: canSeeDocuments + ? decryptOptional(membership.profile.ibanEnc) + : null, + } + : null, + contracts: membership.contracts.map((contract) => ({ + id: contract.id, + type: contract.contractType, + label: contractLabel(contract.contractType), + startDate: contract.startDate, + endDate: contract.endDate, + weeklyHours: contract.weeklyHours.toString(), + forfaitJours: contract.workTimeArrangement === 'FORFAIT_JOURS', + forfaitDaysPerYear: contract.forfaitDaysPerYear?.toString() ?? null, + status: contract.status, + monthlySalary: canSeeSalary + ? (contract.monthlySalary?.toString() ?? null) + : null, + amendments: contract.amendments.map((amendment) => ({ + id: amendment.id, + effectiveDate: amendment.effectiveDate, + reason: amendment.reason, + })), + })), + canSeeSalary, + }; + }); +} diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index 70530c5..f13dbe7 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -19,9 +19,11 @@ test('les six écrans se chargent et affichent leur contenu', async ({ page }) = await page.getByRole('link', { name: 'Équipe', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible(); - await page.getByRole('link', { name: 'Camille Ferrand' }).click(); + // La fiche est atteinte depuis l'annuaire réel, plus depuis un lien codé + // en dur : l'identifiant est celui de la base. + await page.getByRole('link', { name: /Camille Ferrand/ }).first().click(); await expect( - page.getByRole('heading', { name: 'Camille Ferrand' }), + page.getByRole('heading', { name: /Camille Ferrand/ }), ).toBeVisible(); await page.getByRole('link', { name: 'Congés' }).click(); diff --git a/tests/e2e/reglages.spec.ts b/tests/e2e/reglages.spec.ts index 3817dd6..ce0b34a 100644 --- a/tests/e2e/reglages.spec.ts +++ b/tests/e2e/reglages.spec.ts @@ -58,3 +58,34 @@ test('un manager ne peut ni voir ni modifier les établissements', async ({ page.getByRole('heading', { name: 'Établissements' }), ).toBeHidden(); }); + +test('un salarié sans compte applicatif est créable', async ({ page }) => { + await signIn(page, 'direction@example.test'); + await page.goto('/equipe'); + + await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible(); + // L'effectif vient de la base, pas du module de démonstration. + await expect(page.getByText('E0001')).toBeVisible(); + + const matricule = `E9${Date.now() % 100000}`; + const form = page.locator('form').filter({ hasText: 'Ajouter' }); + await form.locator('input[name="firstName"]').fill('Sans'); + await form.locator('input[name="lastName"]').fill('Compte'); + await form.locator('input[name="employeeNumber"]').fill(matricule); + await page.getByRole('button', { name: 'Ajouter' }).click(); + await expect(page.getByText('Salarié ajouté.')).toBeVisible(); + + // Rechargement explicite : ce qui est vérifié ici est la persistance et la + // présence dans l'annuaire, pas le moment exact où la revalidation atteint + // le rendu courant. + await page.reload(); + + // Un salarié sans adresse doit exister : la plupart des équipes de vente ne + // se connectent jamais à l'outil. + await expect( + page.getByRole('cell', { name: matricule, exact: true }), + ).toBeVisible(); + // Le nom vit sur le dossier, pas sur le compte : un salarié sans accès + // applicatif doit tout de même figurer nommément au registre du personnel. + await expect(page.getByRole('link', { name: /Sans Compte/ })).toBeVisible(); +}); diff --git a/tests/unit/contracts.test.ts b/tests/unit/contracts.test.ts new file mode 100644 index 0000000..1fff885 --- /dev/null +++ b/tests/unit/contracts.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; + +import { + findOverlaps, + FORFAIT_JOURS_CAP, + isHourScheduled, + periodsOverlap, + validateContract, +} from '@/domain/contracts/rules'; + +const d = (iso: string) => new Date(`${iso}T00:00:00Z`); + +describe('periodsOverlap', () => { + it('détecte deux périodes fermées qui se recouvrent', () => { + expect( + periodsOverlap( + { startDate: d('2026-01-01'), endDate: d('2026-06-30') }, + { startDate: d('2026-06-01'), endDate: d('2026-12-31') }, + ), + ).toBe(true); + }); + + it('accepte deux périodes qui ne se touchent pas', () => { + expect( + periodsOverlap( + { startDate: d('2026-01-01'), endDate: d('2026-05-31') }, + { startDate: d('2026-06-01'), endDate: d('2026-12-31') }, + ), + ).toBe(false); + }); + + it('traite le jour de contact comme un chevauchement', () => { + // Deux contrats actifs le même jour comptent le salarié deux fois en paie. + expect( + periodsOverlap( + { startDate: d('2026-01-01'), endDate: d('2026-06-01') }, + { startDate: d('2026-06-01'), endDate: d('2026-12-31') }, + ), + ).toBe(true); + }); + + it('fait chevaucher un contrat sans terme avec tout ce qui suit', () => { + // Le cas qu'on oublie en comparant naïvement deux couples de dates : un CDI + // en cours n'a pas de fin, il couvre donc toute période postérieure. + expect( + periodsOverlap( + { startDate: d('2020-01-01'), endDate: null }, + { startDate: d('2030-01-01'), endDate: d('2030-12-31') }, + ), + ).toBe(true); + }); + + it('n’étend pas un contrat sans terme vers le passé', () => { + expect( + periodsOverlap( + { startDate: d('2026-01-01'), endDate: null }, + { startDate: d('2020-01-01'), endDate: d('2020-12-31') }, + ), + ).toBe(false); + }); +}); + +describe('findOverlaps', () => { + const existing = [ + { id: 'c1', startDate: d('2024-01-01'), endDate: d('2025-12-31') }, + { id: 'c2', startDate: d('2026-01-01'), endDate: null }, + ]; + + it('signale le contrat en conflit', () => { + const conflicts = findOverlaps( + { startDate: d('2026-06-01'), endDate: d('2026-08-31') }, + existing, + ); + expect(conflicts.map((c) => c.id)).toEqual(['c2']); + }); + + it('ignore le contrat en cours de modification', () => { + const conflicts = findOverlaps( + { id: 'c2', startDate: d('2026-06-01'), endDate: null }, + existing, + ); + expect(conflicts).toEqual([]); + }); + + it('laisse passer une période libre', () => { + expect( + findOverlaps( + { startDate: d('2023-01-01'), endDate: d('2023-06-30') }, + existing, + ), + ).toEqual([]); + }); +}); + +describe('validateContract — forfait jours', () => { + const base = { + startDate: d('2026-01-01'), + endDate: null, + weeklyHours: 0, + forfaitDaysPerYear: 218, + forfaitAgreementRef: 'CONV-2026-001', + forfaitAgreedAt: d('2025-12-15'), + workTimeArrangement: 'FORFAIT_JOURS' as const, + }; + + it('accepte un forfait complet', () => { + expect(validateContract(base)).toEqual([]); + }); + + it('refuse sans convention individuelle écrite', () => { + // Sans elle le forfait est inopposable — et l'activer supprimerait au + // passage tout contrôle de durée hebdomadaire. + const issues = validateContract({ ...base, forfaitAgreementRef: '' }); + expect(issues.map((i) => i.field)).toContain('forfaitAgreementRef'); + }); + + it('refuse sans accord daté du salarié', () => { + const issues = validateContract({ ...base, forfaitAgreedAt: null }); + expect(issues.map((i) => i.field)).toContain('forfaitAgreedAt'); + }); + + it('refuse au-delà du plafond conventionnel', () => { + const issues = validateContract({ + ...base, + forfaitDaysPerYear: FORFAIT_JOURS_CAP + 1, + }); + expect(issues[0]?.message).toContain('218'); + }); + + it('accepte exactement le plafond', () => { + expect( + validateContract({ ...base, forfaitDaysPerYear: FORFAIT_JOURS_CAP }), + ).toEqual([]); + }); +}); + +describe('validateContract — horaire', () => { + const base = { + startDate: d('2026-01-01'), + endDate: null, + weeklyHours: 35, + workTimeArrangement: 'HOURLY' as const, + }; + + it('accepte un contrat horaire', () => { + expect(validateContract(base)).toEqual([]); + }); + + it('refuse une durée nulle', () => { + expect(validateContract({ ...base, weeklyHours: 0 })).toHaveLength(1); + }); + + it('refuse une fin antérieure au début', () => { + const issues = validateContract({ ...base, endDate: d('2025-01-01') }); + expect(issues.map((i) => i.field)).toContain('endDate'); + }); + + it('n’exige aucune convention de forfait', () => { + expect(validateContract(base)).toEqual([]); + }); +}); + +describe('isHourScheduled', () => { + it('exclut le forfait jours du décompte horaire', () => { + expect(isHourScheduled('HOURLY')).toBe(true); + expect(isHourScheduled('FORFAIT_JOURS')).toBe(false); + }); +}); diff --git a/tests/unit/crypto.test.ts b/tests/unit/crypto.test.ts new file mode 100644 index 0000000..124faa9 --- /dev/null +++ b/tests/unit/crypto.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +process.env.DATABASE_URL ??= 'postgresql://user:pass@localhost:5432/planflow'; +process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64'); + +const crypto = await import('@/server/crypto'); + +describe('chiffrement des colonnes sensibles', () => { + it('fait un aller-retour fidèle', () => { + const nir = '1 85 04 44 109 123 45'; + expect(crypto.decrypt(crypto.encrypt(nir))).toBe(nir); + }); + + it('préserve les accents et les caractères non latins', () => { + const value = 'Rémi Chartier — 电子'; + expect(crypto.decrypt(crypto.encrypt(value))).toBe(value); + }); + + it('produit un chiffré différent à chaque appel', () => { + // Un IV constant ferait apparaître deux salariés au même IBAN comme + // identiques dans la base, sans jamais déchiffrer quoi que ce soit. + const a = crypto.encrypt('FR7630006000011234567890189'); + const b = crypto.encrypt('FR7630006000011234567890189'); + expect(a.equals(b)).toBe(false); + }); + + it('rejette un chiffré modifié', () => { + // GCM authentifie : une altération est détectée au lieu de produire du + // clair corrompu qu'on prendrait pour une donnée valide. + const payload = crypto.encrypt('FR7630006000011234567890189'); + const last = payload.length - 1; + payload.writeUInt8(payload.readUInt8(last) ^ 0xff, last); + expect(() => crypto.decrypt(payload)).toThrow(); + }); + + it('rejette un chiffré tronqué', () => { + const payload = crypto.encrypt('valeur'); + expect(() => crypto.decrypt(payload.subarray(0, 8))).toThrow(/trop court/); + }); + + it('accepte un Uint8Array, comme le renvoie Prisma', () => { + const payload = crypto.encrypt('valeur'); + const asArray = new Uint8Array(payload); + expect(crypto.decrypt(asArray)).toBe('valeur'); + }); + + it('gère les valeurs absentes sans lever', () => { + expect(crypto.encryptOptional(null)).toBeNull(); + expect(crypto.encryptOptional('')).toBeNull(); + expect(crypto.decryptOptional(null)).toBeNull(); + }); +}); + +describe('jetons', () => { + it('produit des jetons uniques et suffisamment longs', () => { + const tokens = new Set( + Array.from({ length: 200 }, () => crypto.generateToken()), + ); + expect(tokens.size).toBe(200); + for (const token of tokens) expect(token.length).toBeGreaterThanOrEqual(40); + }); + + it('ne stocke jamais le jeton en clair', () => { + const token = crypto.generateToken(); + const hash = crypto.hashToken(token); + expect(hash).not.toContain(token); + expect(hash).toHaveLength(64); + expect(crypto.hashToken(token)).toBe(hash); + }); + + it('compare à temps constant sans se tromper', () => { + expect(crypto.safeEqual('abc', 'abc')).toBe(true); + expect(crypto.safeEqual('abc', 'abd')).toBe(false); + expect(crypto.safeEqual('abc', 'abcd')).toBe(false); + }); +}); diff --git a/tests/unit/navigation.test.ts b/tests/unit/navigation.test.ts index abaab0b..8511e8b 100644 --- a/tests/unit/navigation.test.ts +++ b/tests/unit/navigation.test.ts @@ -27,10 +27,11 @@ describe('matches', () => { describe('activeItem', () => { it('retient la correspondance la plus spécifique', () => { - // Sur une fiche, « Membres » (/equipe) et « Fiche salarié » correspondent - // tous deux ; c'est la fiche qui doit s'allumer. - expect(activeItem('/equipe/camille-ferrand')?.id).toBe('fiche'); + // Une fiche salarié est un détail de « Membres » : c'est cette entrée qui + // reste allumée, et non un lien codé en dur vers un salarié particulier. + expect(activeItem('/equipe/cm123abc')?.id).toBe('membres'); expect(activeItem('/equipe')?.id).toBe('membres'); + expect(activeItem('/reglages/registre')?.id).toBe('registre'); }); it('ne renvoie rien pour une route hors navigation', () => { @@ -47,7 +48,7 @@ describe('isActive', () => { typeof href === 'string' && !href.includes('#'), ); - for (const pathname of [...targets, '/equipe/sarah-lemoine']) { + for (const pathname of [...targets, '/equipe/cm123abc']) { const lit = NAVIGATION.flatMap((section) => section.items).filter( (item) => item.href && isActive(item.href, pathname), );