Merge pull request #37 from R0m1k3/claude/mfa-lever-barriere-enrolement
Claude/mfa lever barriere enrolement
This commit is contained in:
29 files changed
+7179
-270
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Carte de consultation du dossier.
|
||||
*
|
||||
* Deux colonnes par ligne — l'intitulé, puis la valeur — et un filet entre
|
||||
* chaque : c'est la forme d'un état civil, qui se parcourt du regard en
|
||||
* cherchant un champ précis plutôt qu'en lisant de haut en bas.
|
||||
*/
|
||||
export function InfoCard({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-3 border border-line-1 bg-surface p-5">
|
||||
<header className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-7 flex-none rounded-2 border border-accent bg-accent-soft"
|
||||
/>
|
||||
<h2 className="text-md font-semibold">{title}</h2>
|
||||
</header>
|
||||
<dl className="mt-4">{children}</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoRow({
|
||||
label,
|
||||
value,
|
||||
tnum = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
/** Chiffres alignés : dates, téléphones, numéros. */
|
||||
tnum?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line-1 py-3 last:border-b-0">
|
||||
<dt className="min-w-[11rem] flex-none text-sm font-medium text-ink-1">
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={
|
||||
value
|
||||
? `text-sm text-ink-2${tnum ? ' tnum' : ''}`
|
||||
: 'text-sm text-ink-3'
|
||||
}
|
||||
>
|
||||
{value || 'Non renseigné'}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Grille à deux colonnes des cartes du dossier. */
|
||||
export function InfoGrid({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid items-start gap-5 [grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr))]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
/**
|
||||
* Onglets de la fiche salarié.
|
||||
*
|
||||
* Chaque onglet est une **route**, pas un état de composant : une fiche
|
||||
* s'envoie par lien, se rouvre au même endroit, et le retour arrière ramène à
|
||||
* l'onglet précédent plutôt qu'à la liste.
|
||||
*/
|
||||
|
||||
const TABS = [
|
||||
{ segment: '', label: 'Informations personnelles' },
|
||||
{ segment: 'contrats', label: 'Contrats' },
|
||||
{ segment: 'planification', label: 'Planification et accès' },
|
||||
{ segment: 'absences', label: 'Congés et Absences' },
|
||||
{ segment: 'documents', label: 'Documents' },
|
||||
] as const;
|
||||
|
||||
export function MemberTabs({ id }: { id: string }) {
|
||||
const pathname = usePathname();
|
||||
const base = `/equipe/${id}`;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections de la fiche"
|
||||
className="flex gap-1 overflow-x-auto rounded-3 border border-line-1 bg-surface p-1.5"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const href = tab.segment ? `${base}/${tab.segment}` : base;
|
||||
const active = tab.segment
|
||||
? pathname.startsWith(href)
|
||||
: pathname === base;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.segment}
|
||||
href={href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cx(
|
||||
'shrink-0 rounded-full px-3.5 py-1.5 text-sm font-medium whitespace-nowrap',
|
||||
'transition-colors duration-[var(--d-1)] ease-organic',
|
||||
active
|
||||
? 'bg-surface-3 text-ink-1'
|
||||
: 'text-ink-2 hover:bg-surface-2 hover:text-ink-1',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
|
||||
import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import {
|
||||
updateProfileAction,
|
||||
updateSensitiveAction,
|
||||
type ProfileActionState,
|
||||
} from '@/server/employees/profile-actions';
|
||||
|
||||
/**
|
||||
* Dossier personnel : consultation, puis saisie.
|
||||
*
|
||||
* Un seul écran et deux modes, plutôt qu'une page de lecture et une page de
|
||||
* formulaire. Le dossier se corrige champ par champ, souvent au téléphone avec
|
||||
* la personne concernée : la faire changer de page pour changer un chiffre
|
||||
* ferait perdre le reste de vue.
|
||||
*/
|
||||
|
||||
export interface ProfileFields {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
birthDate: string;
|
||||
birthPlace: string;
|
||||
nationality: string;
|
||||
personalEmail: string;
|
||||
phone: string;
|
||||
addressLine1: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
country: string;
|
||||
emergencyContactName: string;
|
||||
emergencyContactPhone: string;
|
||||
}
|
||||
|
||||
export interface SensitiveFields {
|
||||
socialSecurityNumber: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
}
|
||||
|
||||
const empty: ProfileActionState = {};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
|
||||
function readableDate(value: string): string {
|
||||
if (!value) return '';
|
||||
const parsed = new Date(`${value}T00:00:00Z`);
|
||||
return Number.isNaN(parsed.getTime()) ? value : dateFormat.format(parsed);
|
||||
}
|
||||
|
||||
export function PersonalInfoPanel({
|
||||
membershipId,
|
||||
profile,
|
||||
sensitive,
|
||||
canEdit,
|
||||
}: {
|
||||
membershipId: string;
|
||||
profile: ProfileFields;
|
||||
/** Absent quand la capacité de lecture manque : la carte n'est pas rendue. */
|
||||
sensitive: SensitiveFields | null;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [state, save] = useActionState(updateProfileAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
|
||||
|
||||
// L'enregistrement referme le mode saisie, mais seulement après la réponse :
|
||||
// refermer à l'envoi ferait disparaître le message d'erreur avec le
|
||||
// formulaire, et avec lui ce que la personne venait de taper.
|
||||
//
|
||||
// La réponse est **acquittée** une fois pour toutes : sans cela, un succès
|
||||
// resté en mémoire refermerait aussitôt le formulaire à la prochaine
|
||||
// ouverture, et le dossier ne serait plus jamais modifiable.
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{editing ? (
|
||||
<form action={save} className="flex flex-col gap-5">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
<InfoGrid>
|
||||
<InfoCard title="État civil">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Field label="Prénom" name="firstName" required defaultValue={profile.firstName} />
|
||||
<Field label="Nom de famille" name="lastName" required defaultValue={profile.lastName} />
|
||||
<Field
|
||||
label="Date de naissance"
|
||||
name="birthDate"
|
||||
type="date"
|
||||
defaultValue={profile.birthDate}
|
||||
/>
|
||||
<Field
|
||||
label="Lieu de naissance"
|
||||
name="birthPlace"
|
||||
defaultValue={profile.birthPlace}
|
||||
hint="Commune, et département pour la France."
|
||||
/>
|
||||
<Field label="Nationalité" name="nationality" defaultValue={profile.nationality} />
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Coordonnées">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Field
|
||||
label="Adresse électronique personnelle"
|
||||
name="personalEmail"
|
||||
type="email"
|
||||
defaultValue={profile.personalEmail}
|
||||
/>
|
||||
<Field label="Téléphone mobile" name="phone" type="tel" defaultValue={profile.phone} />
|
||||
<Field label="Adresse" name="addressLine1" defaultValue={profile.addressLine1} />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Code postal" name="postalCode" defaultValue={profile.postalCode} />
|
||||
<Field label="Ville" name="city" defaultValue={profile.city} />
|
||||
</div>
|
||||
<Field label="Pays" name="country" defaultValue={profile.country} />
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Contact d’urgence">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<Field
|
||||
label="Nom"
|
||||
name="emergencyContactName"
|
||||
defaultValue={profile.emergencyContactName}
|
||||
/>
|
||||
<Field
|
||||
label="Téléphone"
|
||||
name="emergencyContactPhone"
|
||||
type="tel"
|
||||
defaultValue={profile.emergencyContactPhone}
|
||||
/>
|
||||
</div>
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
{/* Barre collante : le dossier est long, et le bouton d'enregistrement
|
||||
ne doit pas se trouver hors de l'écran au moment où l'on finit de
|
||||
corriger un champ du haut. */}
|
||||
<div className="sticky bottom-4 flex flex-wrap items-center justify-center gap-3 rounded-3 border border-line-1 bg-surface p-3 shadow-[var(--shadow-1,0_1px_2px_rgba(0,0,0,0.06))]">
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<SubmitButton>Enregistrer les modifications</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<InfoGrid>
|
||||
<InfoCard title="État civil">
|
||||
<InfoRow label="Prénom" value={profile.firstName} />
|
||||
<InfoRow label="Nom de famille" value={profile.lastName} />
|
||||
<InfoRow label="Date de naissance" value={readableDate(profile.birthDate)} tnum />
|
||||
<InfoRow label="Lieu de naissance" value={profile.birthPlace} />
|
||||
<InfoRow label="Nationalité" value={profile.nationality} />
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Coordonnées">
|
||||
<InfoRow label="Adresse électronique personnelle" value={profile.personalEmail} />
|
||||
<InfoRow label="Téléphone mobile" value={profile.phone} tnum />
|
||||
<InfoRow label="Adresse" value={profile.addressLine1} />
|
||||
<InfoRow label="Code postal" value={profile.postalCode} tnum />
|
||||
<InfoRow label="Ville" value={profile.city} />
|
||||
<InfoRow label="Pays" value={profile.country} />
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Contact d’urgence">
|
||||
<InfoRow label="Nom" value={profile.emergencyContactName} />
|
||||
<InfoRow label="Téléphone" value={profile.emergencyContactPhone} tnum />
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
|
||||
{canEdit ? (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="primary" onClick={() => setEditing(true)}>
|
||||
Modifier les informations personnelles
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{sensitive ? (
|
||||
<SensitivePanel
|
||||
membershipId={membershipId}
|
||||
sensitive={sensitive}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NIR, IBAN et BIC.
|
||||
*
|
||||
* Séparés du reste, et pas seulement à l'écran : ils sont chiffrés au repos,
|
||||
* leur lecture demande sa propre capacité, et leur écriture passe par une autre
|
||||
* action. Les mêler au formulaire commun ferait dépendre leur sort d'un droit
|
||||
* qui ne les concerne pas.
|
||||
*/
|
||||
function SensitivePanel({
|
||||
membershipId,
|
||||
sensitive,
|
||||
canEdit,
|
||||
}: {
|
||||
membershipId: string;
|
||||
sensitive: SensitiveFields;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [state, save] = useActionState(updateSensitiveAction, empty);
|
||||
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title="Données protégées">
|
||||
{editing ? (
|
||||
<form action={save} className="flex flex-col gap-3 pt-1">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
<Field
|
||||
label="Numéro de sécurité sociale"
|
||||
name="socialSecurityNumber"
|
||||
inputMode="numeric"
|
||||
defaultValue={sensitive.socialSecurityNumber}
|
||||
hint="Quinze chiffres. Conservé chiffré ; jamais écrit au journal."
|
||||
/>
|
||||
<Field label="IBAN" name="iban" defaultValue={sensitive.iban} />
|
||||
<Field label="BIC" name="bic" defaultValue={sensitive.bic} />
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer</SubmitButton>
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Numéro de sécurité sociale"
|
||||
value={sensitive.socialSecurityNumber}
|
||||
tnum
|
||||
/>
|
||||
<InfoRow label="IBAN" value={sensitive.iban} tnum />
|
||||
<InfoRow label="BIC" value={sensitive.bic} tnum />
|
||||
{canEdit ? (
|
||||
<div className="pt-3">
|
||||
<Button onClick={() => setEditing(true)}>Modifier</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { listMemberAbsences, statusLabel } from '@/server/absences/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
|
||||
|
||||
const STATUS_TONES: Record<string, Tone> = {
|
||||
PENDING: 'warn',
|
||||
ACCEPTED: 'ok',
|
||||
DECLINED: 'danger',
|
||||
CANCELLED: 'neutral',
|
||||
EXPIRED: 'neutral',
|
||||
};
|
||||
|
||||
const readable = (iso: string) => dateFormat.format(new Date(`${iso}T00:00:00Z`));
|
||||
|
||||
export default async function AbsencesTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const absences = await listMemberAbsences(id);
|
||||
|
||||
if (!absences) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Absences non consultables"
|
||||
description="Votre rôle ne donne pas accès aux congés des autres salariés."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader title="Compteurs" />
|
||||
{absences.counters.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun compteur"
|
||||
description="Les compteurs s’ouvrent à la première acquisition de la période."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-x-8 gap-y-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(170px,1fr))]">
|
||||
{absences.counters.map((counter) => (
|
||||
<div key={`${counter.membershipId}-${counter.counterType}`}>
|
||||
<p className="text-micro font-semibold text-ink-1">
|
||||
{counter.counterType}
|
||||
</p>
|
||||
<p className="tnum mt-1 text-lg font-semibold">
|
||||
{counter.balance}
|
||||
</p>
|
||||
<p className="tnum text-micro text-ink-3">
|
||||
{counter.accrued} acquis · {counter.taken} pris
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Congés et absences"
|
||||
badge={<Badge tone="neutral">{absences.requests.length}</Badge>}
|
||||
/>
|
||||
{absences.requests.length === 0 ? (
|
||||
<EmptyState title="Aucune absence enregistrée" />
|
||||
) : (
|
||||
<ul>
|
||||
{absences.requests.map((request) => (
|
||||
<li
|
||||
key={request.id}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<span className="font-medium">{request.typeLabel}</span>
|
||||
<span className="tnum text-xs text-ink-2">
|
||||
{readable(request.startDate)}
|
||||
{request.endDate !== request.startDate
|
||||
? ` → ${readable(request.endDate)}`
|
||||
: ''}
|
||||
</span>
|
||||
<span className="tnum text-xs text-ink-3">
|
||||
{request.days} j
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
|
||||
{statusLabel(request.status)}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { MemberTabs } from '@/app/(app)/equipe/[id]/MemberTabs';
|
||||
import { PageBody } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { getEmployee, getMemberPlacement } from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'full' });
|
||||
|
||||
/**
|
||||
* Gabarit de la fiche salarié.
|
||||
*
|
||||
* Le bandeau et les onglets sont tenus ici, et non répétés dans chaque onglet :
|
||||
* ils ne changent pas d'un onglet à l'autre, et les rejouer ferait clignoter
|
||||
* l'identité de la personne consultée à chaque navigation.
|
||||
*/
|
||||
export default async function MemberLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
const placement = await getMemberPlacement(id);
|
||||
const primaryTeam =
|
||||
placement?.teams.find((team) => team.isPrimary) ?? placement?.teams[0];
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<header className="rounded-3 bg-surface-2 p-6">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-14 flex-none items-center justify-center rounded-full bg-accent-soft text-md font-semibold text-accent-soft-ink"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.018em]">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-ink-2">
|
||||
{employee.headline.jobTitle ??
|
||||
`Matricule ${employee.employeeNumber}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!employee.hasAccount ? (
|
||||
<Badge tone="info">Sans accès applicatif</Badge>
|
||||
) : null}
|
||||
<Badge tone="accent">{employee.roleName}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-6 grid gap-x-6 gap-y-4 [grid-template-columns:repeat(auto-fit,minmax(150px,1fr))]">
|
||||
<HeadlineItem
|
||||
label="Début du contrat"
|
||||
value={
|
||||
employee.contract
|
||||
? dateFormat.format(employee.contract.since)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Fin du contrat"
|
||||
value={
|
||||
employee.headline.contractEnd
|
||||
? dateFormat.format(employee.headline.contractEnd)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Type de contrat"
|
||||
value={employee.contract?.label ?? null}
|
||||
/>
|
||||
<HeadlineItem
|
||||
label="Établissement"
|
||||
value={employee.locationName}
|
||||
/>
|
||||
<HeadlineItem label="Équipe" value={primaryTeam?.name ?? null} />
|
||||
<HeadlineItem
|
||||
label="Responsable hiérarchique"
|
||||
value={employee.headline.lineManagerName}
|
||||
/>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<MemberTabs id={id} />
|
||||
|
||||
{children}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
/** Un tiret, et non « non renseigné » : le bandeau se lit d'un balayage. */
|
||||
function HeadlineItem({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-micro font-semibold text-ink-1">{label}</dt>
|
||||
<dd className="mt-1 truncate text-sm text-ink-2">{value ?? '—'}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel';
|
||||
import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { listDocuments } from '@/server/documents/queries';
|
||||
import {
|
||||
PersonalInfoPanel,
|
||||
type ProfileFields,
|
||||
type SensitiveFields,
|
||||
} from '@/app/(app)/equipe/[id]/PersonalInfoPanel';
|
||||
import { EmptyState } from '@/components/ui/Card';
|
||||
import { getEmployee } from '@/server/employees/queries';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
/** `null` et `undefined` deviennent la chaîne vide : un `<input>` non contrôlé
|
||||
* affiche « null » sinon. */
|
||||
const text = (value: string | null | undefined): string => value ?? '';
|
||||
|
||||
export default async function FichePage({
|
||||
export default async function PersonalTab({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -21,181 +23,49 @@ export default async function FichePage({
|
||||
const employee = await getEmployee(id);
|
||||
if (!employee) notFound();
|
||||
|
||||
// `members.documents.view` peut manquer là où `members.view` est accordée :
|
||||
// la section disparaît alors, plutôt que d'échouer sur toute la page.
|
||||
const documents = await listDocuments(id).catch(() => null);
|
||||
if (!employee.profile) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Aucun dossier personnel"
|
||||
description="Ce salarié n’a pas de dossier. Il a probablement été créé hors de l’application ; reprenez sa création depuis la liste de l’équipe."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const active = employee.contracts.find(
|
||||
(contract) => contract.status === 'ACTIVE',
|
||||
);
|
||||
const profile: ProfileFields = {
|
||||
firstName: text(employee.profile.firstName),
|
||||
lastName: text(employee.profile.lastName),
|
||||
birthDate: employee.profile.birthDate
|
||||
? employee.profile.birthDate.toISOString().slice(0, 10)
|
||||
: '',
|
||||
birthPlace: text(employee.profile.birthPlace),
|
||||
nationality: text(employee.profile.nationality),
|
||||
personalEmail: text(employee.profile.personalEmail),
|
||||
phone: text(employee.profile.phone),
|
||||
addressLine1: text(employee.profile.addressLine1),
|
||||
postalCode: text(employee.profile.postalCode),
|
||||
city: text(employee.profile.city),
|
||||
country: text(employee.profile.country),
|
||||
emergencyContactName: text(employee.profile.emergencyContactName),
|
||||
emergencyContactPhone: text(employee.profile.emergencyContactPhone),
|
||||
};
|
||||
|
||||
const sensitive: SensitiveFields | null = employee.profile.sensitive
|
||||
? {
|
||||
socialSecurityNumber: text(
|
||||
employee.profile.sensitive.socialSecurityNumber,
|
||||
),
|
||||
iban: text(employee.profile.sensitive.iban),
|
||||
bic: text(employee.profile.sensitive.bic),
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title={`${employee.firstName} ${employee.lastName}`}
|
||||
subtitle={[
|
||||
employee.contract?.label,
|
||||
employee.locationName,
|
||||
`matricule ${employee.employeeNumber}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-3 border border-line-1 bg-surface p-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-11 flex-none items-center justify-center rounded-full bg-surface-3 text-sm font-semibold text-ink-2"
|
||||
>
|
||||
{employee.firstName.charAt(0)}
|
||||
{employee.lastName.charAt(0)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{employee.email ?? 'Aucun compte applicatif'}
|
||||
</p>
|
||||
<p className="text-micro text-ink-3">Rôle · {employee.roleName}</p>
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
{active?.forfaitJours ? (
|
||||
<Badge tone="accent">
|
||||
Forfait jours · {active.forfaitDaysPerYear ?? '—'} j
|
||||
</Badge>
|
||||
) : active ? (
|
||||
<Badge tone="neutral">{active.weeklyHours} h hebdomadaires</Badge>
|
||||
) : null}
|
||||
{!employee.hasAccount ? (
|
||||
<Badge tone="info">Sans accès applicatif</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{documents ? (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Pièces du dossier"
|
||||
badge={<Badge tone="neutral">{documents.documents.length}</Badge>}
|
||||
/>
|
||||
<DocumentsPanel
|
||||
membershipId={employee.id}
|
||||
documents={documents.documents}
|
||||
canManage={documents.canManage}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{employee.canInvite ? (
|
||||
<Card>
|
||||
<CardHeader title="Accès à l’application" />
|
||||
<InvitationPanel
|
||||
membershipId={employee.id}
|
||||
defaultEmail={
|
||||
employee.email ?? employee.profile?.personalEmail ?? ''
|
||||
}
|
||||
hasAccount={employee.hasAccount}
|
||||
invitation={employee.invitation}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Contrats et avenants"
|
||||
badge={<Badge tone="neutral">{employee.contracts.length}</Badge>}
|
||||
/>
|
||||
{employee.contracts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun contrat"
|
||||
description="Ce salarié n’a pas encore de contrat. Il ne peut pas être planifié tant qu’aucune période n’est ouverte."
|
||||
/>
|
||||
) : (
|
||||
<ul>
|
||||
{employee.contracts.map((contract) => (
|
||||
<li
|
||||
key={contract.id}
|
||||
className="border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{contract.label}</span>
|
||||
{contract.forfaitJours ? (
|
||||
<Badge tone="accent">Forfait jours</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">{contract.weeklyHours} h</Badge>
|
||||
)}
|
||||
<span className="tnum text-xs text-ink-2">
|
||||
{dateFormat.format(contract.startDate)}
|
||||
{contract.endDate
|
||||
? ` → ${dateFormat.format(contract.endDate)}`
|
||||
: ' → en cours'}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{employee.canSeeSalary && contract.monthlySalary ? (
|
||||
<span className="tnum text-sm">
|
||||
{contract.monthlySalary} € brut
|
||||
</span>
|
||||
) : null}
|
||||
<Badge
|
||||
tone={contract.status === 'ACTIVE' ? 'ok' : 'neutral'}
|
||||
>
|
||||
{contract.status === 'ACTIVE' ? 'En cours' : 'Terminé'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{contract.amendments.length > 0 ? (
|
||||
<ul className="mt-2 border-l-2 border-line-2 pl-3">
|
||||
{contract.amendments.map((amendment) => (
|
||||
<li
|
||||
key={amendment.id}
|
||||
className="py-1 text-xs text-ink-2"
|
||||
>
|
||||
<span className="tnum">
|
||||
{dateFormat.format(amendment.effectiveDate)}
|
||||
</span>{' '}
|
||||
— avenant{amendment.reason ? ` · ${amendment.reason}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{employee.profile ? (
|
||||
<Card>
|
||||
<CardHeader title="Dossier personnel" />
|
||||
<dl className="grid gap-x-8 gap-y-3 p-4 text-sm [grid-template-columns:repeat(auto-fit,minmax(200px,1fr))]">
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">Téléphone</dt>
|
||||
<dd className="tnum">{employee.profile.phone ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">Ville</dt>
|
||||
<dd>{employee.profile.city ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">
|
||||
Numéro de sécurité sociale
|
||||
</dt>
|
||||
<dd className="tnum">
|
||||
{/* Non chargé quand la capacité manque : un champ absent de la
|
||||
réponse ne peut fuiter ni par le HTML ni par un journal. */}
|
||||
{employee.profile.socialSecurityNumber ?? (
|
||||
<span className="text-ink-3">Accès non autorisé</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">IBAN</dt>
|
||||
<dd className="tnum">
|
||||
{employee.profile.iban ?? (
|
||||
<span className="text-ink-3">Accès non autorisé</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
) : null}
|
||||
</PageBody>
|
||||
<PersonalInfoPanel
|
||||
membershipId={employee.id}
|
||||
profile={profile}
|
||||
sensitive={sensitive}
|
||||
canEdit={employee.canEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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' },
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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');
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in new issue
Block a user