Merge pull request #37 from R0m1k3/claude/mfa-lever-barriere-enrolement

Claude/mfa lever barriere enrolement
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-10 12:32:28 +02:00
commit 1d64989239
29 files changed
+7179 -270

No files matched your search

+3736
View File
File diff suppressed because it is too large. Load diff
+194 -10
View File
@@ -1,25 +1,64 @@
'use client';
import { useActionState } from 'react';
import { useActionState, useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createEmployeeAction,
type ActionState,
} from '@/server/employees/actions';
import type { ContractLocation } from '@/server/employees/queries';
export function AddEmployeeForm() {
/**
* Embauche.
*
* Un salarié créé sans contrat ni équipe est un dossier que rien ne rattache :
* il n'apparaît sur aucune grille et ne se déclare pas. Le contrat est donc
* proposé d'emblée — et reste décochable, parce qu'un remplaçant se saisit
* parfois avant que son établissement soit tranché.
*/
const empty: ActionState = {};
const CONTRACT_TYPES = [
['CDI', 'CDI'],
['CDD', 'CDD'],
['APPRENTISSAGE', 'Apprentissage'],
['STAGIAIRE', 'Stagiaire'],
['SAISONNIER', 'Saisonnier'],
['EXTRA', 'Extra'],
['INTERIM', 'Intérim'],
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
] as const;
const selectClass =
'h-9 min-w-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
export function AddEmployeeForm({
locations,
}: {
/** Vide quand la capacité d'ouvrir un contrat manque : la section disparaît. */
locations: ContractLocation[];
}) {
const [state, formAction] = useActionState<ActionState, FormData>(
createEmployeeAction,
{},
empty,
);
const [withContract, setWithContract] = useState(locations.length > 0);
const [locationId, setLocationId] = useState(locations[0]?.id ?? '');
const [forfait, setForfait] = useState(false);
const teams =
locations.find((location) => location.id === locationId)?.teams ?? [];
return (
<PersistentForm
action={formAction}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
className="flex flex-col gap-4"
>
<div className="flex flex-wrap gap-3">
<Field label="Prénom" name="firstName" required />
@@ -27,12 +66,147 @@ export function AddEmployeeForm() {
<Field label="Matricule" name="employeeNumber" required />
</div>
<Field
label="Adresse électronique"
name="email"
type="email"
hint="Facultative. Un salarié sans adresse reste plannifiable et déclarable ; il n'aura simplement pas d'accès à l'application."
/>
<div className="flex flex-wrap gap-3">
<Field
label="Adresse électronique"
name="email"
type="email"
hint="Facultative. Sans adresse, le salarié reste planifiable et déclarable ; il n’aura simplement pas d’accès à l’application."
/>
<Field label="Téléphone mobile" name="phone" type="tel" />
</div>
{locations.length > 0 ? (
<>
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
name="withContract"
checked={withContract}
onChange={(event) => setWithContract(event.target.checked)}
className="size-4 accent-[var(--accent)]"
/>
Ouvrir un contrat maintenant
</label>
{withContract ? (
<div className="flex flex-col gap-3 rounded-3 border border-line-1 bg-surface-2 p-4">
<div className="flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Établissement</span>
<select
name="locationId"
className={selectClass}
value={locationId}
onChange={(event) => setLocationId(event.target.value)}
>
{locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Équipe</span>
<select name="teamId" className={selectClass}>
<option value="">Aucune pour l’instant</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Type de contrat</span>
<select name="contractType" className={selectClass}>
{CONTRACT_TYPES.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
</div>
<div className="flex flex-wrap gap-3">
<Field
label="Début du contrat"
name="startDate"
type="date"
required={withContract}
/>
<Field
label="Fin du contrat"
name="endDate"
type="date"
hint="Vide pour un contrat sans terme."
/>
</div>
<div className="flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">
Organisation du temps
</span>
<select
name="workTimeArrangement"
className={selectClass}
onChange={(event) =>
setForfait(event.target.value === 'FORFAIT_JOURS')
}
>
<option value="HOURLY">Décompte horaire</option>
<option value="FORFAIT_JOURS">Forfait jours</option>
</select>
</label>
{forfait ? (
<Field
label="Jours par an"
name="forfaitDaysPerYear"
type="number"
step="0.5"
min="0"
max="218"
defaultValue="218"
/>
) : (
<Field
label="Durée hebdomadaire"
name="weeklyHours"
type="number"
step="0.5"
min="0"
max="60"
defaultValue="35"
/>
)}
</div>
{forfait ? (
<div className="flex flex-wrap gap-3">
<Field
label="Référence de la convention de forfait"
name="forfaitAgreementRef"
required
hint="Sans convention individuelle écrite, le forfait est inopposable."
/>
<Field
label="Date d’accord du salarié"
name="forfaitAgreedAt"
type="date"
required
/>
</div>
) : null}
</div>
) : null}
</>
) : null}
<FormError>{state.error}</FormError>
@@ -41,6 +215,16 @@ export function AddEmployeeForm() {
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Salarié ajouté.</span>
) : null}
{withContract ? null : (
<Button
type="button"
variant="ghost"
onClick={() => setWithContract(true)}
disabled={locations.length === 0}
>
Ajouter un contrat
</Button>
)}
</div>
</PersistentForm>
);
+123
View File
@@ -0,0 +1,123 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRef } from 'react';
/**
* Filtres de l'annuaire.
*
* L'état vit dans l'adresse, pas dans le composant : un effectif filtré
* s'envoie par lien, se met en favori, et le retour arrière défait le dernier
* choix plutôt que de vider la page.
*/
const selectClass =
'h-9 min-w-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
export interface FilterOptions {
locations: Array<{ id: string; name: string }>;
roles: Array<{ id: string; name: string }>;
contractTypes: Array<{ value: string; label: string }>;
}
export function DirectoryFilters({ options }: { options: FilterOptions }) {
const router = useRouter();
const params = useSearchParams();
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
const current = (key: string) => params.get(key) ?? '';
function apply(key: string, value: string) {
const next = new URLSearchParams(params.toString());
if (value) next.set(key, value);
else next.delete(key);
router.replace(next.size > 0 ? `/equipe?${next}` : '/equipe');
}
return (
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1.5">
<span className="sr-only">Rechercher un collaborateur</span>
<input
name="q"
type="search"
defaultValue={current('q')}
placeholder="Rechercher par prénom, nom ou matricule."
className="h-10 rounded-3 border border-line-2 bg-surface px-3.5 text-sm text-ink-1 outline-none placeholder:text-ink-3 focus-visible:border-focus"
onChange={(event) => {
// La frappe ne déclenche pas une requête par caractère : elle
// attend un silence. Sans cela, chercher « Dupont » en lance six.
const value = event.target.value;
if (debounce.current) clearTimeout(debounce.current);
debounce.current = setTimeout(() => apply('q', value), 300);
}}
/>
</label>
<div className="flex flex-wrap gap-2">
<select
aria-label="Établissement"
className={selectClass}
value={current('etablissement')}
onChange={(event) => apply('etablissement', event.target.value)}
>
<option value="">Tous les établissements</option>
{options.locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
<select
aria-label="Rôle"
className={selectClass}
value={current('role')}
onChange={(event) => apply('role', event.target.value)}
>
<option value="">Tous les rôles</option>
{options.roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<select
aria-label="Type de contrat"
className={selectClass}
value={current('contrat')}
onChange={(event) => apply('contrat', event.target.value)}
>
<option value="">Tous les types de contrats</option>
{options.contractTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<select
aria-label="Présence"
className={selectClass}
value={current('presence')}
onChange={(event) => apply('presence', event.target.value)}
>
<option value="">Utilisateurs actifs</option>
<option value="archived">Archivés</option>
<option value="all">Tous</option>
</select>
<select
aria-label="Tri"
className={selectClass}
value={current('tri')}
onChange={(event) => apply('tri', event.target.value)}
>
<option value="">Tri par nom</option>
<option value="number">Tri par matricule</option>
</select>
</div>
</div>
);
}
+66
View File
@@ -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>
);
}
+58
View File
@@ -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,254 @@
'use client';
import { useActionState, useState } from 'react';
import { InfoCard, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
import { Button } from '@/components/ui/Button';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import {
createAmendmentAction,
endContractAction,
type ActionState,
} from '@/server/employees/actions';
/**
* Vie du contrat en cours.
*
* Trois gestes et un seul menu : lire le détail, déclarer un changement,
* terminer. Ils sont réunis parce qu'ils portent sur le même objet, et séparés
* du formulaire de création parce qu'un contrat qui existe ne se recrée pas.
*/
const empty: ActionState = {};
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
export interface ActiveContract {
id: string;
label: string;
startDate: string;
endDate: string | null;
trialEndDate: string | null;
weeklyHours: string;
forfaitJours: boolean;
forfaitDaysPerYear: string | null;
forfaitAgreementRef: string | null;
isModulated: boolean;
classification: string | null;
coefficient: string | null;
locationName: string | null;
monthlySalary: string | null;
jobTitle: string | null;
}
const readable = (iso: string | null) =>
iso ? dateFormat.format(new Date(`${iso}T00:00:00Z`)) : '';
type Panel = 'none' | 'detail' | 'amend' | 'end';
export function ContractActions({
contract,
canEdit,
canSeeSalary,
}: {
contract: ActiveContract;
canEdit: boolean;
canSeeSalary: boolean;
}) {
const [panel, setPanel] = useState<Panel>('none');
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-md font-semibold">Contrat en cours</h2>
<span className="flex-1" />
<Button
onClick={() => setPanel(panel === 'detail' ? 'none' : 'detail')}
>
{panel === 'detail' ? 'Masquer le détail' : 'Afficher le détail'}
</Button>
{canEdit ? (
<>
<Button
onClick={() => setPanel(panel === 'amend' ? 'none' : 'amend')}
>
Déclarer un changement
</Button>
<Button
variant="danger"
onClick={() => setPanel(panel === 'end' ? 'none' : 'end')}
>
Terminer le contrat
</Button>
</>
) : null}
</div>
<InfoCard title={contract.label}>
<InfoRow label="Début du contrat" value={readable(contract.startDate)} tnum />
<InfoRow label="Fin du contrat" value={readable(contract.endDate)} tnum />
<InfoRow label="Emploi" value={contract.jobTitle} />
<InfoRow
label="Organisation du temps"
value={
contract.forfaitJours
? `Forfait jours · ${contract.forfaitDaysPerYear ?? '—'} jours par an`
: `${contract.weeklyHours} heures hebdomadaires`
}
/>
{canSeeSalary ? (
<InfoRow
label="Rémunération mensuelle brute"
value={contract.monthlySalary ? `${contract.monthlySalary} €` : ''}
tnum
/>
) : null}
<InfoRow label="Établissement" value={contract.locationName} />
{panel === 'detail' ? (
<>
<InfoRow
label="Fin de période d’essai"
value={readable(contract.trialEndDate)}
tnum
/>
<InfoRow label="Classification" value={contract.classification} />
<InfoRow label="Coefficient" value={contract.coefficient} tnum />
<InfoRow
label="Modulation"
value={contract.isModulated ? 'Oui' : 'Non'}
/>
{contract.forfaitJours ? (
<InfoRow
label="Convention de forfait"
value={contract.forfaitAgreementRef}
/>
) : null}
</>
) : null}
</InfoCard>
{panel === 'amend' ? (
<AmendmentForm
contractId={contract.id}
weeklyHours={contract.weeklyHours}
onDone={() => setPanel('none')}
/>
) : null}
{panel === 'end' ? (
<EndForm contractId={contract.id} onDone={() => setPanel('none')} />
) : null}
</div>
);
}
/**
* Avenant.
*
* Le motif est obligatoire : un changement de durée sans raison écrite est
* indéfendable devant un contrôle, et illisible six mois plus tard.
*/
function AmendmentForm({
contractId,
weeklyHours,
onDone,
}: {
contractId: string;
weeklyHours: string;
onDone: () => void;
}) {
const [state, submit] = useActionState(createAmendmentAction, empty);
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
onDone();
}
return (
<InfoCard title="Déclarer un changement">
<form action={submit} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="contractId" value={contractId} />
<div className="flex flex-wrap gap-3">
<Field
label="Date d’effet"
name="effectiveDate"
type="date"
required
/>
<Field
label="Durée hebdomadaire"
name="weeklyHours"
type="number"
step="0.5"
min="0"
max="60"
defaultValue={weeklyHours}
/>
</div>
<Field
label="Motif"
name="reason"
required
hint="Ce que l’avenant change, et pourquoi. Conservé au journal."
/>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer l’avenant</SubmitButton>
<Button type="button" onClick={onDone}>
Annuler
</Button>
</div>
</form>
</InfoCard>
);
}
function EndForm({
contractId,
onDone,
}: {
contractId: string;
onDone: () => void;
}) {
const [state, submit] = useActionState(endContractAction, empty);
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
onDone();
}
return (
<InfoCard title="Terminer le contrat">
<form action={submit} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="contractId" value={contractId} />
<p className="text-sm text-ink-2">
Le contrat est daté et clos, non supprimé : la période travaillée
reste opposable, et le solde de tout compte s’appuie dessus.
</p>
<div className="flex flex-wrap gap-3">
<Field label="Date de fin" name="endDate" type="date" required />
<Field
label="Motif de fin"
name="endReason"
required
hint="Démission, fin de CDD, rupture conventionnelle…"
/>
</div>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Terminer le contrat</SubmitButton>
<Button type="button" onClick={onDone}>
Annuler
</Button>
</div>
</form>
</InfoCard>
);
}
@@ -0,0 +1,166 @@
'use client';
import { useActionState, useState } from 'react';
import { InfoCard } from '@/app/(app)/equipe/[id]/InfoCard';
import { Button } from '@/components/ui/Button';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import {
createContractAction,
type ActionState,
} from '@/server/employees/actions';
/**
* Ouverture d'un contrat.
*
* Le forfait jours découvre ses propres champs : la convention individuelle
* écrite et sa date d'accord sont exigées par la règle métier, et les demander
* en permanence ferait porter à tous les contrats horaires deux champs qui ne
* les concernent pas.
*/
const empty: ActionState = {};
const CONTRACT_TYPES = [
['CDI', 'CDI'],
['CDD', 'CDD'],
['APPRENTISSAGE', 'Apprentissage'],
['STAGIAIRE', 'Stagiaire'],
['SAISONNIER', 'Saisonnier'],
['EXTRA', 'Extra'],
['INTERIM', 'Intérim'],
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
] as const;
const selectClass =
'h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
export function NewContractForm({
membershipId,
locations,
}: {
membershipId: string;
locations: Array<{ id: string; name: string }>;
}) {
const [state, submit] = useActionState(createContractAction, empty);
const [forfait, setForfait] = useState(false);
const [open, setOpen] = useState(false);
if (!open) {
return (
<div className="flex justify-center">
<Button variant="primary" onClick={() => setOpen(true)}>
Ouvrir un contrat
</Button>
</div>
);
}
return (
<InfoCard title="Nouveau contrat">
<form action={submit} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="membershipId" value={membershipId} />
<div className="flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Établissement</span>
<select name="locationId" required className={selectClass}>
{locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Type de contrat</span>
<select name="contractType" required className={selectClass}>
{CONTRACT_TYPES.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
</div>
<div className="flex flex-wrap gap-3">
<Field label="Début du contrat" name="startDate" type="date" required />
<Field
label="Fin du contrat"
name="endDate"
type="date"
hint="Laissez vide pour un contrat sans terme."
/>
</div>
<div className="flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Organisation du temps</span>
<select
name="workTimeArrangement"
className={selectClass}
onChange={(event) =>
setForfait(event.target.value === 'FORFAIT_JOURS')
}
>
<option value="HOURLY">Décompte horaire</option>
<option value="FORFAIT_JOURS">Forfait jours</option>
</select>
</label>
{forfait ? (
<Field
label="Jours par an"
name="forfaitDaysPerYear"
type="number"
step="0.5"
min="0"
max="218"
defaultValue="218"
hint="Plafond conventionnel : 218 jours, journée de solidarité incluse."
/>
) : (
<Field
label="Durée hebdomadaire"
name="weeklyHours"
type="number"
step="0.5"
min="0"
max="60"
defaultValue="35"
/>
)}
</div>
{forfait ? (
<div className="flex flex-wrap gap-3">
<Field
label="Référence de la convention de forfait"
name="forfaitAgreementRef"
required
hint="Sans convention individuelle écrite, le forfait est inopposable."
/>
<Field
label="Date d’accord du salarié"
name="forfaitAgreedAt"
type="date"
required
/>
</div>
) : null}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Ouvrir le contrat</SubmitButton>
<Button type="button" onClick={() => setOpen(false)}>
Annuler
</Button>
</div>
</form>
</InfoCard>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { notFound } from 'next/navigation';
import {
ContractActions,
type ActiveContract,
} from '@/app/(app)/equipe/[id]/contrats/ContractActions';
import { NewContractForm } from '@/app/(app)/equipe/[id]/contrats/NewContractForm';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
getEmployee,
listContractLocations,
} from '@/server/employees/queries';
export const dynamic = 'force-dynamic';
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
/** Les dates traversent la frontière serveur/client en ISO court : un `Date`
* y perd son fuseau, et « 1er novembre » deviendrait « 31 octobre » à l'ouest. */
const iso = (value: Date | null) => value?.toISOString().slice(0, 10) ?? null;
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',
);
// La liste n'est chargée que si elle sert : sans contrat en cours, l'écran
// propose d'en ouvrir un, et la capacité peut manquer.
const locations = active ? [] : await listContractLocations().catch(() => []);
const activeContract: ActiveContract | null = active
? {
id: active.id,
label: active.label,
startDate: iso(active.startDate) as string,
endDate: iso(active.endDate),
trialEndDate: iso(active.trialEndDate),
weeklyHours: active.weeklyHours,
forfaitJours: active.forfaitJours,
forfaitDaysPerYear: active.forfaitDaysPerYear,
forfaitAgreementRef: active.forfaitAgreementRef,
isModulated: active.isModulated,
classification: active.classification,
coefficient: active.coefficient,
locationName: active.locationName,
monthlySalary: active.monthlySalary,
jobTitle: employee.headline.jobTitle,
}
: null;
return (
<div className="flex flex-col gap-5">
{activeContract ? (
<ContractActions
contract={activeContract}
canEdit={employee.canEdit}
canSeeSalary={employee.canSeeSalary}
/>
) : (
<div className="flex flex-col gap-4">
<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."
/>
{employee.canEdit && locations.length > 0 ? (
<NewContractForm membershipId={employee.id} locations={locations} />
) : null}
</div>
)}
<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>
{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.endReason ? (
<p className="mt-1 text-xs text-ink-3">
Fin · {contract.endReason}
</p>
) : null}
{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>
);
}
+118
View File
@@ -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>
);
}
+51 -181
View File
@@ -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,186 @@
'use client';
import { useActionState, useState } from 'react';
import { InfoCard } from '@/app/(app)/equipe/[id]/InfoCard';
import { Button } from '@/components/ui/Button';
import { FormError, SubmitButton } from '@/components/ui/Form';
import {
updateScopeAction,
updateTeamsAction,
type PlacementActionState,
} from '@/server/employees/placement-actions';
import type { ContractLocation } from '@/server/employees/queries';
/**
* Périmètre et rattachement, en saisie.
*
* Deux formulaires plutôt qu'un : ils ne portent ni la même décision ni la
* même capacité, et les réunir donnerait à l'un le pouvoir de l'autre.
*/
const empty: PlacementActionState = {};
export function ScopeForm({
membershipId,
locations,
allLocations,
scopedLocationIds,
}: {
membershipId: string;
locations: ContractLocation[];
allLocations: boolean;
scopedLocationIds: string[];
}) {
const [state, save] = useActionState(updateScopeAction, empty);
const [acknowledged, setAcknowledged] = useState<PlacementActionState>(empty);
const [editing, setEditing] = useState(false);
const [all, setAll] = useState(allLocations);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
setEditing(false);
}
if (!editing) {
return (
<Button onClick={() => setEditing(true)}>Modifier le périmètre</Button>
);
}
return (
<form action={save} className="flex flex-col gap-3 pt-3">
<input type="hidden" name="membershipId" value={membershipId} />
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="allLocations"
checked={all}
onChange={(event) => setAll(event.target.checked)}
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">Accès généralisé</span>
<span className="block text-micro text-ink-3">
Tous les établissements, y compris ceux qui ouvriront plus tard.
</span>
</span>
</label>
{all ? null : (
<fieldset className="flex flex-col gap-2 rounded-3 border border-line-1 p-3">
<legend className="px-1 text-micro font-semibold text-ink-1">
Établissements
</legend>
{locations.map((location) => (
<label key={location.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="locationIds"
value={location.id}
defaultChecked={scopedLocationIds.includes(location.id)}
className="size-4 accent-[var(--accent)]"
/>
{location.name}
</label>
))}
</fieldset>
)}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer le périmètre</SubmitButton>
<Button type="button" onClick={() => setEditing(false)}>
Annuler
</Button>
</div>
</form>
);
}
export function TeamsForm({
membershipId,
locations,
teamIds,
primaryTeamId,
}: {
membershipId: string;
locations: ContractLocation[];
teamIds: string[];
primaryTeamId: string | null;
}) {
const [state, save] = useActionState(updateTeamsAction, empty);
const [acknowledged, setAcknowledged] = useState<PlacementActionState>(empty);
const [editing, setEditing] = useState(false);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
setEditing(false);
}
const withTeams = locations.filter((location) => location.teams.length > 0);
if (!editing) {
return (
<Button onClick={() => setEditing(true)}>Modifier les équipes</Button>
);
}
return (
<form action={save} className="flex flex-col gap-3 pt-3">
<input type="hidden" name="membershipId" value={membershipId} />
{withTeams.length === 0 ? (
<p className="text-sm text-ink-2">
Aucune équipe déclarée. Elles se créent dans les réglages des
établissements.
</p>
) : (
<InfoCard title="Équipes">
{withTeams.map((location) => (
<fieldset key={location.id} className="flex flex-col gap-2 py-2">
<legend className="text-micro font-semibold text-ink-1">
{location.name}
</legend>
{location.teams.map((team) => (
<div key={team.id} className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="teamIds"
value={team.id}
defaultChecked={teamIds.includes(team.id)}
className="size-4 accent-[var(--accent)]"
/>
{team.name}
</label>
<label className="flex items-center gap-2 text-micro text-ink-2">
<input
type="radio"
name="primaryTeamId"
value={team.id}
defaultChecked={primaryTeamId === team.id}
className="size-3.5 accent-[var(--accent)]"
/>
Principale
</label>
</div>
))}
</fieldset>
))}
</InfoCard>
)}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer les équipes</SubmitButton>
<Button type="button" onClick={() => setEditing(false)}>
Annuler
</Button>
</div>
</form>
);
}
@@ -0,0 +1,130 @@
import { notFound } from 'next/navigation';
import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
import {
ScopeForm,
TeamsForm,
} from '@/app/(app)/equipe/[id]/planification/PlacementPanel';
import { Badge } from '@/components/ui/Badge';
import { can } from '@/domain/access/authorize';
import { requireSession } from '@/server/context';
import {
getEmployee,
getMemberPlacement,
listPlacementOptions,
} from '@/server/employees/queries';
export const dynamic = 'force-dynamic';
/**
* Rattachement et périmètre.
*
* L'établissement de rattachement reste 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, locations, session] = await Promise.all([
getMemberPlacement(id),
listPlacementOptions(),
requireSession(),
]);
const canScope = can(session.actor, 'settings.roles.manage');
const canTeams = can(session.actor, 'settings.teams.manage');
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}
/>
<p className="pt-3 text-micro text-ink-3">
L’établissement vient du contrat : il se change par avenant, pas ici.
</p>
{canTeams ? (
<div className="pt-3">
<TeamsForm
membershipId={employee.id}
locations={locations}
teamIds={placement?.teamIds ?? []}
primaryTeamId={placement?.primaryTeamId ?? null}
/>
</div>
) : null}
</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} />
{canScope ? (
<div className="pt-3">
<ScopeForm
membershipId={employee.id}
locations={locations}
allLocations={placement?.allLocations ?? false}
scopedLocationIds={placement?.scopedLocationIds ?? []}
/>
</div>
) : (
<p className="pt-3 text-micro text-ink-3">
Élargir un périmètre revient à distribuer des droits : la capacité
« Gérer les rôles » est requise.
</p>
)}
</InfoCard>
</InfoGrid>
);
}
+105 -34
View File
@@ -1,59 +1,121 @@
import Link from 'next/link';
import { Suspense } from 'react';
import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm';
import { DirectoryFilters } from '@/app/(app)/equipe/DirectoryFilters';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge, type Tone } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm';
import { listEmployees } from '@/server/employees/queries';
import {
listContractLocations,
listEmployees,
} from '@/server/employees/queries';
export const metadata = { title: 'Équipe · PlanFlow' };
export const dynamic = 'force-dynamic';
const STATUS_TONES: Record<string, { label: string; tone: Tone }> = {
ACTIVE: { label: 'Actif', tone: 'ok' },
INVITED: { label: 'Invitation en attente', tone: 'info' },
ARCHIVED: { label: 'Archivé', tone: 'neutral' },
const INVITATION_TONES: Record<string, { label: string; tone: Tone }> = {
ACCEPTED: { label: 'Acceptée', tone: 'ok' },
PENDING: { label: 'En attente', tone: 'info' },
EXPIRED: { label: 'Expirée', tone: 'warn' },
REVOKED: { label: 'Révoquée', tone: 'neutral' },
};
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
export default async function EquipePage() {
const employees = await listEmployees();
const withoutAccount = employees.filter((e) => !e.hasAccount).length;
const one = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value;
export default async function EquipePage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const presence = one(params.presence);
const directory = await listEmployees({
...(one(params.q) ? { q: one(params.q) as string } : {}),
...(one(params.etablissement)
? { locationId: one(params.etablissement) as string }
: {}),
...(one(params.role) ? { roleId: one(params.role) as string } : {}),
...(one(params.contrat)
? { contractType: one(params.contrat) as string }
: {}),
...(presence === 'archived' || presence === 'all'
? { presence }
: { presence: 'active' as const }),
...(one(params.tri) === 'number' ? { sort: 'number' as const } : {}),
});
// Sans la capacité d'ouvrir un contrat, la liste revient vide et le
// formulaire s'en tient à l'identité.
const hiringLocations = await listContractLocations().catch(() => []);
const filtered = directory.rows.length !== directory.total;
return (
<PageBody>
<PageHeader
title="Équipe"
subtitle={`${employees.length} salarié${employees.length > 1 ? 's' : ''}${
withoutAccount > 0 ? ` · ${withoutAccount} sans compte applicatif` : ''
}`}
subtitle={
filtered
? `${directory.rows.length} sur ${directory.total} salariés`
: `${directory.total} salarié${directory.total > 1 ? 's' : ''}`
}
actions={
<Link
href="/reglages/registre"
className="inline-flex h-8 items-center rounded-2 border border-line-3 bg-surface px-3.5 text-sm font-medium text-ink-1 hover:bg-surface-2"
>
Registre unique du personnel
</Link>
}
/>
{/* `useSearchParams` impose une frontière de suspense : sans elle, la
page entière serait rendue à la demande au lieu d'être servie puis
hydratée. */}
<Suspense fallback={<div className="h-[5.5rem]" />}>
<DirectoryFilters
options={{
locations: directory.locations,
roles: directory.roles,
contractTypes: directory.contractTypes,
}}
/>
</Suspense>
<Card>
<CardHeader title="Effectif" />
{employees.length === 0 ? (
{directory.rows.length === 0 ? (
<EmptyState
title="Aucun salarié"
description="Ajoutez un premier salarié pour commencer à planifier."
title={filtered ? 'Aucun résultat' : 'Aucun salarié'}
description={
filtered
? 'Aucun salarié ne répond à ces critères. Élargissez la recherche.'
: 'Ajoutez un premier salarié pour commencer à planifier.'
}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[880px] border-collapse text-sm">
<table className="w-full min-w-[980px] border-collapse text-sm">
<thead>
<tr className="border-b border-line-2 bg-surface-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
<th className="px-4 py-2.5">Collaborateur</th>
<th className="px-4 py-2.5">Matricule</th>
<th className="px-4 py-2.5">Contrat</th>
<th className="px-4 py-2.5">Établissement</th>
<th className="px-4 py-2.5">Rôle</th>
<th className="px-4 py-2.5">Statut</th>
<th className="px-4 py-2.5">Contrat</th>
<th className="px-4 py-2.5">Mobile</th>
<th className="px-4 py-2.5">Rattachement</th>
<th className="px-4 py-2.5">Invitation</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => {
const status =
STATUS_TONES[employee.status] ?? STATUS_TONES.ACTIVE!;
{directory.rows.map((employee) => {
const invitation = employee.invitationState
? INVITATION_TONES[employee.invitationState]
: null;
return (
<tr
key={employee.id}
@@ -66,7 +128,7 @@ export default async function EquipePage() {
>
<span
aria-hidden
className="flex size-7 flex-none items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
className="flex size-7 flex-none items-center justify-center rounded-full bg-accent-soft text-micro font-semibold text-accent-soft-ink"
>
{employee.firstName.charAt(0)}
{employee.lastName.charAt(0)}
@@ -76,13 +138,13 @@ export default async function EquipePage() {
{employee.firstName} {employee.lastName}
</span>
<span className="block truncate text-micro text-ink-3">
{employee.email ?? 'Sans compte applicatif'}
{employee.email ?? `Matricule ${employee.employeeNumber}`}
</span>
</span>
</Link>
</td>
<td className="tnum px-4 py-2.5 text-ink-2">
{employee.employeeNumber}
<td className="px-4 py-2.5">
<Badge tone="accent">{employee.roleName}</Badge>
</td>
<td className="px-4 py-2.5">
{employee.contract ? (
@@ -100,14 +162,23 @@ export default async function EquipePage() {
<Badge tone="warn">Sans contrat</Badge>
)}
</td>
<td className="tnum px-4 py-2.5 text-ink-2">
{employee.phone ?? (
<span className="text-ink-3">Non renseigné</span>
)}
</td>
<td className="px-4 py-2.5 text-ink-2">
{employee.locationName ?? '—'}
</td>
<td className="px-4 py-2.5 text-ink-2">
{employee.roleName}
{employee.teamName ? ` / ${employee.teamName}` : ''}
</td>
<td className="px-4 py-2.5">
<Badge tone={status.tone}>{status.label}</Badge>
{invitation ? (
<Badge tone={invitation.tone}>{invitation.label}</Badge>
) : (
<span className="text-micro text-ink-3">
Sans accès applicatif
</span>
)}
</td>
</tr>
);
@@ -119,9 +190,9 @@ export default async function EquipePage() {
</Card>
<Card>
<CardHeader title="Ajouter un salarié" />
<CardHeader title="Ajouter un collaborateur" />
<div className="p-4">
<AddEmployeeForm />
<AddEmployeeForm locations={hiringLocations} />
</div>
</Card>
</PageBody>
+33 -3
View File
@@ -1,5 +1,6 @@
'use client';
import { useRouter } from 'next/navigation';
import { useActionState, useState, useTransition } from 'react';
import { Button } from '@/components/ui/Button';
@@ -28,8 +29,12 @@ export function MfaSettings({
enrolled: boolean;
required: boolean;
}) {
const router = useRouter();
const [offer, setOffer] = useState<OfferState | null>(null);
const [starting, startTransition] = useTransition();
// Les codes restent à l'écran tant que personne n'a dit les avoir notés : ce
// sont eux, et non l'horloge, qui décident du moment où l'on passe à la suite.
const [codesNoted, setCodesNoted] = useState(false);
const [confirmState, confirm, confirming] = useActionState(
confirmEnrolmentAction,
@@ -43,8 +48,22 @@ export function MfaSettings({
const codes = confirmState.recoveryCodes;
const active = (enrolled || confirmState.ok) && !disableState.ok;
if (codes && codes.length > 0) {
return <RecoveryCodes codes={codes} />;
if (codes && codes.length > 0 && !codesNoted) {
return (
<RecoveryCodes
codes={codes}
onNoted={() => {
setCodesNoted(true);
// Le layout applicatif décide de l'écran d'enrôlement au rendu, et un
// layout n'est pas rejoué par la navigation client : sans ce
// rafraîchissement, l'écran resterait posé sur toutes les pages, quel
// que soit le menu choisi. Il est déclenché ici, et non par l'action
// d'enrôlement, pour que les codes ne s'effacent pas avant d'avoir été
// notés.
router.refresh();
}}
/>
);
}
if (active) {
@@ -193,7 +212,13 @@ export function MfaSettings({
* Ils sont conservés hachés : ni le support ni un administrateur ne peuvent les
* relire. La seule issue, ensuite, est d'en régénérer une série.
*/
function RecoveryCodes({ codes }: { codes: string[] }) {
function RecoveryCodes({
codes,
onNoted,
}: {
codes: string[];
onNoted: () => void;
}) {
return (
<div className="flex flex-col gap-3 p-4">
<p className="text-sm text-ink-1">
@@ -214,6 +239,11 @@ function RecoveryCodes({ codes }: { codes: string[] }) {
Affichés une seule fois : ils ne sont pas conservés en clair. Chacun ne
sert qu’une fois.
</p>
<div>
<Button variant="primary" onClick={onNoted}>
J’ai noté mes codes, continuer
</Button>
</div>
</div>
);
}
+99
View File
@@ -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');
+230 -3
View File
@@ -3,7 +3,7 @@
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { AuthorizationError, can } from '@/domain/access/authorize';
import { findOverlaps, validateContract } from '@/domain/contracts/rules';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
@@ -31,14 +31,39 @@ const employeeInput = z.object({
employeeNumber: z.string().trim().min(1, 'Matricule requis').max(40),
/** Vide = salarié géré sans accès applicatif. */
email: z.string().trim().email('Adresse invalide').or(z.literal('')),
phone: z.string().trim().max(30),
});
/**
* Crée un salarié.
* Contrat posé au moment de l'embauche.
*
* Facultatif dans le formulaire, mais tout ou rien une fois entamé : un
* établissement sans date de début produirait un contrat qu'aucune règle ne
* sait valider.
*/
const hiringContractInput = z.object({
locationId: z.string().min(1, 'Établissement requis'),
teamId: z.string().trim(),
contractType: z.enum(CONTRACT_TYPES),
startDate: z.coerce.date(),
endDate: z.string().trim(),
workTimeArrangement: z.enum(['HOURLY', 'FORFAIT_JOURS']),
weeklyHours: z.coerce.number().min(0).max(60),
forfaitDaysPerYear: z.coerce.number().min(0).max(400).optional(),
forfaitAgreementRef: z.string().trim(),
forfaitAgreedAt: z.string().trim(),
});
/**
* Crée un salarié, et l'embauche s'il y a lieu.
*
* Un salarié **sans compte utilisateur** doit rester créable : la plupart des
* équipes de vente ne se connectent jamais à l'outil, et exiger une adresse
* électronique les rendrait impossibles à planifier ou à déclarer.
*
* Le contrat et l'équipe sont posés dans la **même transaction** que le
* dossier. Créés séparément, un incident au milieu laisserait un salarié sans
* contrat — c'est-à-dire un dossier qu'aucun écran ne permettait de rattraper.
*/
export async function createEmployeeAction(
_previous: ActionState,
@@ -49,14 +74,71 @@ export async function createEmployeeAction(
lastName: formData.get('lastName'),
employeeNumber: formData.get('employeeNumber'),
email: formData.get('email') ?? '',
phone: formData.get('phone') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
// Le contrat n'est tenté que si le formulaire l'a ouvert : créer un salarié
// sans contrat reste légitime — un remplaçant se saisit avant que son
// établissement soit tranché.
const wantsContract = formData.get('withContract') === 'on';
let contract: z.infer<typeof hiringContractInput> | null = null;
let contractEnd: Date | null = null;
let forfaitAgreedAt: Date | null = null;
if (wantsContract) {
const parsedContract = hiringContractInput.safeParse({
locationId: formData.get('locationId') ?? '',
teamId: formData.get('teamId') ?? '',
contractType: formData.get('contractType') ?? 'CDI',
startDate: formData.get('startDate'),
endDate: formData.get('endDate') ?? '',
workTimeArrangement: formData.get('workTimeArrangement') ?? 'HOURLY',
weeklyHours: formData.get('weeklyHours') || 35,
forfaitDaysPerYear: formData.get('forfaitDaysPerYear') || undefined,
forfaitAgreementRef: formData.get('forfaitAgreementRef') ?? '',
forfaitAgreedAt: formData.get('forfaitAgreedAt') ?? '',
});
if (!parsedContract.success) {
return {
error:
parsedContract.error.issues[0]?.message ?? 'Contrat invalide',
};
}
contract = parsedContract.data;
contractEnd = contract.endDate ? new Date(contract.endDate) : null;
forfaitAgreedAt = contract.forfaitAgreedAt
? new Date(contract.forfaitAgreedAt)
: null;
const issues = validateContract({
startDate: contract.startDate,
endDate: contractEnd,
workTimeArrangement: contract.workTimeArrangement,
weeklyHours: contract.weeklyHours,
forfaitDaysPerYear: contract.forfaitDaysPerYear ?? null,
forfaitAgreementRef: contract.forfaitAgreementRef || null,
forfaitAgreedAt,
});
if (issues.length > 0) {
return { error: issues[0]?.message ?? 'Contrat invalide' };
}
}
try {
await mutate('members.create', async (db, actor) => {
// Poser un contrat est une capacité distincte de celle de créer un
// dossier : un gestionnaire d'annuaire n'engage pas l'entreprise.
if (contract && !can(actor, 'members.contract.create')) {
throw new AuthorizationError('members.contract.create');
}
const role = await db.role.findFirst({ where: { key: 'employee' } });
if (!role) throw new Error('Rôle « employee » introuvable.');
@@ -81,9 +163,64 @@ export async function createEmployeeAction(
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
personalEmail: parsed.data.email || null,
phone: parsed.data.phone || null,
} as never,
});
if (contract) {
const location = await db.location.findUnique({
where: { id: contract.locationId },
});
if (!location) {
throw new ValidationError('Établissement introuvable.');
}
const openedContract = await db.userContract.create({
data: {
membershipId: created.id,
locationId: location.id,
contractType: contract.contractType,
startDate: contract.startDate,
endDate: contractEnd,
workTimeArrangement: contract.workTimeArrangement,
weeklyHours: contract.weeklyHours,
forfaitDaysPerYear: contract.forfaitDaysPerYear ?? null,
forfaitAgreementRef: contract.forfaitAgreementRef || null,
forfaitAgreedAt,
} as never,
});
// Sans équipe, le salarié n'apparaît sur aucune grille : le planning
// s'ordonne par équipe, et un rattachement omis se paie à la première
// semaine à couvrir.
if (contract.teamId) {
const team = await db.team.findUnique({
where: { id: contract.teamId },
select: { id: true, locationId: true },
});
if (!team || team.locationId !== location.id) {
throw new ValidationError(
'Cette équipe n’appartient pas à l’établissement choisi.',
);
}
await db.teamMember.create({
data: { teamId: team.id, membershipId: created.id } as never,
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'contract.create',
entityType: 'UserContract',
entityId: openedContract.id,
after: {
contractType: openedContract.contractType,
startDate: openedContract.startDate.toISOString(),
workTimeArrangement: openedContract.workTimeArrangement,
},
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.create',
@@ -92,13 +229,19 @@ export async function createEmployeeAction(
after: {
employeeNumber: created.employeeNumber,
hasAccount: Boolean(parsed.data.email),
withContract: Boolean(contract),
},
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de créer un salarié." };
return {
error:
error.permission === 'members.contract.create'
? "Vous n'avez pas le droit d'ouvrir un contrat. Créez le salarié sans contrat, ou faites-le poser."
: "Vous n'avez pas le droit de créer un salarié.",
};
}
throw error;
}
@@ -329,3 +472,87 @@ export async function createAmendmentAction(
if (membershipId) revalidatePath(`/equipe/${membershipId}`);
return { ok: true };
}
const endContractInput = z.object({
contractId: z.string().min(1),
endDate: z.coerce.date(),
endReason: z.string().trim().min(1, 'Motif de fin requis').max(300),
});
/**
* Termine un contrat.
*
* Le contrat n'est pas supprimé : il est **daté et clos**. Un contrôle porte
* sur une période révolue, et effacer un contrat terminé effacerait la preuve
* que le salarié a travaillé — ainsi que la base de son solde de tout compte.
*/
export async function endContractAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = endContractInput.safeParse({
contractId: formData.get('contractId'),
endDate: formData.get('endDate'),
endReason: formData.get('endReason'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
let membershipId = '';
try {
await mutate('members.contract.edit', async (db, actor) => {
const contract = await db.userContract.findUnique({
where: { id: parsed.data.contractId },
});
if (!contract) throw new AuthorizationError('members.contract.edit');
membershipId = contract.membershipId;
if (contract.status === 'ENDED') {
throw new ValidationError('Ce contrat est déjà terminé.');
}
if (parsed.data.endDate < contract.startDate) {
throw new ValidationError(
'La fin du contrat précède son début.',
);
}
await db.userContract.update({
where: { id: contract.id },
data: {
endDate: parsed.data.endDate,
endReason: parsed.data.endReason,
status: 'ENDED',
version: { increment: 1 },
},
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'contract.end',
entityType: 'UserContract',
entityId: contract.id,
before: { status: contract.status, endDate: contract.endDate?.toISOString() ?? null },
after: {
status: 'ENDED',
endDate: parsed.data.endDate.toISOString(),
},
reason: parsed.data.endReason,
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de terminer un contrat." };
}
throw error;
}
if (membershipId) {
revalidatePath(`/equipe/${membershipId}`);
revalidatePath('/equipe');
}
return { ok: true };
}
+202
View File
@@ -0,0 +1,202 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
/**
* Rattachement et périmètre.
*
* Deux capacités distinctes, parce que ce sont deux décisions distinctes :
*
* - le **périmètre** dit ce qu'une personne voit du compte. L'élargir revient à
* lui donner accès à des dossiers et des plannings qu'elle n'avait pas :
* c'est distribuer des droits, donc `settings.roles.manage` ;
* - le **rattachement** dit sur quelle grille elle apparaît. C'est de
* l'organisation du travail, donc `settings.teams.manage`.
*
* L'établissement du contrat n'est modifiable ni par l'une ni par l'autre : il
* est porté par le contrat, et ne se change que par avenant.
*/
export interface PlacementActionState {
error?: string;
ok?: boolean;
}
class ValidationError extends Error {}
const scopeInput = z.object({
membershipId: z.string().min(1),
allLocations: z.boolean(),
locationIds: z.array(z.string().min(1)),
});
export async function updateScopeAction(
_previous: PlacementActionState,
formData: FormData,
): Promise<PlacementActionState> {
const parsed = scopeInput.safeParse({
membershipId: formData.get('membershipId') ?? '',
allLocations: formData.get('allLocations') === 'on',
locationIds: formData.getAll('locationIds').map(String),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const { membershipId, allLocations, locationIds } = parsed.data;
if (!allLocations && locationIds.length === 0) {
return {
error:
'Sans accès généralisé, désignez au moins un établissement — sinon cette personne ne verrait plus rien.',
};
}
try {
await mutate('settings.roles.manage', async (db, actor) => {
const membership = await db.membership.findUnique({
where: { id: membershipId },
select: { id: true },
});
if (!membership) throw new ValidationError('Salarié introuvable.');
const known = await db.location.findMany({
where: { id: { in: locationIds } },
select: { id: true },
});
if (known.length !== locationIds.length) {
throw new ValidationError('Établissement inconnu.');
}
const before = await db.membershipScope.findMany({
where: { membershipId },
select: { allLocations: true, locationId: true },
});
// Remplacement et non fusion : un périmètre qui ne fait que grandir ne
// se réduit jamais, et le retrait d'un établissement resterait sans
// effet — l'exact contraire de ce que l'écran promet.
await db.membershipScope.deleteMany({ where: { membershipId } });
if (allLocations) {
await db.membershipScope.create({
data: { membershipId, allLocations: true } as never,
});
} else {
for (const locationId of locationIds) {
await db.membershipScope.create({
data: { membershipId, allLocations: false, locationId } as never,
});
}
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.scope.update',
entityType: 'Membership',
entityId: membershipId,
before: { scopes: before },
after: { allLocations, locationIds },
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return {
error:
'Modifier un périmètre revient à distribuer des droits : la capacité « Gérer les rôles » est requise.',
};
}
throw error;
}
revalidatePath(`/equipe/${membershipId}/planification`);
return { ok: true };
}
const teamsInput = z.object({
membershipId: z.string().min(1),
teamIds: z.array(z.string().min(1)),
primaryTeamId: z.string().trim(),
});
export async function updateTeamsAction(
_previous: PlacementActionState,
formData: FormData,
): Promise<PlacementActionState> {
const parsed = teamsInput.safeParse({
membershipId: formData.get('membershipId') ?? '',
teamIds: formData.getAll('teamIds').map(String),
primaryTeamId: formData.get('primaryTeamId') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const { membershipId, teamIds, primaryTeamId } = parsed.data;
if (primaryTeamId && !teamIds.includes(primaryTeamId)) {
return {
error: 'L’équipe principale doit figurer parmi les équipes cochées.',
};
}
try {
await mutate('settings.teams.manage', async (db, actor) => {
const known = await db.team.findMany({
where: { id: { in: teamIds } },
select: { id: true },
});
if (known.length !== teamIds.length) {
throw new ValidationError('Équipe inconnue.');
}
const before = await db.teamMember.findMany({
where: { membershipId },
select: { teamId: true, isPrimary: true },
});
await db.teamMember.deleteMany({ where: { membershipId } });
for (const teamId of teamIds) {
await db.teamMember.create({
data: {
teamId,
membershipId,
// Une seule principale : c'est elle qui décide de la colonne où la
// personne apparaît quand la grille n'en montre qu'une.
isPrimary: teamId === (primaryTeamId || teamIds[0]),
} as never,
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.teams.update',
entityType: 'Membership',
entityId: membershipId,
before: { teams: before },
after: { teamIds, primaryTeamId: primaryTeamId || teamIds[0] || null },
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return {
error: 'La capacité « Gérer les équipes » est requise.',
};
}
throw error;
}
revalidatePath(`/equipe/${membershipId}/planification`);
revalidatePath('/equipe');
return { ok: true };
}
+223
View File
@@ -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 };
}
+459 -34
View File
@@ -1,3 +1,5 @@
import { cache } from 'react';
import { can } from '@/domain/access/authorize';
import {
invitationState,
@@ -21,6 +23,8 @@ export interface EmployeeListRow {
firstName: string;
lastName: string;
email: string | null;
/** Téléphone du dossier : la colonne « Mobile » de l'annuaire. */
phone: string | null;
status: string;
hasAccount: boolean;
roleName: string;
@@ -32,6 +36,36 @@ export interface EmployeeListRow {
forfaitJours: boolean;
} | null;
locationName: string | null;
teamName: string | null;
invitationState: InvitationState | null;
}
/**
* Critères de l'annuaire.
*
* Tous facultatifs, et tous appliqués **en base** plutôt qu'après lecture :
* un effectif de plusieurs centaines de dossiers ne se filtre pas en mémoire à
* chaque affichage.
*/
export interface EmployeeFilters {
/** Prénom, nom ou matricule. */
q?: string;
locationId?: string;
roleId?: string;
contractType?: string;
/** `active` par défaut : un archivé n'a rien à faire dans l'effectif courant. */
presence?: 'active' | 'archived' | 'all';
sort?: 'name' | 'number';
}
export interface EmployeeDirectory {
rows: EmployeeListRow[];
/** De quoi peupler les listes déroulantes, sans seconde requête à l'écran. */
locations: Array<{ id: string; name: string }>;
roles: Array<{ id: string; name: string }>;
contractTypes: Array<{ value: string; label: string }>;
/** Effectif total, avant filtres : « 12 sur 87 » se lit autrement que « 12 ». */
total: number;
}
const CONTRACT_LABELS: Record<string, string> = {
@@ -50,22 +84,104 @@ export function contractLabel(type: string): string {
return CONTRACT_LABELS[type] ?? type;
}
export async function listEmployees(): Promise<EmployeeListRow[]> {
export async function listEmployees(
filters: EmployeeFilters = {},
): Promise<EmployeeDirectory> {
return query('members.view', async (db) => {
const memberships = await db.membership.findMany({
where: { archivedAt: null },
include: {
profile: { select: { firstName: true, lastName: true } },
user: { select: { email: true } },
role: { select: { name: true } },
contracts: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
take: 1,
const now = new Date();
const search = filters.q?.trim();
// Un archivé n'appartient plus à l'effectif : il faut le demander.
const presence =
filters.presence === 'all'
? {}
: filters.presence === 'archived'
? { archivedAt: { not: null } }
: { archivedAt: null };
// L'établissement et le type de contrat sont portés par le contrat en
// cours, pas par le membership : le filtre passe donc par la relation.
const contractFilter =
filters.locationId || filters.contractType
? {
contracts: {
some: {
status: 'ACTIVE' as const,
...(filters.locationId
? { locationId: filters.locationId }
: {}),
...(filters.contractType
? { contractType: filters.contractType as never }
: {}),
},
},
}
: {};
const searchFilter = search
? {
OR: [
{ employeeNumber: { contains: search, mode: 'insensitive' as const } },
{
profile: {
is: {
OR: [
{ firstName: { contains: search, mode: 'insensitive' as const } },
{ lastName: { contains: search, mode: 'insensitive' as const } },
],
},
},
},
],
}
: {};
const where = {
...presence,
...contractFilter,
...searchFilter,
...(filters.roleId ? { roleId: filters.roleId } : {}),
};
const [memberships, total, locations, roles] = await Promise.all([
db.membership.findMany({
where,
include: {
profile: {
select: { firstName: true, lastName: true, phone: true },
},
user: { select: { email: true } },
role: { select: { name: true } },
teams: {
select: {
isPrimary: true,
team: { select: { name: true } },
},
},
contracts: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
take: 1,
},
invitations: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { expiresAt: true, acceptedAt: true, revokedAt: true },
},
},
},
orderBy: { employeeNumber: 'asc' },
});
orderBy: { employeeNumber: 'asc' },
}),
db.membership.count({ where: { archivedAt: null } }),
db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
db.role.findMany({
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
]);
const locationIds = [
...new Set(
@@ -74,20 +190,28 @@ export async function listEmployees(): Promise<EmployeeListRow[]> {
.map((contract) => contract.locationId),
),
];
const locations = await db.location.findMany({
const contractLocations = await db.location.findMany({
where: { id: { in: locationIds } },
select: { id: true, name: true },
});
const locationNames = new Map(locations.map((l) => [l.id, l.name]));
const locationNames = new Map(
contractLocations.map((entry) => [entry.id, entry.name]),
);
return memberships.map((membership) => {
const rows: EmployeeListRow[] = memberships.map((membership) => {
const contract = membership.contracts[0];
const team =
membership.teams.find((entry) => entry.isPrimary) ??
membership.teams[0];
const invitation = membership.invitations[0];
return {
id: membership.id,
employeeNumber: membership.employeeNumber,
firstName: membership.profile?.firstName ?? '',
lastName: membership.profile?.lastName ?? membership.employeeNumber,
email: membership.user?.email ?? null,
phone: membership.profile?.phone ?? null,
status: membership.status,
hasAccount: membership.userId !== null,
roleName: membership.role.name,
@@ -103,37 +227,104 @@ export async function listEmployees(): Promise<EmployeeListRow[]> {
locationName: contract
? (locationNames.get(contract.locationId) ?? null)
: null,
teamName: team?.team.name ?? null,
invitationState: invitation ? invitationState(invitation, now) : null,
};
});
// Le tri par nom se fait ici : trier en base sur le nom du dossier
// demanderait une jointure ordonnée, et « Étienne » se range après
// « Etchegaray » pour Postgres, avant pour un lecteur français.
if (filters.sort !== 'number') {
rows.sort((a, b) =>
`${a.lastName} ${a.firstName}`.localeCompare(
`${b.lastName} ${b.firstName}`,
'fr',
),
);
}
return {
rows,
locations,
roles,
contractTypes: Object.entries(CONTRACT_LABELS).map(([value, label]) => ({
value,
label,
})),
total,
};
});
}
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;
label: string;
startDate: Date;
endDate: Date | null;
trialEndDate: Date | null;
weeklyHours: string;
forfaitJours: boolean;
forfaitDaysPerYear: string | null;
forfaitAgreementRef: string | null;
isModulated: boolean;
classification: string | null;
coefficient: string | null;
locationName: string | null;
status: string;
endReason: string | null;
/** Absent quand `members.salary.view` manque. */
monthlySalary: string | null;
amendments: Array<{ id: string; effectiveDate: Date; reason: string | null }>;
amendments: Array<{
id: string;
effectiveDate: Date;
reason: string | null;
changes: unknown;
}>;
}>;
canSeeSalary: boolean;
canInvite: boolean;
canEdit: boolean;
/** Dernière invitation émise, quel que soit son sort. */
invitation: {
state: InvitationState;
@@ -143,7 +334,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');
@@ -154,6 +352,9 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
user: { select: { email: true } },
role: { select: { name: true } },
profile: true,
teams: {
select: { isPrimary: true, team: { select: { name: true } } },
},
contracts: {
orderBy: { startDate: 'desc' },
include: {
@@ -168,13 +369,45 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
const active = membership.contracts.find(
(contract) => contract.status === 'ACTIVE',
);
// Tous les établissements portés par l'historique, et pas seulement celui
// du contrat en cours : un contrat terminé ailleurs reste à lire.
const contractLocations = await db.location.findMany({
where: {
id: {
in: [
...new Set(membership.contracts.map((entry) => entry.locationId)),
],
},
},
select: { id: true, name: true },
});
const locationNames = new Map(
contractLocations.map((entry) => [entry.id, entry.name]),
);
const location = active
? await db.location.findUnique({
where: { id: active.locationId },
? { name: locationNames.get(active.locationId) ?? 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.
@@ -196,10 +429,19 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
firstName: membership.profile?.firstName ?? '',
lastName: membership.profile?.lastName ?? membership.employeeNumber,
email: membership.user?.email ?? null,
phone: membership.profile?.phone ?? null,
status: membership.status,
hasAccount: membership.userId !== null,
roleName: membership.role.name,
locationName: location?.name ?? null,
teamName:
(
membership.teams.find((entry) => entry.isPrimary) ??
membership.teams[0]
)?.team.name ?? null,
invitationState: lastInvitation
? invitationState(lastInvitation, new Date())
: null,
contract: active
? {
type: active.contractType,
@@ -209,18 +451,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,
@@ -230,10 +492,17 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
label: contractLabel(contract.contractType),
startDate: contract.startDate,
endDate: contract.endDate,
trialEndDate: contract.trialEndDate,
weeklyHours: contract.weeklyHours.toString(),
forfaitJours: contract.workTimeArrangement === 'FORFAIT_JOURS',
forfaitDaysPerYear: contract.forfaitDaysPerYear?.toString() ?? null,
forfaitAgreementRef: contract.forfaitAgreementRef,
isModulated: contract.isModulated,
classification: contract.classification,
coefficient: contract.coefficient,
locationName: locationNames.get(contract.locationId) ?? null,
status: contract.status,
endReason: contract.endReason,
monthlySalary: canSeeSalary
? (contract.monthlySalary?.toString() ?? null)
: null,
@@ -241,10 +510,12 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
id: amendment.id,
effectiveDate: amendment.effectiveDate,
reason: amendment.reason,
changes: amendment.changes,
})),
})),
canSeeSalary,
canInvite: can(actor, 'members.invite'),
canEdit: can(actor, 'members.edit'),
invitation: lastInvitation
? {
state: invitationState(lastInvitation, new Date()),
@@ -255,4 +526,158 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
: null,
};
});
});
/**
* Établissements où poser un contrat.
*
* Une liste à part de celle des réglages : ouvrir un contrat n'exige pas
* d'administrer les établissements, et emprunter `settings.access` fermerait
* la création à un gestionnaire de paie qui n'a rien à y administrer.
*/
export interface ContractLocation {
id: string;
name: string;
teams: Array<{ id: string; name: string }>;
}
export const listContractLocations = cache(
async function listContractLocations(): Promise<ContractLocation[]> {
return query('members.contract.create', async (db) =>
db.location.findMany({
where: { archivedAt: null },
select: {
id: true,
name: true,
teams: {
where: { archivedAt: null },
select: { id: true, name: true },
orderBy: { position: 'asc' },
},
},
orderBy: { name: 'asc' },
}),
);
},
);
/** 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[];
/** Les mêmes, par identifiant : ce que les formulaires cochent. */
scopedLocationIds: string[];
teamIds: string[];
primaryTeamId: string | null;
}
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,
locationId: 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)),
),
],
scopedLocationIds: [
...new Set(
membership.scopes
.map((scope) => scope.locationId)
.filter((id): id is string => Boolean(id)),
),
],
teamIds: membership.teams.map((member) => member.team.id),
primaryTeamId:
membership.teams.find((member) => member.isPrimary)?.team.id ?? null,
};
});
});
/** Établissements et équipes où rattacher quelqu'un. */
export const listPlacementOptions = cache(
async function listPlacementOptions(): Promise<ContractLocation[]> {
// `members.view` suffit : ces noms sont déjà affichés par l'annuaire, et
// exiger davantage fermerait l'écran à qui a le droit de le lire.
return query('members.view', async (db) =>
db.location.findMany({
where: { archivedAt: null },
select: {
id: true,
name: true,
teams: {
where: { archivedAt: null },
select: { id: true, name: true },
orderBy: { position: 'asc' },
},
},
orderBy: { name: 'asc' },
}),
);
},
);
+8
View File
@@ -117,10 +117,14 @@ test('une pièce sans politique n’est jamais purgée', async ({ page }) => {
await create.getByLabel('Prénom').fill('Garde');
await create.getByLabel('Nom', { exact: true }).fill(`Garde${suffix}`);
await create.getByLabel('Matricule').fill(`RET${suffix}`);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
await create.getByRole('button', { name: 'Ajouter' }).click();
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
@@ -167,10 +171,14 @@ test('une pièce échue est effectivement effacée', async ({ page }) => {
await create.getByLabel('Prénom').fill('Purge');
await create.getByLabel('Nom', { exact: true }).fill(`Purge${suffix}`);
await create.getByLabel('Matricule').fill(`PUR${suffix}`);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
await create.getByRole('button', { name: 'Ajouter' }).click();
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');
+7
View File
@@ -31,11 +31,18 @@ async function createEmployee(page: Page, tag: string) {
await form.getByLabel('Prénom').fill('Inès');
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Matricule').fill(`DOC${suffix}`);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
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();
// 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() };
}
+127
View File
@@ -0,0 +1,127 @@
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}`);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
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();
});
test('une embauche pose le contrat en même temps que le dossier', async ({
page,
}) => {
const suffix = `${Date.now()}-embauche`;
const lastName = `Embauche${suffix}`;
await page.goto('/equipe');
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.getByLabel('Prénom').fill('Sofia');
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Matricule').fill(`EMB${suffix}`);
// Le contrat est proposé coché : un dossier créé sans lui n'apparaît sur
// aucune grille et ne se déclare pas.
await expect(
form.getByLabel('Ouvrir un contrat maintenant'),
).toBeChecked();
await form.getByLabel('Début du contrat').fill('2026-01-05');
await form.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
// Le contrat est visible depuis l'annuaire, sans passer par la fiche.
await expect(page.getByRole('row', { name: new RegExp(lastName) })).toContainText(
'CDI',
);
await page.getByRole('link', { name: new RegExp(lastName) }).click();
// Le bandeau porte le contrat : c'est ce qui distingue un dossier embauché
// d'un dossier ouvert.
await expect(page.getByText('Type de contrat')).toBeVisible();
await page.getByRole('link', { name: 'Contrats', exact: true }).click();
await expect(page.getByText('35 heures hebdomadaires')).toBeVisible();
});
+7
View File
@@ -26,6 +26,9 @@ async function createEmployee(page: Page, tag: string) {
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Matricule').fill(`E2E${suffix}`);
await form.getByLabel('Adresse électronique').fill(email);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await form.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
@@ -37,6 +40,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() };
}
+13 -5
View File
@@ -43,8 +43,13 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
await page.goto('/equipe');
await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible();
// L'effectif vient de la base, pas du module de démonstration.
await expect(page.getByText('E0001')).toBeVisible();
// L'effectif vient de la base, pas du module de démonstration : un annuaire
// fabriqué à l'écran ne se filtrerait pas par matricule.
await page
.getByPlaceholder('Rechercher par prénom, nom ou matricule.')
.fill('E0001');
await expect(page.getByText(/^1 sur \d+ salariés$/)).toBeVisible();
await page.goto('/equipe');
// Nom unique par exécution : la base de test n'est pas remise à zéro entre
// deux passages, et un nom fixe finirait par désigner plusieurs salariés.
@@ -54,6 +59,9 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
await form.locator('input[name="firstName"]').fill('Sans');
await form.locator('input[name="lastName"]').fill(nom);
await form.locator('input[name="employeeNumber"]').fill(matricule);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
@@ -64,9 +72,9 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
// Un salarié sans adresse doit exister : la plupart des équipes de vente ne
// se connectent jamais à l'outil.
await expect(
page.getByRole('cell', { name: matricule, exact: true }),
).toBeVisible();
// Sans compte applicatif, l'annuaire affiche le matricule sous le nom : c'est
// la seule adresse à laquelle on désigne ce salarié.
await expect(page.getByText(`Matricule ${matricule}`)).toBeVisible();
// Le nom vit sur le dossier, pas sur le compte : un salarié sans accès
// applicatif doit tout de même figurer nommément au registre du personnel.
await expect(
+5
View File
@@ -163,6 +163,9 @@ async function inviteWithRole(page: Page, roleLabel: string) {
await create.getByLabel('Nom', { exact: true }).fill(lastName);
await create.getByLabel('Matricule').fill(`ROL${suffix}`);
await create.getByLabel('Adresse électronique').fill(email);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
await create.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
@@ -177,6 +180,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' })
+5
View File
@@ -156,10 +156,15 @@ async function createAccessibleEmployee(page: Page) {
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Matricule').fill(`MFA${suffix}`);
await form.getByLabel('Adresse électronique').fill(email);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await form.getByRole('button', { name: 'Ajouter' }).click();
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' })