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

{title}

+
+
{children}
+
+ ); +} + +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 ( + + ); +} 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 ? ( +
+ + + + +
+ + + + + +
+
+ + +
+ + + +
+ + +
+ +
+
+ + +
+ + +
+
+
+ + {state.error} + + {/* Barre collante : le dossier est long, et le bouton d'enregistrement + ne doit pas se trouver hors de l'écran au moment où l'on finit de + corriger un champ du haut. */} +
+ + Enregistrer les modifications +
+
+ ) : ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + {canEdit ? ( +
+ +
+ ) : 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 ? ( +
+ + + + + + {state.error} + +
+ Enregistrer + +
+ + ) : ( + <> + + + + {canEdit ? ( +
+ +
+ ) : 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 ? ( + + ) : ( +
    + {[...(active ? [active] : []), ...past].map((contract) => ( +
  • +
    + {contract.label} + {contract.forfaitJours ? ( + Forfait jours + ) : ( + {contract.weeklyHours} h + )} + + {dateFormat.format(contract.startDate)} + {contract.endDate + ? ` → ${dateFormat.format(contract.endDate)}` + : ' → en cours'} + + + {employee.canSeeSalary && contract.monthlySalary ? ( + + {contract.monthlySalary} € brut + + ) : null} + + {contract.status === 'ACTIVE' ? 'En cours' : 'Terminé'} + +
    + + {contract.amendments.length > 0 ? ( +
      + {contract.amendments.map((amendment) => ( +
    • + + {dateFormat.format(amendment.effectiveDate)} + {' '} + — avenant + {amendment.reason ? ` · ${amendment.reason}` : ''} +
    • + ))} +
    + ) : null} +
  • + ))} +
+ )} +
+
+ ); +} 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 ( + +
+
+ + {employee.firstName.charAt(0)} + {employee.lastName.charAt(0)} + +
+

+ {employee.firstName} {employee.lastName} +

+

+ {employee.headline.jobTitle ?? + `Matricule ${employee.employeeNumber}`} +

+
+ +
+ {!employee.hasAccount ? ( + Sans accès applicatif + ) : null} + {employee.roleName} +
+
+ +
+ + + + + + +
+
+ + + + {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.contracts.map((contract) => ( -
  • -
    - {contract.label} - {contract.forfaitJours ? ( - Forfait jours - ) : ( - {contract.weeklyHours} h - )} - - {dateFormat.format(contract.startDate)} - {contract.endDate - ? ` → ${dateFormat.format(contract.endDate)}` - : ' → en cours'} - - - {employee.canSeeSalary && contract.monthlySalary ? ( - - {contract.monthlySalary} € brut - - ) : null} - - {contract.status === 'ACTIVE' ? 'En cours' : 'Terminé'} - -
    - - {contract.amendments.length > 0 ? ( -
      - {contract.amendments.map((amendment) => ( -
    • - - {dateFormat.format(amendment.effectiveDate)} - {' '} - — avenant{amendment.reason ? ` · ${amendment.reason}` : ''} -
    • - ))} -
    - ) : null} -
  • - ))} -
- )} -
- - {employee.profile ? ( - - -
-
-
Téléphone
-
{employee.profile.phone ?? '—'}
-
-
-
Ville
-
{employee.profile.city ?? '—'}
-
-
-
- Numéro de sécurité sociale -
-
- {/* Non chargé quand la capacité manque : un champ absent de la - réponse ne peut fuiter ni par le HTML ni par un journal. */} - {employee.profile.socialSecurityNumber ?? ( - Accès non autorisé - )} -
-
-
-
IBAN
-
- {employee.profile.iban ?? ( - Accès non autorisé - )} -
-
-
-
- ) : null} -
+ ); } diff --git a/src/app/(app)/equipe/[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' })