diff --git a/src/app/(app)/equipe/[id]/InfoCard.tsx b/src/app/(app)/equipe/[id]/InfoCard.tsx
new file mode 100644
index 0000000..4346d0f
--- /dev/null
+++ b/src/app/(app)/equipe/[id]/InfoCard.tsx
@@ -0,0 +1,66 @@
+import type { ReactNode } from 'react';
+
+/**
+ * Carte de consultation du dossier.
+ *
+ * Deux colonnes par ligne — l'intitulé, puis la valeur — et un filet entre
+ * chaque : c'est la forme d'un état civil, qui se parcourt du regard en
+ * cherchant un champ précis plutôt qu'en lisant de haut en bas.
+ */
+export function InfoCard({
+ title,
+ children,
+}: {
+ title: string;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+export function InfoRow({
+ label,
+ value,
+ tnum = false,
+}: {
+ label: string;
+ value: string | null | undefined;
+ /** Chiffres alignés : dates, téléphones, numéros. */
+ tnum?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value || 'Non renseigné'}
+
+
+ );
+}
+
+/** Grille à deux colonnes des cartes du dossier. */
+export function InfoGrid({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/(app)/equipe/[id]/MemberTabs.tsx b/src/app/(app)/equipe/[id]/MemberTabs.tsx
new file mode 100644
index 0000000..d8c20df
--- /dev/null
+++ b/src/app/(app)/equipe/[id]/MemberTabs.tsx
@@ -0,0 +1,58 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+import { cx } from '@/lib/cx';
+
+/**
+ * Onglets de la fiche salarié.
+ *
+ * Chaque onglet est une **route**, pas un état de composant : une fiche
+ * s'envoie par lien, se rouvre au même endroit, et le retour arrière ramène à
+ * l'onglet précédent plutôt qu'à la liste.
+ */
+
+const TABS = [
+ { segment: '', label: 'Informations personnelles' },
+ { segment: 'contrats', label: 'Contrats' },
+ { segment: 'planification', label: 'Planification et accès' },
+ { segment: 'absences', label: 'Congés et Absences' },
+ { segment: 'documents', label: 'Documents' },
+] as const;
+
+export function MemberTabs({ id }: { id: string }) {
+ const pathname = usePathname();
+ const base = `/equipe/${id}`;
+
+ return (
+
+ {TABS.map((tab) => {
+ const href = tab.segment ? `${base}/${tab.segment}` : base;
+ const active = tab.segment
+ ? pathname.startsWith(href)
+ : pathname === base;
+
+ return (
+
+ {tab.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx b/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx
new file mode 100644
index 0000000..c676f4a
--- /dev/null
+++ b/src/app/(app)/equipe/[id]/PersonalInfoPanel.tsx
@@ -0,0 +1,272 @@
+'use client';
+
+import { useActionState, useState } from 'react';
+
+import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
+import { Button } from '@/components/ui/Button';
+import { Field, FormError, SubmitButton } from '@/components/ui/Form';
+import {
+ updateProfileAction,
+ updateSensitiveAction,
+ type ProfileActionState,
+} from '@/server/employees/profile-actions';
+
+/**
+ * Dossier personnel : consultation, puis saisie.
+ *
+ * Un seul écran et deux modes, plutôt qu'une page de lecture et une page de
+ * formulaire. Le dossier se corrige champ par champ, souvent au téléphone avec
+ * la personne concernée : la faire changer de page pour changer un chiffre
+ * ferait perdre le reste de vue.
+ */
+
+export interface ProfileFields {
+ firstName: string;
+ lastName: string;
+ birthDate: string;
+ birthPlace: string;
+ nationality: string;
+ personalEmail: string;
+ phone: string;
+ addressLine1: string;
+ postalCode: string;
+ city: string;
+ country: string;
+ emergencyContactName: string;
+ emergencyContactPhone: string;
+}
+
+export interface SensitiveFields {
+ socialSecurityNumber: string;
+ iban: string;
+ bic: string;
+}
+
+const empty: ProfileActionState = {};
+
+const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
+
+function readableDate(value: string): string {
+ if (!value) return '';
+ const parsed = new Date(`${value}T00:00:00Z`);
+ return Number.isNaN(parsed.getTime()) ? value : dateFormat.format(parsed);
+}
+
+export function PersonalInfoPanel({
+ membershipId,
+ profile,
+ sensitive,
+ canEdit,
+}: {
+ membershipId: string;
+ profile: ProfileFields;
+ /** Absent quand la capacité de lecture manque : la carte n'est pas rendue. */
+ sensitive: SensitiveFields | null;
+ canEdit: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [state, save] = useActionState(updateProfileAction, empty);
+ const [acknowledged, setAcknowledged] = useState(empty);
+
+ // L'enregistrement referme le mode saisie, mais seulement après la réponse :
+ // refermer à l'envoi ferait disparaître le message d'erreur avec le
+ // formulaire, et avec lui ce que la personne venait de taper.
+ //
+ // La réponse est **acquittée** une fois pour toutes : sans cela, un succès
+ // resté en mémoire refermerait aussitôt le formulaire à la prochaine
+ // ouverture, et le dossier ne serait plus jamais modifiable.
+ if (state !== acknowledged && state.ok) {
+ setAcknowledged(state);
+ setEditing(false);
+ }
+
+ return (
+
+ {editing ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {canEdit ? (
+
+ setEditing(true)}>
+ Modifier les informations personnelles
+
+
+ ) : null}
+ >
+ )}
+
+ {sensitive ? (
+
+ ) : null}
+
+ );
+}
+
+/**
+ * NIR, IBAN et BIC.
+ *
+ * Séparés du reste, et pas seulement à l'écran : ils sont chiffrés au repos,
+ * leur lecture demande sa propre capacité, et leur écriture passe par une autre
+ * action. Les mêler au formulaire commun ferait dépendre leur sort d'un droit
+ * qui ne les concerne pas.
+ */
+function SensitivePanel({
+ membershipId,
+ sensitive,
+ canEdit,
+}: {
+ membershipId: string;
+ sensitive: SensitiveFields;
+ canEdit: boolean;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [state, save] = useActionState(updateSensitiveAction, empty);
+ const [acknowledged, setAcknowledged] = useState(empty);
+
+ if (state !== acknowledged && state.ok) {
+ setAcknowledged(state);
+ setEditing(false);
+ }
+
+ return (
+
+ {editing ? (
+
+ ) : (
+ <>
+
+
+
+ {canEdit ? (
+
+ setEditing(true)}>Modifier
+
+ ) : null}
+ >
+ )}
+
+ );
+}
diff --git a/src/app/(app)/equipe/[id]/absences/page.tsx b/src/app/(app)/equipe/[id]/absences/page.tsx
new file mode 100644
index 0000000..6984f62
--- /dev/null
+++ b/src/app/(app)/equipe/[id]/absences/page.tsx
@@ -0,0 +1,99 @@
+import { Badge, type Tone } from '@/components/ui/Badge';
+import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
+import { listMemberAbsences, statusLabel } from '@/server/absences/queries';
+
+export const dynamic = 'force-dynamic';
+
+const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
+
+const STATUS_TONES: Record = {
+ PENDING: 'warn',
+ ACCEPTED: 'ok',
+ DECLINED: 'danger',
+ CANCELLED: 'neutral',
+ EXPIRED: 'neutral',
+};
+
+const readable = (iso: string) => dateFormat.format(new Date(`${iso}T00:00:00Z`));
+
+export default async function AbsencesTab({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ const absences = await listMemberAbsences(id);
+
+ if (!absences) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {absences.counters.length === 0 ? (
+
+ ) : (
+
+ {absences.counters.map((counter) => (
+
+
+ {counter.counterType}
+
+
+ {counter.balance}
+
+
+ {counter.accrued} acquis · {counter.taken} pris
+
+
+ ))}
+
+ )}
+
+
+
+ {absences.requests.length}}
+ />
+ {absences.requests.length === 0 ? (
+
+ ) : (
+
+ {absences.requests.map((request) => (
+
+ {request.typeLabel}
+
+ {readable(request.startDate)}
+ {request.endDate !== request.startDate
+ ? ` → ${readable(request.endDate)}`
+ : ''}
+
+
+ {request.days} j
+
+
+
+ {statusLabel(request.status)}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(app)/equipe/[id]/contrats/page.tsx b/src/app/(app)/equipe/[id]/contrats/page.tsx
new file mode 100644
index 0000000..5877c0a
--- /dev/null
+++ b/src/app/(app)/equipe/[id]/contrats/page.tsx
@@ -0,0 +1,122 @@
+import { notFound } from 'next/navigation';
+
+import { InfoCard, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
+import { Badge } from '@/components/ui/Badge';
+import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
+import { getEmployee } from '@/server/employees/queries';
+
+export const dynamic = 'force-dynamic';
+
+const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
+
+export default async function ContractsTab({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ const employee = await getEmployee(id);
+ if (!employee) notFound();
+
+ const active = employee.contracts.find(
+ (contract) => contract.status === 'ACTIVE',
+ );
+ const past = employee.contracts.filter(
+ (contract) => contract.id !== active?.id,
+ );
+
+ return (
+
+ {active ? (
+
+
+
+
+
+
+ {employee.canSeeSalary ? (
+
+ ) : null}
+
+
+ ) : (
+
+ )}
+
+
+ {employee.contracts.length}}
+ />
+ {employee.contracts.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(app)/equipe/[id]/documents/page.tsx b/src/app/(app)/equipe/[id]/documents/page.tsx
new file mode 100644
index 0000000..fd92f24
--- /dev/null
+++ b/src/app/(app)/equipe/[id]/documents/page.tsx
@@ -0,0 +1,59 @@
+import { notFound } from 'next/navigation';
+
+import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel';
+import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
+import { Badge } from '@/components/ui/Badge';
+import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
+import { listDocuments } from '@/server/documents/queries';
+import { getEmployee } from '@/server/employees/queries';
+
+export const dynamic = 'force-dynamic';
+
+export default async function DocumentsTab({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ const employee = await getEmployee(id);
+ if (!employee) notFound();
+
+ // `members.documents.view` peut manquer là où `members.view` est accordée :
+ // la section disparaît alors, plutôt que d'échouer sur toute la page.
+ const documents = await listDocuments(id).catch(() => null);
+
+ return (
+
+ {documents ? (
+
+ {documents.documents.length}}
+ />
+
+
+ ) : (
+
+ )}
+
+ {employee.canInvite ? (
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/app/(app)/equipe/[id]/layout.tsx b/src/app/(app)/equipe/[id]/layout.tsx
new file mode 100644
index 0000000..1a06300
--- /dev/null
+++ b/src/app/(app)/equipe/[id]/layout.tsx
@@ -0,0 +1,118 @@
+import { notFound } from 'next/navigation';
+import type { ReactNode } from 'react';
+
+import { MemberTabs } from '@/app/(app)/equipe/[id]/MemberTabs';
+import { PageBody } from '@/components/shell/PageHeader';
+import { Badge } from '@/components/ui/Badge';
+import { getEmployee, getMemberPlacement } from '@/server/employees/queries';
+
+export const dynamic = 'force-dynamic';
+
+const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'full' });
+
+/**
+ * Gabarit de la fiche salarié.
+ *
+ * Le bandeau et les onglets sont tenus ici, et non répétés dans chaque onglet :
+ * ils ne changent pas d'un onglet à l'autre, et les rejouer ferait clignoter
+ * l'identité de la personne consultée à chaque navigation.
+ */
+export default async function MemberLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ const employee = await getEmployee(id);
+ if (!employee) notFound();
+
+ const placement = await getMemberPlacement(id);
+ const primaryTeam =
+ placement?.teams.find((team) => team.isPrimary) ?? placement?.teams[0];
+
+ return (
+
+
+
+
+
+ {children}
+
+ );
+}
+
+/** Un tiret, et non « non renseigné » : le bandeau se lit d'un balayage. */
+function HeadlineItem({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | null;
+}) {
+ return (
+
+
{label}
+ {value ?? '—'}
+
+ );
+}
diff --git a/src/app/(app)/equipe/[id]/page.tsx b/src/app/(app)/equipe/[id]/page.tsx
index 80a9fd9..43f72ca 100644
--- a/src/app/(app)/equipe/[id]/page.tsx
+++ b/src/app/(app)/equipe/[id]/page.tsx
@@ -1,18 +1,20 @@
import { notFound } from 'next/navigation';
-import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel';
-import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
-import { PageBody, PageHeader } from '@/components/shell/PageHeader';
-import { Badge } from '@/components/ui/Badge';
-import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
-import { listDocuments } from '@/server/documents/queries';
+import {
+ PersonalInfoPanel,
+ type ProfileFields,
+ type SensitiveFields,
+} from '@/app/(app)/equipe/[id]/PersonalInfoPanel';
+import { EmptyState } from '@/components/ui/Card';
import { getEmployee } from '@/server/employees/queries';
export const dynamic = 'force-dynamic';
-const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
+/** `null` et `undefined` deviennent la chaîne vide : un ` ` non contrôlé
+ * affiche « null » sinon. */
+const text = (value: string | null | undefined): string => value ?? '';
-export default async function FichePage({
+export default async function PersonalTab({
params,
}: {
params: Promise<{ id: string }>;
@@ -21,181 +23,49 @@ export default async function FichePage({
const employee = await getEmployee(id);
if (!employee) notFound();
- // `members.documents.view` peut manquer là où `members.view` est accordée :
- // la section disparaît alors, plutôt que d'échouer sur toute la page.
- const documents = await listDocuments(id).catch(() => null);
+ if (!employee.profile) {
+ return (
+
+ );
+ }
- const active = employee.contracts.find(
- (contract) => contract.status === 'ACTIVE',
- );
+ const profile: ProfileFields = {
+ firstName: text(employee.profile.firstName),
+ lastName: text(employee.profile.lastName),
+ birthDate: employee.profile.birthDate
+ ? employee.profile.birthDate.toISOString().slice(0, 10)
+ : '',
+ birthPlace: text(employee.profile.birthPlace),
+ nationality: text(employee.profile.nationality),
+ personalEmail: text(employee.profile.personalEmail),
+ phone: text(employee.profile.phone),
+ addressLine1: text(employee.profile.addressLine1),
+ postalCode: text(employee.profile.postalCode),
+ city: text(employee.profile.city),
+ country: text(employee.profile.country),
+ emergencyContactName: text(employee.profile.emergencyContactName),
+ emergencyContactPhone: text(employee.profile.emergencyContactPhone),
+ };
+
+ const sensitive: SensitiveFields | null = employee.profile.sensitive
+ ? {
+ socialSecurityNumber: text(
+ employee.profile.sensitive.socialSecurityNumber,
+ ),
+ iban: text(employee.profile.sensitive.iban),
+ bic: text(employee.profile.sensitive.bic),
+ }
+ : null;
return (
-
-
-
-
-
- {employee.firstName.charAt(0)}
- {employee.lastName.charAt(0)}
-
-
-
- {employee.email ?? 'Aucun compte applicatif'}
-
-
Rôle · {employee.roleName}
-
-
- {active?.forfaitJours ? (
-
- Forfait jours · {active.forfaitDaysPerYear ?? '—'} j
-
- ) : active ? (
-
{active.weeklyHours} h hebdomadaires
- ) : null}
- {!employee.hasAccount ? (
-
Sans accès applicatif
- ) : null}
-
-
- {documents ? (
-
- {documents.documents.length}}
- />
-
-
- ) : null}
-
- {employee.canInvite ? (
-
-
-
-
- ) : null}
-
-
- {employee.contracts.length}}
- />
- {employee.contracts.length === 0 ? (
-
- ) : (
-
- )}
-
-
- {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/[id]/planification/page.tsx b/src/app/(app)/equipe/[id]/planification/page.tsx
new file mode 100644
index 0000000..1dafe66
--- /dev/null
+++ b/src/app/(app)/equipe/[id]/planification/page.tsx
@@ -0,0 +1,82 @@
+import { notFound } from 'next/navigation';
+
+import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
+import { Badge } from '@/components/ui/Badge';
+import { getEmployee, getMemberPlacement } from '@/server/employees/queries';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * Rattachement et périmètre.
+ *
+ * L'établissement de rattachement est en lecture seule : il est porté par le
+ * contrat, et le changer sans avenant ferait diverger le planning du document
+ * opposable.
+ */
+export default async function PlacementTab({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ const employee = await getEmployee(id);
+ if (!employee) notFound();
+
+ const placement = await getMemberPlacement(id);
+
+ return (
+
+
+
+ 0
+ ? placement.teams
+ .map((team) =>
+ team.isPrimary ? `${team.name} (principale)` : team.name,
+ )
+ .join(', ')
+ : ''
+ }
+ />
+
+
+
+
+
+
+
+ Accès généralisé
+
+
+ {placement?.allLocations ? (
+ Tous les établissements
+ ) : (
+ Limité
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/server/absences/queries.ts b/src/server/absences/queries.ts
index bad2fe4..7e46a12 100644
--- a/src/server/absences/queries.ts
+++ b/src/server/absences/queries.ts
@@ -1,3 +1,5 @@
+import { cache } from 'react';
+
import { can } from '@/domain/access/authorize';
import { counterView, type LedgerEntry } from '@/domain/absences/ledger';
import { monthDates, monthLabel, type Month } from '@/domain/planning/month';
@@ -71,6 +73,103 @@ export function statusLabel(status: AbsenceRequest['status']): string {
return STATUS_LABEL[status];
}
+export interface MemberAbsences {
+ requests: AbsenceRequest[];
+ counters: CounterSummary[];
+}
+
+/**
+ * Absences d'un salarié — onglet de sa fiche.
+ *
+ * Le tableau mensuel répond à « qui est absent ce mois-ci » ; il ne répond pas
+ * à « qu'a pris ce salarié depuis son entrée ». D'où cette lecture, bornée à
+ * une personne et non à un mois.
+ *
+ * Rend `null` plutôt que de lever quand le dossier consulté n'est pas le sien
+ * et que la capacité manque : l'onglet se retire, le reste de la fiche tient.
+ */
+export const listMemberAbsences = cache(async function listMemberAbsences(
+ membershipId: string,
+): Promise {
+ return query('timeoff.view_own', async (db, actor) => {
+ if (
+ membershipId !== actor.membershipId &&
+ !can(actor, 'timeoff.view_others')
+ ) {
+ return null;
+ }
+
+ const canSeeMedical = can(actor, 'members.documents.view');
+
+ const member = await db.membership.findUnique({
+ where: { id: membershipId },
+ select: {
+ employeeNumber: true,
+ profile: { select: { firstName: true, lastName: true } },
+ },
+ });
+ if (!member) return null;
+
+ const name =
+ `${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim();
+
+ const timeOffs = await db.timeOff.findMany({
+ where: { membershipId },
+ include: { absenceType: true },
+ orderBy: { startDate: 'desc' },
+ });
+
+ const counters = await db.counter.findMany({
+ where: { membershipId },
+ include: { operations: true },
+ });
+
+ return {
+ requests: timeOffs.map((entry) => ({
+ id: entry.id,
+ membershipId: entry.membershipId,
+ name,
+ typeLabel:
+ entry.absenceType.isSocialSecurity && !canSeeMedical
+ ? 'Absence'
+ : entry.absenceType.name,
+ colorKey: entry.absenceType.colorKey,
+ isSocialSecurity: entry.absenceType.isSocialSecurity,
+ startDate: entry.startDate.toISOString().slice(0, 10),
+ endDate: entry.endDate.toISOString().slice(0, 10),
+ startHalfDay: entry.startHalfDay,
+ endHalfDay: entry.endHalfDay,
+ days: Number(entry.countedDays?.toString() ?? '0'),
+ status: entry.status,
+ comment:
+ entry.absenceType.isSocialSecurity && !canSeeMedical
+ ? null
+ : entry.comment,
+ decisionComment: entry.decisionComment,
+ requestedAt: entry.requestedAt,
+ })),
+ counters: counters.map((counter) => {
+ const entries: LedgerEntry[] = counter.operations.map((operation) => ({
+ kind: operation.kind,
+ quantity: Number(operation.quantity.toString()),
+ unit: operation.unit,
+ effectiveDate: operation.effectiveDate.toISOString().slice(0, 10),
+ }));
+ const view = counterView(entries, 0);
+ return {
+ membershipId: counter.membershipId,
+ name,
+ counterType: counter.counterType,
+ accrued: view.accrued,
+ taken: view.taken,
+ balance: view.balance,
+ projected: view.projected,
+ };
+ }),
+ };
+ });
+});
+
export async function getAbsenceBoard(month: Month): Promise {
return query('timeoff.view_own', async (db, actor) => {
const canSeeOthers = can(actor, 'timeoff.view_others');
diff --git a/src/server/employees/profile-actions.ts b/src/server/employees/profile-actions.ts
new file mode 100644
index 0000000..a3db89d
--- /dev/null
+++ b/src/server/employees/profile-actions.ts
@@ -0,0 +1,223 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { z } from 'zod';
+
+import { AuthorizationError, can } from '@/domain/access/authorize';
+import { recordAudit } from '@/server/audit';
+import { mutate } from '@/server/context';
+import { encryptOptional } from '@/server/crypto';
+
+/**
+ * Saisie du dossier personnel — matrice n° 1 et registre unique du personnel.
+ *
+ * Deux points d'écriture et non un seul. L'état civil et les coordonnées se
+ * modifient avec `members.edit` ; le NIR, l'IBAN et le BIC exigent en plus de
+ * pouvoir les **lire**. Les fondre dans une seule action laisserait un profil
+ * habilité à modifier mais pas à lire renvoyer des champs vides — et effacer
+ * ainsi ce qui ne lui avait jamais été montré.
+ */
+
+export interface ProfileActionState {
+ error?: string;
+ ok?: boolean;
+}
+
+class ValidationError extends Error {}
+
+/** Un champ laissé vide vaut « non renseigné », pas la chaîne vide. */
+const optionalText = (max: number) =>
+ z
+ .string()
+ .trim()
+ .max(max)
+ .transform((value) => (value === '' ? null : value));
+
+const profileInput = z.object({
+ membershipId: z.string().min(1),
+ firstName: z.string().trim().min(1, 'Prénom requis').max(80),
+ lastName: z.string().trim().min(1, 'Nom requis').max(80),
+ birthDate: optionalText(10),
+ birthPlace: optionalText(120),
+ nationality: optionalText(80),
+ personalEmail: z
+ .string()
+ .trim()
+ .max(180)
+ .refine(
+ (value) => value === '' || z.string().email().safeParse(value).success,
+ 'Adresse électronique invalide',
+ )
+ .transform((value) => (value === '' ? null : value)),
+ phone: optionalText(30),
+ addressLine1: optionalText(180),
+ postalCode: optionalText(12),
+ city: optionalText(120),
+ country: optionalText(80),
+ emergencyContactName: optionalText(120),
+ emergencyContactPhone: optionalText(30),
+});
+
+export async function updateProfileAction(
+ _previous: ProfileActionState,
+ formData: FormData,
+): Promise {
+ const parsed = profileInput.safeParse(
+ Object.fromEntries(
+ [
+ 'membershipId',
+ 'firstName',
+ 'lastName',
+ 'birthDate',
+ 'birthPlace',
+ 'nationality',
+ 'personalEmail',
+ 'phone',
+ 'addressLine1',
+ 'postalCode',
+ 'city',
+ 'country',
+ 'emergencyContactName',
+ 'emergencyContactPhone',
+ ].map((key) => [key, formData.get(key) ?? '']),
+ ),
+ );
+
+ if (!parsed.success) {
+ return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
+ }
+
+ const { membershipId, birthDate, ...rest } = parsed.data;
+
+ // Une date de naissance postérieure à aujourd'hui n'est pas une faute de
+ // frappe anodine : elle fausse l'âge, et l'âge commande l'emploi des mineurs.
+ let parsedBirthDate: Date | null = null;
+ if (birthDate) {
+ const candidate = new Date(`${birthDate}T00:00:00Z`);
+ if (Number.isNaN(candidate.getTime()) || candidate > new Date()) {
+ return { error: 'Date de naissance invalide.' };
+ }
+ parsedBirthDate = candidate;
+ }
+
+ try {
+ await mutate('members.edit', async (db, actor) => {
+ const existing = await db.employeeProfile.findUnique({
+ where: { membershipId },
+ select: { firstName: true, lastName: true },
+ });
+ if (!existing) throw new ValidationError('Dossier introuvable.');
+
+ await db.employeeProfile.update({
+ where: { membershipId },
+ data: { ...rest, birthDate: parsedBirthDate } as never,
+ });
+
+ await recordAudit(db, {
+ actorMembershipId: actor.membershipId,
+ action: 'membership.profile.update',
+ entityType: 'EmployeeProfile',
+ entityId: membershipId,
+ before: existing,
+ after: { firstName: rest.firstName, lastName: rest.lastName },
+ });
+ });
+ } catch (error) {
+ if (error instanceof ValidationError) return { error: error.message };
+ if (error instanceof AuthorizationError) {
+ return { error: 'Vous n’avez pas le droit de modifier ce dossier.' };
+ }
+ throw error;
+ }
+
+ revalidatePath('/equipe');
+ revalidatePath(`/equipe/${membershipId}`);
+ return { ok: true };
+}
+
+const sensitiveInput = z.object({
+ membershipId: z.string().min(1),
+ socialSecurityNumber: optionalText(20),
+ iban: optionalText(40),
+ bic: optionalText(15),
+});
+
+/**
+ * Écrit les trois champs chiffrés au repos.
+ *
+ * Le NIR est contrôlé sur sa forme — quinze chiffres — et non sur sa clé : un
+ * refus fondé sur un calcul que le saisisseur ne peut pas refaire à la main
+ * bloquerait des dossiers valides sans jamais dire lesquels.
+ */
+export async function updateSensitiveAction(
+ _previous: ProfileActionState,
+ formData: FormData,
+): Promise {
+ const parsed = sensitiveInput.safeParse({
+ membershipId: formData.get('membershipId') ?? '',
+ socialSecurityNumber: formData.get('socialSecurityNumber') ?? '',
+ iban: formData.get('iban') ?? '',
+ bic: formData.get('bic') ?? '',
+ });
+
+ if (!parsed.success) {
+ return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
+ }
+
+ const { membershipId } = parsed.data;
+ const nir = parsed.data.socialSecurityNumber?.replace(/\s/g, '') ?? null;
+ const iban = parsed.data.iban?.replace(/\s/g, '').toUpperCase() ?? null;
+ const bic = parsed.data.bic?.replace(/\s/g, '').toUpperCase() ?? null;
+
+ if (nir && !/^\d{15}$/.test(nir)) {
+ return { error: 'Le numéro de sécurité sociale compte quinze chiffres.' };
+ }
+ if (iban && !/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(iban)) {
+ return { error: 'IBAN invalide.' };
+ }
+
+ try {
+ await mutate('members.edit', async (db, actor) => {
+ // La capacité de lecture est exigée pour écrire : sans elle, le
+ // formulaire n'aurait pas pu afficher la valeur en place, et l'envoyer
+ // reviendrait à écraser à l'aveugle.
+ if (!can(actor, 'members.documents.view')) {
+ throw new AuthorizationError('members.documents.view');
+ }
+
+ await db.employeeProfile.update({
+ where: { membershipId },
+ data: {
+ socialSecurityNumberEnc: encryptOptional(nir),
+ ibanEnc: encryptOptional(iban),
+ bicEnc: encryptOptional(bic),
+ } as never,
+ });
+
+ // Le journal ne retient que **ce qui a été renseigné**, jamais la valeur :
+ // il se relit, s'exporte et se conserve longtemps.
+ await recordAudit(db, {
+ actorMembershipId: actor.membershipId,
+ action: 'membership.profile.sensitive.update',
+ entityType: 'EmployeeProfile',
+ entityId: membershipId,
+ after: {
+ socialSecurityNumber: nir ? 'renseigné' : 'effacé',
+ iban: iban ? 'renseigné' : 'effacé',
+ bic: bic ? 'renseigné' : 'effacé',
+ },
+ });
+ });
+ } catch (error) {
+ if (error instanceof ValidationError) return { error: error.message };
+ if (error instanceof AuthorizationError) {
+ return {
+ error: 'Vous n’avez pas le droit de modifier ces données protégées.',
+ };
+ }
+ throw error;
+ }
+
+ revalidatePath(`/equipe/${membershipId}`);
+ return { ok: true };
+}
diff --git a/src/server/employees/queries.ts b/src/server/employees/queries.ts
index e288026..4c1a221 100644
--- a/src/server/employees/queries.ts
+++ b/src/server/employees/queries.ts
@@ -1,3 +1,5 @@
+import { cache } from 'react';
+
import { can } from '@/domain/access/authorize';
import {
invitationState,
@@ -108,16 +110,45 @@ export async function listEmployees(): Promise {
});
}
-export interface EmployeeDetail extends EmployeeListRow {
- profile: {
- birthDate: Date | null;
- city: string | null;
- phone: string | null;
- personalEmail: string | null;
- /** Chiffré au repos, déchiffré seulement pour qui a le droit de le lire. */
+/**
+ * Dossier personnel tel qu'il est saisi.
+ *
+ * Les trois champs chiffrés sont regroupés à part : ils ne sont pas seulement
+ * masqués à l'affichage quand la capacité manque, ils sont **absents** de la
+ * réponse, et l'écran de saisie qui les porte n'est alors pas rendu du tout.
+ * Un formulaire affiché vide renverrait des champs vides, et effacerait ce
+ * qu'il n'avait pas le droit de lire.
+ */
+export interface EmployeeProfileDetail {
+ firstName: string;
+ lastName: string;
+ birthDate: Date | null;
+ birthPlace: string | null;
+ nationality: string | null;
+ addressLine1: string | null;
+ postalCode: string | null;
+ city: string | null;
+ country: string | null;
+ phone: string | null;
+ personalEmail: string | null;
+ emergencyContactName: string | null;
+ emergencyContactPhone: string | null;
+ /** Chiffrés au repos, déchiffrés seulement pour qui a le droit de les lire. */
+ sensitive: {
socialSecurityNumber: string | null;
iban: string | null;
+ bic: string | null;
} | null;
+}
+
+export interface EmployeeDetail extends EmployeeListRow {
+ /** Ce que porte le bandeau de la fiche, au-dessus des onglets. */
+ headline: {
+ jobTitle: string | null;
+ lineManagerName: string | null;
+ contractEnd: Date | null;
+ };
+ profile: EmployeeProfileDetail | null;
contracts: Array<{
id: string;
type: string;
@@ -134,6 +165,7 @@ export interface EmployeeDetail extends EmployeeListRow {
}>;
canSeeSalary: boolean;
canInvite: boolean;
+ canEdit: boolean;
/** Dernière invitation émise, quel que soit son sort. */
invitation: {
state: InvitationState;
@@ -143,7 +175,14 @@ export interface EmployeeDetail extends EmployeeListRow {
} | null;
}
-export async function getEmployee(id: string): Promise {
+/**
+ * Mémorisé le temps d'une requête : la fiche est désormais un gabarit à
+ * onglets, et le gabarit comme l'onglet ont besoin du même dossier. Sans cela,
+ * chaque affichage le lirait deux fois.
+ */
+export const getEmployee = cache(async function getEmployee(
+ id: string,
+): Promise {
return query('members.view', async (db, actor) => {
const canSeeSalary = can(actor, 'members.salary.view');
const canSeeDocuments = can(actor, 'members.documents.view');
@@ -175,6 +214,25 @@ export async function getEmployee(id: string): Promise {
})
: null;
+ // L'emploi est porté par le contrat, pas par le dossier : il change par
+ // avenant, et c'est l'avenant qui fait foi devant l'inspection.
+ const jobTitle = active?.jobTitleId
+ ? await db.jobTitle.findUnique({
+ where: { id: active.jobTitleId },
+ select: { name: true },
+ })
+ : null;
+
+ const lineManager = membership.lineManagerId
+ ? await db.membership.findUnique({
+ where: { id: membership.lineManagerId },
+ select: {
+ employeeNumber: true,
+ profile: { select: { firstName: true, lastName: true } },
+ },
+ })
+ : null;
+
// La plus récente, pas la seule en attente : « invitation expirée le 3 »
// est une information utile, et la masquer laisserait croire qu'aucune
// n'a jamais été envoyée.
@@ -209,18 +267,38 @@ export async function getEmployee(id: string): Promise {
forfaitJours: active.workTimeArrangement === 'FORFAIT_JOURS',
}
: null,
+ headline: {
+ jobTitle: jobTitle?.name ?? null,
+ lineManagerName: lineManager
+ ? `${lineManager.profile?.firstName ?? ''} ${lineManager.profile?.lastName ?? lineManager.employeeNumber}`.trim()
+ : null,
+ contractEnd: active?.endDate ?? null,
+ },
profile: membership.profile
? {
+ firstName: membership.profile.firstName,
+ lastName: membership.profile.lastName,
birthDate: membership.profile.birthDate,
+ birthPlace: membership.profile.birthPlace,
+ nationality: membership.profile.nationality,
+ addressLine1: membership.profile.addressLine1,
+ postalCode: membership.profile.postalCode,
city: membership.profile.city,
+ country: membership.profile.country,
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)
+ emergencyContactName: membership.profile.emergencyContactName,
+ emergencyContactPhone: membership.profile.emergencyContactPhone,
+ // Le NIR, l'IBAN et le BIC ne sont déchiffrés que pour un profil
+ // habilité — sinon le bloc entier reste absent de la réponse.
+ sensitive: canSeeDocuments
+ ? {
+ socialSecurityNumber: decryptOptional(
+ membership.profile.socialSecurityNumberEnc,
+ ),
+ iban: decryptOptional(membership.profile.ibanEnc),
+ bic: decryptOptional(membership.profile.bicEnc),
+ }
: null,
}
: null,
@@ -245,6 +323,7 @@ export async function getEmployee(id: string): Promise {
})),
canSeeSalary,
canInvite: can(actor, 'members.invite'),
+ canEdit: can(actor, 'members.edit'),
invitation: lastInvitation
? {
state: invitationState(lastInvitation, new Date()),
@@ -255,4 +334,87 @@ export async function getEmployee(id: string): Promise {
: null,
};
});
+});
+
+/** Rattachement et périmètre — onglet « Planification et accès ». */
+export interface MemberPlacement {
+ /** Établissement porté par le contrat en cours. Il ne se change qu'en avenant. */
+ contractLocationName: string | null;
+ teams: Array<{ id: string; name: string; locationName: string; isPrimary: boolean }>;
+ /** Vrai quand le périmètre couvre tout le compte, ouvertures futures comprises. */
+ allLocations: boolean;
+ scopedLocations: string[];
+ scopedTeams: string[];
}
+
+export const getMemberPlacement = cache(async function getMemberPlacement(
+ id: string,
+): Promise {
+ return query('members.view', async (db) => {
+ const membership = await db.membership.findUnique({
+ where: { id },
+ select: {
+ contracts: {
+ where: { status: 'ACTIVE' },
+ orderBy: { startDate: 'desc' },
+ take: 1,
+ select: { locationId: true },
+ },
+ teams: {
+ select: {
+ isPrimary: true,
+ team: {
+ select: {
+ id: true,
+ name: true,
+ location: { select: { name: true } },
+ },
+ },
+ },
+ },
+ scopes: {
+ select: {
+ allLocations: true,
+ location: { select: { name: true } },
+ team: { select: { name: true } },
+ },
+ },
+ },
+ });
+
+ if (!membership) return null;
+
+ const contractLocationId = membership.contracts[0]?.locationId;
+ const contractLocation = contractLocationId
+ ? await db.location.findUnique({
+ where: { id: contractLocationId },
+ select: { name: true },
+ })
+ : null;
+
+ return {
+ contractLocationName: contractLocation?.name ?? null,
+ teams: membership.teams.map((member) => ({
+ id: member.team.id,
+ name: member.team.name,
+ locationName: member.team.location.name,
+ isPrimary: member.isPrimary,
+ })),
+ allLocations: membership.scopes.some((scope) => scope.allLocations),
+ scopedLocations: [
+ ...new Set(
+ membership.scopes
+ .map((scope) => scope.location?.name)
+ .filter((name): name is string => Boolean(name)),
+ ),
+ ],
+ scopedTeams: [
+ ...new Set(
+ membership.scopes
+ .map((scope) => scope.team?.name)
+ .filter((name): name is string => Boolean(name)),
+ ),
+ ],
+ };
+ });
+});
diff --git a/tests/e2e/conservation.spec.ts b/tests/e2e/conservation.spec.ts
index 66a3b38..28505ef 100644
--- a/tests/e2e/conservation.spec.ts
+++ b/tests/e2e/conservation.spec.ts
@@ -121,6 +121,7 @@ test('une pièce sans politique n’est jamais purgée', async ({ page }) => {
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(`Garde${suffix}`) }).click();
+ await page.goto(`${page.url()}/documents`);
const upload = page.locator('form').filter({ hasText: 'Déposer' });
await upload.getByLabel('Catégorie').selectOption('OTHER');
await upload
@@ -171,6 +172,7 @@ test('une pièce échue est effectivement effacée', async ({ page }) => {
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(`Purge${suffix}`) }).click();
+ await page.goto(`${page.url()}/documents`);
const dossier = page.url();
const upload = page.locator('form').filter({ hasText: 'Déposer' });
await upload.getByLabel('Catégorie').selectOption('REGISTER');
diff --git a/tests/e2e/documents.spec.ts b/tests/e2e/documents.spec.ts
index 2020212..3ceabf8 100644
--- a/tests/e2e/documents.spec.ts
+++ b/tests/e2e/documents.spec.ts
@@ -36,6 +36,10 @@ async function createEmployee(page: Page, tag: string) {
await page.getByRole('link', { name: new RegExp(lastName) }).click();
await expect(page.getByRole('heading', { name: new RegExp(lastName) })).toBeVisible();
+
+ // La fiche s'ouvre sur les informations personnelles : les pièces sont un
+ // onglet, et une route à part entière.
+ await page.goto(`${page.url()}/documents`);
return { lastName, url: page.url() };
}
diff --git a/tests/e2e/fiche.spec.ts b/tests/e2e/fiche.spec.ts
new file mode 100644
index 0000000..4d55100
--- /dev/null
+++ b/tests/e2e/fiche.spec.ts
@@ -0,0 +1,90 @@
+import { expect, test, type Page } from '@playwright/test';
+
+/**
+ * Fiche salarié — onglets et saisie du dossier.
+ *
+ * Le dossier personnel est la première chose qu'on remplit après une embauche,
+ * et la dernière qu'on relit avant une déclaration. Ce parcours éprouve donc ce
+ * que voit un gestionnaire : les onglets tiennent leur route, et ce qui est
+ * saisi est encore là au rechargement.
+ */
+
+async function createEmployee(page: Page) {
+ const suffix = `${Date.now()}-fiche`;
+ const lastName = `Fiche${suffix}`;
+
+ await page.goto('/equipe');
+ const form = page.locator('form').filter({ hasText: 'Ajouter' });
+ await form.getByLabel('Prénom').fill('Awa');
+ await form.getByLabel('Nom', { exact: true }).fill(lastName);
+ await form.getByLabel('Matricule').fill(`FIC${suffix}`);
+ await form.getByRole('button', { name: 'Ajouter' }).click();
+ await expect(page.getByText('Salarié ajouté.')).toBeVisible();
+
+ await page.getByRole('link', { name: new RegExp(lastName) }).click();
+ await expect(
+ page.getByRole('heading', { name: new RegExp(lastName) }),
+ ).toBeVisible();
+
+ return { lastName, url: page.url() };
+}
+
+test('les onglets de la fiche sont des routes', async ({ page }) => {
+ const employee = await createEmployee(page);
+
+ // Le bandeau ne bouge pas d'un onglet à l'autre : c'est ce qui dit de qui on
+ // parle pendant qu'on navigue.
+ for (const [label, segment] of [
+ ['Contrats', '/contrats'],
+ ['Planification et accès', '/planification'],
+ ['Congés et Absences', '/absences'],
+ ['Documents', '/documents'],
+ ] as const) {
+ await page.getByRole('link', { name: label, exact: true }).click();
+ await expect(page).toHaveURL(new RegExp(`${segment}$`));
+ await expect(
+ page.getByRole('heading', { name: new RegExp(employee.lastName) }),
+ ).toBeVisible();
+ }
+
+ // Rouvrir l'onglet directement par son adresse doit donner le même écran :
+ // une fiche s'envoie par lien.
+ await page.goto(`${employee.url}/contrats`);
+ await expect(page.getByText('Tous les contrats et avenants')).toBeVisible();
+});
+
+test('le dossier personnel se saisit et se conserve', async ({ page }) => {
+ const employee = await createEmployee(page);
+
+ await expect(page.getByText('Non renseigné').first()).toBeVisible();
+ await page
+ .getByRole('button', { name: 'Modifier les informations personnelles' })
+ .click();
+
+ await page.getByLabel('Date de naissance').fill('1988-03-12');
+ await page.getByLabel('Lieu de naissance').fill('Nancy');
+ await page.getByLabel('Nationalité').fill('France');
+ await page.getByLabel('Téléphone mobile').fill('+33 6 12 34 56 78');
+ await page.getByLabel('Ville').fill('Frouard');
+ await page.getByRole('button', { name: 'Enregistrer les modifications' }).click();
+
+ await expect(page.getByText('Nancy')).toBeVisible();
+
+ // Le rechargement est la seule preuve qui vaille : un écran qui affiche ce
+ // qu'on vient de taper ne dit rien de ce qui a été écrit.
+ await page.goto(employee.url);
+ await expect(page.getByText('Nancy')).toBeVisible();
+ await expect(page.getByText('Frouard')).toBeVisible();
+});
+
+test('une date de naissance à venir est refusée', async ({ page }) => {
+ await createEmployee(page);
+
+ await page
+ .getByRole('button', { name: 'Modifier les informations personnelles' })
+ .click();
+ await page.getByLabel('Date de naissance').fill('2999-01-01');
+ await page.getByRole('button', { name: 'Enregistrer les modifications' }).click();
+
+ await expect(page.getByText('Date de naissance invalide.')).toBeVisible();
+});
diff --git a/tests/e2e/invitation.spec.ts b/tests/e2e/invitation.spec.ts
index b310e04..573743d 100644
--- a/tests/e2e/invitation.spec.ts
+++ b/tests/e2e/invitation.spec.ts
@@ -37,6 +37,10 @@ async function createEmployee(page: Page, tag: string) {
page.getByRole('heading', { name: `${firstName} ${lastName}` }),
).toBeVisible();
+ // L'accès applicatif est porté par l'onglet « Documents », avec les pièces du
+ // dossier : la fiche s'ouvre ailleurs.
+ await page.goto(`${page.url()}/documents`);
+
return { firstName, lastName, email, url: page.url() };
}
diff --git a/tests/e2e/roles.spec.ts b/tests/e2e/roles.spec.ts
index 9df3ef1..4556a40 100644
--- a/tests/e2e/roles.spec.ts
+++ b/tests/e2e/roles.spec.ts
@@ -177,6 +177,8 @@ async function inviteWithRole(page: Page, roleLabel: string) {
const { assignRole } = await import('./support/db');
await assignRole(membershipId, roleLabel);
+ // L'invitation est portée par l'onglet « Documents » de la fiche.
+ await page.goto(`/equipe/${membershipId}/documents`);
await page
.locator('form')
.filter({ hasText: 'Adresse d’invitation' })
diff --git a/tests/e2e/securite.spec.ts b/tests/e2e/securite.spec.ts
index d15ea2d..f6000f2 100644
--- a/tests/e2e/securite.spec.ts
+++ b/tests/e2e/securite.spec.ts
@@ -160,6 +160,8 @@ async function createAccessibleEmployee(page: Page) {
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
+ // L'invitation est portée par l'onglet « Documents » de la fiche.
+ await page.goto(`${page.url()}/documents`);
await page
.locator('form')
.filter({ hasText: 'Adresse d’invitation' })