Merge pull request #10 — WP-03 dossiers salariés et contrats
WP-03 — dossiers salariés et contrats sur données réelles
This commit is contained in:
18 files changed
+1667
-210
No files matched your search
@@ -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;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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).'],
|
||||
|
||||
@@ -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<ActionState, FormData>(
|
||||
createEmployeeAction,
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Prénom" name="firstName" required />
|
||||
<Field label="Nom" name="lastName" required />
|
||||
<Field label="Matricule" name="employeeNumber" required />
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Adresse électronique"
|
||||
name="email"
|
||||
type="email"
|
||||
hint="Facultative. Un salarié sans adresse reste plannifiable et déclarable ; il n'aura simplement pas d'accès à l'application."
|
||||
/>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Ajouter</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Salarié ajouté.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
+122
-103
@@ -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 (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title={fullName(employee)}
|
||||
subtitle={`${employee.job} · Nantes Atlantis · entrée le ${employee.since}`}
|
||||
actions={
|
||||
<>
|
||||
<Button>Documents</Button>
|
||||
<Button variant="primary">Modifier le contrat</Button>
|
||||
</>
|
||||
}
|
||||
title={`${employee.firstName} ${employee.lastName}`}
|
||||
subtitle={[
|
||||
employee.contract?.label,
|
||||
employee.locationName,
|
||||
`matricule ${employee.employeeNumber}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-3 border border-line-1 bg-surface p-4">
|
||||
@@ -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)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{employee.contract}</p>
|
||||
<p className="text-micro text-ink-3">
|
||||
Poste principal · {POSTE_LABELS[employee.poste]}
|
||||
<p className="text-sm font-medium">
|
||||
{employee.email ?? 'Aucun compte applicatif'}
|
||||
</p>
|
||||
<p className="text-micro text-ink-3">Rôle · {employee.roleName}</p>
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
{employee.forfaitJours ? (
|
||||
<Badge tone="accent">Forfait jours · 218 j</Badge>
|
||||
{active?.forfaitJours ? (
|
||||
<Badge tone="accent">
|
||||
Forfait jours · {active.forfaitDaysPerYear ?? '—'} j
|
||||
</Badge>
|
||||
) : active ? (
|
||||
<Badge tone="neutral">{active.weeklyHours} h hebdomadaires</Badge>
|
||||
) : null}
|
||||
{!employee.hasAccount ? (
|
||||
<Badge tone="info">Sans accès applicatif</Badge>
|
||||
) : null}
|
||||
<ul className="flex flex-wrap items-center gap-2">
|
||||
{FICHE_COUNTERS.map((counter) => (
|
||||
<li
|
||||
key={counter.label}
|
||||
className="rounded-2 border border-line-1 bg-surface-2 px-3 py-1.5"
|
||||
>
|
||||
<span className="block text-micro text-ink-3">
|
||||
{counter.label}
|
||||
</span>
|
||||
<span
|
||||
className={`tnum block text-sm font-semibold ${
|
||||
counter.tone === 'warn' ? 'text-warn-soft-ink' : 'text-ink-1'
|
||||
}`}
|
||||
>
|
||||
{counter.value}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(400px,1fr))]">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Créneaux de la semaine"
|
||||
action={
|
||||
<Button size="sm" variant="ghost">
|
||||
Voir le planning
|
||||
</Button>
|
||||
}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Contrats et avenants"
|
||||
badge={<Badge tone="neutral">{employee.contracts.length}</Badge>}
|
||||
/>
|
||||
{employee.contracts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun contrat"
|
||||
description="Ce salarié n’a pas encore de contrat. Il ne peut pas être planifié tant qu’aucune période n’est ouverte."
|
||||
/>
|
||||
) : (
|
||||
<ul>
|
||||
{FICHE_SHIFTS.map((shift, index) => {
|
||||
const tokens = posteTokens(shift.poste);
|
||||
return (
|
||||
<li
|
||||
key={`${shift.day}-${index}`}
|
||||
className="flex items-center gap-3 border-b border-line-1 px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<span className="tnum w-28 flex-none text-xs text-ink-2">
|
||||
{shift.day}
|
||||
</span>
|
||||
<span
|
||||
className="flex-none rounded-2 px-1.5 py-0.5 text-micro font-semibold tracking-wide"
|
||||
style={{
|
||||
background: tokens.bg,
|
||||
color: tokens.fg,
|
||||
border: `1px solid ${tokens.edge}`,
|
||||
}}
|
||||
title={POSTE_LABELS[shift.poste]}
|
||||
>
|
||||
{posteShort(shift.poste)}
|
||||
</span>
|
||||
<span className="tnum flex-1 text-sm">{shift.time}</span>
|
||||
<span className="tnum flex-none text-xs text-ink-2">
|
||||
{shift.worked}
|
||||
</span>
|
||||
<Badge tone={shift.tone}>{shift.state}</Badge>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Documents" />
|
||||
<ul>
|
||||
{FICHE_DOCUMENTS.map((document) => (
|
||||
{employee.contracts.map((contract) => (
|
||||
<li
|
||||
key={document.label}
|
||||
className="flex items-center gap-3 border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
key={contract.id}
|
||||
className="border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{document.label}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{contract.label}</span>
|
||||
{contract.forfaitJours ? (
|
||||
<Badge tone="accent">Forfait jours</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">{contract.weeklyHours} h</Badge>
|
||||
)}
|
||||
<span className="tnum text-xs text-ink-2">
|
||||
{dateFormat.format(contract.startDate)}
|
||||
{contract.endDate
|
||||
? ` → ${dateFormat.format(contract.endDate)}`
|
||||
: ' → en cours'}
|
||||
</span>
|
||||
<span className="tnum block text-micro text-ink-3">
|
||||
{document.date}
|
||||
</span>
|
||||
</span>
|
||||
<Badge tone={document.tone}>{document.state}</Badge>
|
||||
<span className="flex-1" />
|
||||
{employee.canSeeSalary && contract.monthlySalary ? (
|
||||
<span className="tnum text-sm">
|
||||
{contract.monthlySalary} € brut
|
||||
</span>
|
||||
) : null}
|
||||
<Badge
|
||||
tone={contract.status === 'ACTIVE' ? 'ok' : 'neutral'}
|
||||
>
|
||||
{contract.status === 'ACTIVE' ? 'En cours' : 'Terminé'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{contract.amendments.length > 0 ? (
|
||||
<ul className="mt-2 border-l-2 border-line-2 pl-3">
|
||||
{contract.amendments.map((amendment) => (
|
||||
<li
|
||||
key={amendment.id}
|
||||
className="py-1 text-xs text-ink-2"
|
||||
>
|
||||
<span className="tnum">
|
||||
{dateFormat.format(amendment.effectiveDate)}
|
||||
</span>{' '}
|
||||
— avenant{amendment.reason ? ` · ${amendment.reason}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{employee.profile ? (
|
||||
<Card>
|
||||
<CardHeader title="Dossier personnel" />
|
||||
<dl className="grid gap-x-8 gap-y-3 p-4 text-sm [grid-template-columns:repeat(auto-fit,minmax(200px,1fr))]">
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">Téléphone</dt>
|
||||
<dd className="tnum">{employee.profile.phone ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">Ville</dt>
|
||||
<dd>{employee.profile.city ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">
|
||||
Numéro de sécurité sociale
|
||||
</dt>
|
||||
<dd className="tnum">
|
||||
{/* Non chargé quand la capacité manque : un champ absent de la
|
||||
réponse ne peut fuiter ni par le HTML ni par un journal. */}
|
||||
{employee.profile.socialSecurityNumber ?? (
|
||||
<span className="text-ink-3">Accès non autorisé</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">IBAN</dt>
|
||||
<dd className="tnum">
|
||||
{employee.profile.iban ?? (
|
||||
<span className="text-ink-3">Accès non autorisé</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
+109
-98
@@ -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<string, { label: string; tone: Tone }> = {
|
||||
actif: { label: 'Actif', tone: 'ok' },
|
||||
essai: { label: 'Période d’essai', tone: 'info' },
|
||||
sortie: { label: 'Sortie prévue', tone: 'warn' },
|
||||
const STATUS_TONES: Record<string, { label: string; tone: Tone }> = {
|
||||
ACTIVE: { label: 'Actif', tone: 'ok' },
|
||||
INVITED: { label: 'Invitation en attente', tone: 'info' },
|
||||
ARCHIVED: { label: 'Archivé', tone: 'neutral' },
|
||||
};
|
||||
|
||||
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 (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Équipe"
|
||||
subtitle={`Nantes Atlantis · ${actifs} salariés actifs, ${essai} en période d’essai`}
|
||||
actions={
|
||||
<>
|
||||
<Button>Registre unique du personnel</Button>
|
||||
<Button variant="primary">Nouvelle embauche</Button>
|
||||
</>
|
||||
}
|
||||
subtitle={`${employees.length} salarié${employees.length > 1 ? 's' : ''}${
|
||||
withoutAccount > 0 ? ` · ${withoutAccount} sans compte applicatif` : ''
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<table className="w-full min-w-[880px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 bg-surface-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
<th scope="col" className="px-4 py-2.5">
|
||||
Collaborateur
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-2.5">
|
||||
Poste principal
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-2.5">
|
||||
Contrat
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-2.5">
|
||||
Entrée
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-2.5">
|
||||
Statut
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{EMPLOYEES.map((employee) => {
|
||||
const tokens = posteTokens(employee.poste);
|
||||
const status = STATUS[employee.status] ?? STATUS.actif!;
|
||||
return (
|
||||
<tr
|
||||
key={employee.id}
|
||||
className="border-b border-line-1 last:border-b-0 hover:bg-surface-2"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<Link
|
||||
href={`/equipe/${employee.id}`}
|
||||
className="flex items-center gap-2.5 rounded-2"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-7 flex-none items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
|
||||
>
|
||||
{initials(employee)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">
|
||||
{fullName(employee)}
|
||||
</span>
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
{employee.job}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-2 px-2 py-0.5 text-micro"
|
||||
style={{
|
||||
background: tokens.bg,
|
||||
color: tokens.fg,
|
||||
border: `1px solid ${tokens.edge}`,
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold tracking-wide">
|
||||
{posteShort(employee.poste)}
|
||||
</span>
|
||||
{POSTE_LABELS[employee.poste]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="tnum px-4 py-2.5 text-ink-2">
|
||||
{employee.contract}
|
||||
</td>
|
||||
<td className="tnum px-4 py-2.5 text-ink-2">
|
||||
{employee.since}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge tone={status.tone}>{status.label}</Badge>
|
||||
</td>
|
||||
<Card>
|
||||
<CardHeader title="Effectif" />
|
||||
{employees.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun salarié"
|
||||
description="Ajoutez un premier salarié pour commencer à planifier."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[880px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 bg-surface-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
<th className="px-4 py-2.5">Collaborateur</th>
|
||||
<th className="px-4 py-2.5">Matricule</th>
|
||||
<th className="px-4 py-2.5">Contrat</th>
|
||||
<th className="px-4 py-2.5">Établissement</th>
|
||||
<th className="px-4 py-2.5">Rôle</th>
|
||||
<th className="px-4 py-2.5">Statut</th>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.map((employee) => {
|
||||
const status =
|
||||
STATUS_TONES[employee.status] ?? STATUS_TONES.ACTIVE!;
|
||||
return (
|
||||
<tr
|
||||
key={employee.id}
|
||||
className="border-b border-line-1 last:border-b-0 hover:bg-surface-2"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<Link
|
||||
href={`/equipe/${employee.id}`}
|
||||
className="flex items-center gap-2.5 rounded-2"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-7 flex-none items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</span>
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
{employee.email ?? 'Sans compte applicatif'}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="tnum px-4 py-2.5 text-ink-2">
|
||||
{employee.employeeNumber}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{employee.contract ? (
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span>{employee.contract.label}</span>
|
||||
{employee.contract.forfaitJours ? (
|
||||
<Badge tone="accent">Forfait jours</Badge>
|
||||
) : null}
|
||||
<span className="tnum text-micro text-ink-3">
|
||||
depuis le{' '}
|
||||
{dateFormat.format(employee.contract.since)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<Badge tone="warn">Sans contrat</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">
|
||||
{employee.locationName ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">
|
||||
{employee.roleName}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge tone={status.tone}>{status.label}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Ajouter un salarié" />
|
||||
<div className="p-4">
|
||||
<AddEmployeeForm />
|
||||
</div>
|
||||
</Card>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
+10
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ActionState> {
|
||||
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<ActionState> {
|
||||
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<ActionState> {
|
||||
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<string, { before: unknown; after: unknown }> = {};
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<EmployeeListRow[]> {
|
||||
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<EmployeeDetail | null> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user