Faire de la fiche salarié un dossier à onglets, et le rendre saisissable
La fiche empilait tout sur une page, en lecture seule. Le dossier personnel existait pourtant en base — date et lieu de naissance, nationalité, adresse, contact d'urgence, NIR et IBAN chiffrés — sans qu'aucun écran ne l'écrive : un salarié créé restait un dossier vide que rien ne permettait de remplir. Cinq onglets, cinq routes : une fiche s'envoie par lien et se rouvre au même endroit. Le bandeau porte ce qui ne change pas d'un onglet à l'autre, et le gabarit le tient une fois pour toutes. Deux points d'écriture et non un seul. L'état civil et les coordonnées demandent `members.edit` ; le NIR, l'IBAN et le BIC exigent en plus de pouvoir les lire. Fondus dans un même formulaire, un profil habilité à modifier mais pas à lire aurait renvoyé des champs vides — et effacé ce qui ne lui avait jamais été montré. Le journal retient qu'ils ont été renseignés, jamais leur valeur. Les parcours qui déposaient une pièce ou ouvraient un accès passent désormais par l'onglet qui les porte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a05fda2f9
commit
c6abbaadc5
18 files changed
+1529
-195
No files matched your search
@@ -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 (
|
||||
<section className="rounded-3 border border-line-1 bg-surface p-5">
|
||||
<header className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-7 flex-none rounded-2 border border-accent bg-accent-soft"
|
||||
/>
|
||||
<h2 className="text-md font-semibold">{title}</h2>
|
||||
</header>
|
||||
<dl className="mt-4">{children}</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line-1 py-3 last:border-b-0">
|
||||
<dt className="min-w-[11rem] flex-none text-sm font-medium text-ink-1">
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={
|
||||
value
|
||||
? `text-sm text-ink-2${tnum ? ' tnum' : ''}`
|
||||
: 'text-sm text-ink-3'
|
||||
}
|
||||
>
|
||||
{value || 'Non renseigné'}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Grille à deux colonnes des cartes du dossier. */
|
||||
export function InfoGrid({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid items-start gap-5 [grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr))]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<nav
|
||||
aria-label="Sections de la fiche"
|
||||
className="flex gap-1 overflow-x-auto rounded-3 border border-line-1 bg-surface p-1.5"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const href = tab.segment ? `${base}/${tab.segment}` : base;
|
||||
const active = tab.segment
|
||||
? pathname.startsWith(href)
|
||||
: pathname === base;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.segment}
|
||||
href={href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cx(
|
||||
'shrink-0 rounded-full px-3.5 py-1.5 text-sm font-medium whitespace-nowrap',
|
||||
'transition-colors duration-[var(--d-1)] ease-organic',
|
||||
active
|
||||
? 'bg-surface-3 text-ink-1'
|
||||
: 'text-ink-2 hover:bg-surface-2 hover:text-ink-1',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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<ProfileActionState>(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 (
|
||||
<div className="flex flex-col gap-5">
|
||||
{editing ? (
|
||||
<form action={save} className="flex flex-col gap-5">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
<InfoGrid>
|
||||
<InfoCard title="État civil">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Field label="Prénom" name="firstName" required defaultValue={profile.firstName} />
|
||||
<Field label="Nom de famille" name="lastName" required defaultValue={profile.lastName} />
|
||||
<Field
|
||||
label="Date de naissance"
|
||||
name="birthDate"
|
||||
type="date"
|
||||
defaultValue={profile.birthDate}
|
||||
/>
|
||||
<Field
|
||||
label="Lieu de naissance"
|
||||
name="birthPlace"
|
||||
defaultValue={profile.birthPlace}
|
||||
hint="Commune, et département pour la France."
|
||||
/>
|
||||
<Field label="Nationalité" name="nationality" defaultValue={profile.nationality} />
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Coordonnées">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Field
|
||||
label="Adresse électronique personnelle"
|
||||
name="personalEmail"
|
||||
type="email"
|
||||
defaultValue={profile.personalEmail}
|
||||
/>
|
||||
<Field label="Téléphone mobile" name="phone" type="tel" defaultValue={profile.phone} />
|
||||
<Field label="Adresse" name="addressLine1" defaultValue={profile.addressLine1} />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Code postal" name="postalCode" defaultValue={profile.postalCode} />
|
||||
<Field label="Ville" name="city" defaultValue={profile.city} />
|
||||
</div>
|
||||
<Field label="Pays" name="country" defaultValue={profile.country} />
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Contact d’urgence">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Field
|
||||
label="Nom"
|
||||
name="emergencyContactName"
|
||||
defaultValue={profile.emergencyContactName}
|
||||
/>
|
||||
<Field
|
||||
label="Téléphone"
|
||||
name="emergencyContactPhone"
|
||||
type="tel"
|
||||
defaultValue={profile.emergencyContactPhone}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="sticky bottom-4 flex flex-wrap items-center justify-center gap-3 rounded-3 border border-line-1 bg-surface p-3 shadow-[var(--shadow-1,0_1px_2px_rgba(0,0,0,0.06))]">
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<SubmitButton>Enregistrer les modifications</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<InfoGrid>
|
||||
<InfoCard title="État civil">
|
||||
<InfoRow label="Prénom" value={profile.firstName} />
|
||||
<InfoRow label="Nom de famille" value={profile.lastName} />
|
||||
<InfoRow label="Date de naissance" value={readableDate(profile.birthDate)} tnum />
|
||||
<InfoRow label="Lieu de naissance" value={profile.birthPlace} />
|
||||
<InfoRow label="Nationalité" value={profile.nationality} />
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Coordonnées">
|
||||
<InfoRow label="Adresse électronique personnelle" value={profile.personalEmail} />
|
||||
<InfoRow label="Téléphone mobile" value={profile.phone} tnum />
|
||||
<InfoRow label="Adresse" value={profile.addressLine1} />
|
||||
<InfoRow label="Code postal" value={profile.postalCode} tnum />
|
||||
<InfoRow label="Ville" value={profile.city} />
|
||||
<InfoRow label="Pays" value={profile.country} />
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Contact d’urgence">
|
||||
<InfoRow label="Nom" value={profile.emergencyContactName} />
|
||||
<InfoRow label="Téléphone" value={profile.emergencyContactPhone} tnum />
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
|
||||
{canEdit ? (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="primary" onClick={() => setEditing(true)}>
|
||||
Modifier les informations personnelles
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{sensitive ? (
|
||||
<SensitivePanel
|
||||
membershipId={membershipId}
|
||||
sensitive={sensitive}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ProfileActionState>(empty);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title="Données protégées">
|
||||
{editing ? (
|
||||
<form action={save} className="flex flex-col gap-3 pt-1">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
<Field
|
||||
label="Numéro de sécurité sociale"
|
||||
name="socialSecurityNumber"
|
||||
inputMode="numeric"
|
||||
defaultValue={sensitive.socialSecurityNumber}
|
||||
hint="Quinze chiffres. Conservé chiffré ; jamais écrit au journal."
|
||||
/>
|
||||
<Field label="IBAN" name="iban" defaultValue={sensitive.iban} />
|
||||
<Field label="BIC" name="bic" defaultValue={sensitive.bic} />
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer</SubmitButton>
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Numéro de sécurité sociale"
|
||||
value={sensitive.socialSecurityNumber}
|
||||
tnum
|
||||
/>
|
||||
<InfoRow label="IBAN" value={sensitive.iban} tnum />
|
||||
<InfoRow label="BIC" value={sensitive.bic} tnum />
|
||||
{canEdit ? (
|
||||
<div className="pt-3">
|
||||
<Button onClick={() => setEditing(true)}>Modifier</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
@@ -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<string, Tone> = {
|
||||
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 (
|
||||
<EmptyState
|
||||
title="Absences non consultables"
|
||||
description="Votre rôle ne donne pas accès aux congés des autres salariés."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader title="Compteurs" />
|
||||
{absences.counters.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun compteur"
|
||||
description="Les compteurs s’ouvrent à la première acquisition de la période."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-x-8 gap-y-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(170px,1fr))]">
|
||||
{absences.counters.map((counter) => (
|
||||
<div key={`${counter.membershipId}-${counter.counterType}`}>
|
||||
<p className="text-micro font-semibold text-ink-1">
|
||||
{counter.counterType}
|
||||
</p>
|
||||
<p className="tnum mt-1 text-lg font-semibold">
|
||||
{counter.balance}
|
||||
</p>
|
||||
<p className="tnum text-micro text-ink-3">
|
||||
{counter.accrued} acquis · {counter.taken} pris
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Congés et absences"
|
||||
badge={<Badge tone="neutral">{absences.requests.length}</Badge>}
|
||||
/>
|
||||
{absences.requests.length === 0 ? (
|
||||
<EmptyState title="Aucune absence enregistrée" />
|
||||
) : (
|
||||
<ul>
|
||||
{absences.requests.map((request) => (
|
||||
<li
|
||||
key={request.id}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<span className="font-medium">{request.typeLabel}</span>
|
||||
<span className="tnum text-xs text-ink-2">
|
||||
{readable(request.startDate)}
|
||||
{request.endDate !== request.startDate
|
||||
? ` → ${readable(request.endDate)}`
|
||||
: ''}
|
||||
</span>
|
||||
<span className="tnum text-xs text-ink-3">
|
||||
{request.days} j
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
|
||||
{statusLabel(request.status)}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-5">
|
||||
{active ? (
|
||||
<InfoCard title="Contrat en cours">
|
||||
<InfoRow label="Type" value={active.label} />
|
||||
<InfoRow label="Début du contrat" value={dateFormat.format(active.startDate)} tnum />
|
||||
<InfoRow
|
||||
label="Fin du contrat"
|
||||
value={active.endDate ? dateFormat.format(active.endDate) : ''}
|
||||
tnum
|
||||
/>
|
||||
<InfoRow label="Emploi" value={employee.headline.jobTitle} />
|
||||
<InfoRow
|
||||
label="Organisation du temps"
|
||||
value={
|
||||
active.forfaitJours
|
||||
? `Forfait jours · ${active.forfaitDaysPerYear ?? '—'} jours par an`
|
||||
: `${active.weeklyHours} heures hebdomadaires`
|
||||
}
|
||||
/>
|
||||
{employee.canSeeSalary ? (
|
||||
<InfoRow
|
||||
label="Rémunération mensuelle brute"
|
||||
value={active.monthlySalary ? `${active.monthlySalary} €` : ''}
|
||||
tnum
|
||||
/>
|
||||
) : null}
|
||||
<InfoRow label="Établissement" value={employee.locationName} />
|
||||
</InfoCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Aucun contrat en cours"
|
||||
description="Ce salarié ne peut être ni planifié ni déclaré tant qu’aucune période n’est ouverte."
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Tous les contrats et avenants"
|
||||
badge={<Badge tone="neutral">{employee.contracts.length}</Badge>}
|
||||
/>
|
||||
{employee.contracts.length === 0 ? (
|
||||
<EmptyState title="Aucun contrat enregistré" />
|
||||
) : (
|
||||
<ul>
|
||||
{[...(active ? [active] : []), ...past].map((contract) => (
|
||||
<li
|
||||
key={contract.id}
|
||||
className="border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<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="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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-5">
|
||||
{documents ? (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Pièces du dossier"
|
||||
badge={<Badge tone="neutral">{documents.documents.length}</Badge>}
|
||||
/>
|
||||
<DocumentsPanel
|
||||
membershipId={employee.id}
|
||||
documents={documents.documents}
|
||||
canManage={documents.canManage}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Documents non consultables"
|
||||
description="Votre rôle ne donne pas accès aux pièces du dossier."
|
||||
/>
|
||||
)}
|
||||
|
||||
{employee.canInvite ? (
|
||||
<Card>
|
||||
<CardHeader title="Accès à l’application" />
|
||||
<InvitationPanel
|
||||
membershipId={employee.id}
|
||||
defaultEmail={employee.email ?? employee.profile?.personalEmail ?? ''}
|
||||
hasAccount={employee.hasAccount}
|
||||
invitation={employee.invitation}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<PageBody>
|
||||
<header className="rounded-3 bg-surface-2 p-6">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-14 flex-none items-center justify-center rounded-full bg-accent-soft text-md font-semibold text-accent-soft-ink"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.018em]">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-ink-2">
|
||||
{employee.headline.jobTitle ??
|
||||
`Matricule ${employee.employeeNumber}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!employee.hasAccount ? (
|
||||
<Badge tone="info">Sans accès applicatif</Badge>
|
||||
) : null}
|
||||
<Badge tone="accent">{employee.roleName}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-6 grid gap-x-6 gap-y-4 [grid-template-columns:repeat(auto-fit,minmax(150px,1fr))]">
|
||||
<HeadlineItem
|
||||
label="Début du contrat"
|
||||
value={
|
||||
employee.contract
|
||||
? dateFormat.format(employee.contract.since)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Fin du contrat"
|
||||
value={
|
||||
employee.headline.contractEnd
|
||||
? dateFormat.format(employee.headline.contractEnd)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Type de contrat"
|
||||
value={employee.contract?.label ?? null}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Établissement"
|
||||
value={employee.locationName}
|
||||
/>
|
||||
<HeadlineItem label="Équipe" value={primaryTeam?.name ?? null} />
|
||||
<HeadlineItem
|
||||
label="Responsable hiérarchique"
|
||||
value={employee.headline.lineManagerName}
|
||||
/>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<MemberTabs id={id} />
|
||||
|
||||
{children}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
/** Un tiret, et non « non renseigné » : le bandeau se lit d'un balayage. */
|
||||
function HeadlineItem({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-micro font-semibold text-ink-1">{label}</dt>
|
||||
<dd className="mt-1 truncate text-sm text-ink-2">{value ?? '—'}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 `<input>` 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 (
|
||||
<EmptyState
|
||||
title="Aucun dossier personnel"
|
||||
description="Ce salarié n’a pas de dossier. Il a probablement été créé hors de l’application ; reprenez sa création depuis la liste de l’équipe."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
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">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-11 flex-none items-center justify-center rounded-full bg-surface-3 text-sm font-semibold text-ink-2"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<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" />
|
||||
{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}
|
||||
</div>
|
||||
|
||||
{documents ? (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Pièces du dossier"
|
||||
badge={<Badge tone="neutral">{documents.documents.length}</Badge>}
|
||||
/>
|
||||
<DocumentsPanel
|
||||
membershipId={employee.id}
|
||||
documents={documents.documents}
|
||||
canManage={documents.canManage}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{employee.canInvite ? (
|
||||
<Card>
|
||||
<CardHeader title="Accès à l’application" />
|
||||
<InvitationPanel
|
||||
membershipId={employee.id}
|
||||
defaultEmail={
|
||||
employee.email ?? employee.profile?.personalEmail ?? ''
|
||||
}
|
||||
hasAccount={employee.hasAccount}
|
||||
invitation={employee.invitation}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
{employee.contracts.map((contract) => (
|
||||
<li
|
||||
key={contract.id}
|
||||
className="border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<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="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>
|
||||
) : null}
|
||||
</PageBody>
|
||||
<PersonalInfoPanel
|
||||
membershipId={employee.id}
|
||||
profile={profile}
|
||||
sensitive={sensitive}
|
||||
canEdit={employee.canEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<InfoGrid>
|
||||
<InfoCard title="Rattachement">
|
||||
<InfoRow
|
||||
label="Établissement"
|
||||
value={placement?.contractLocationName ?? employee.locationName}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Équipes"
|
||||
value={
|
||||
placement && placement.teams.length > 0
|
||||
? placement.teams
|
||||
.map((team) =>
|
||||
team.isPrimary ? `${team.name} (principale)` : team.name,
|
||||
)
|
||||
.join(', ')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<InfoRow label="Matricule" value={employee.employeeNumber} tnum />
|
||||
<InfoRow
|
||||
label="Responsable hiérarchique"
|
||||
value={employee.headline.lineManagerName}
|
||||
/>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Périmètre d’accès">
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line-1 py-3">
|
||||
<dt className="min-w-[11rem] flex-none text-sm font-medium text-ink-1">
|
||||
Accès généralisé
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
{placement?.allLocations ? (
|
||||
<Badge tone="ok">Tous les établissements</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">Limité</Badge>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<InfoRow
|
||||
label="Établissements"
|
||||
value={
|
||||
placement?.allLocations
|
||||
? 'Tous, ouvertures futures comprises'
|
||||
: (placement?.scopedLocations.join(', ') ?? '')
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Équipes"
|
||||
value={placement?.scopedTeams.join(', ') ?? ''}
|
||||
/>
|
||||
<InfoRow label="Rôle" value={employee.roleName} />
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
);
|
||||
}
|
||||
@@ -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<MemberAbsences | null> {
|
||||
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<AbsenceBoard> {
|
||||
return query('timeoff.view_own', async (db, actor) => {
|
||||
const canSeeOthers = can(actor, 'timeoff.view_others');
|
||||
|
||||
@@ -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<ProfileActionState> {
|
||||
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<ProfileActionState> {
|
||||
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 };
|
||||
}
|
||||
+176
-14
@@ -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<EmployeeListRow[]> {
|
||||
});
|
||||
}
|
||||
|
||||
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<EmployeeDetail | null> {
|
||||
/**
|
||||
* 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<EmployeeDetail | null> {
|
||||
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<EmployeeDetail | null> {
|
||||
})
|
||||
: 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<EmployeeDetail | null> {
|
||||
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<EmployeeDetail | null> {
|
||||
})),
|
||||
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<EmployeeDetail | null> {
|
||||
: 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<MemberPlacement | null> {
|
||||
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)),
|
||||
),
|
||||
],
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in new issue
Block a user