Donner à l'annuaire une recherche et des filtres

Six colonnes et rien pour s'y retrouver : au-delà de vingt salariés,
l'effectif se parcourait au défilement. Recherche par prénom, nom ou
matricule, et quatre filtres — établissement, rôle, type de contrat,
présence — plus le tri.

Les critères vivent dans l'adresse et non dans le composant : un effectif
filtré s'envoie par lien, et le retour arrière défait le dernier choix
plutôt que de vider la page. Ils sont appliqués en base, pas après lecture :
plusieurs centaines de dossiers ne se filtrent pas en mémoire à chaque
affichage.

Le tri par nom reste en mémoire, lui : Postgres range « Étienne » après
« Etchegaray », un lecteur français avant.

Les colonnes suivent : rôle, mobile, rattachement et état de l'invitation
remplacent le matricule, qui ne s'affiche plus que sous le nom des salariés
sans compte applicatif — ceux pour qui c'est la seule adresse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-10 11:41:33 +02:00
1 parent bb8f84696a
commit 5a113de02f
4 files changed
+405 -54

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRef } from 'react';
/**
* Filtres de l'annuaire.
*
* L'état vit dans l'adresse, pas dans le composant : un effectif filtré
* s'envoie par lien, se met en favori, et le retour arrière défait le dernier
* choix plutôt que de vider la page.
*/
const selectClass =
'h-9 min-w-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
export interface FilterOptions {
locations: Array<{ id: string; name: string }>;
roles: Array<{ id: string; name: string }>;
contractTypes: Array<{ value: string; label: string }>;
}
export function DirectoryFilters({ options }: { options: FilterOptions }) {
const router = useRouter();
const params = useSearchParams();
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
const current = (key: string) => params.get(key) ?? '';
function apply(key: string, value: string) {
const next = new URLSearchParams(params.toString());
if (value) next.set(key, value);
else next.delete(key);
router.replace(next.size > 0 ? `/equipe?${next}` : '/equipe');
}
return (
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1.5">
<span className="sr-only">Rechercher un collaborateur</span>
<input
name="q"
type="search"
defaultValue={current('q')}
placeholder="Rechercher par prénom, nom ou matricule."
className="h-10 rounded-3 border border-line-2 bg-surface px-3.5 text-sm text-ink-1 outline-none placeholder:text-ink-3 focus-visible:border-focus"
onChange={(event) => {
// La frappe ne déclenche pas une requête par caractère : elle
// attend un silence. Sans cela, chercher « Dupont » en lance six.
const value = event.target.value;
if (debounce.current) clearTimeout(debounce.current);
debounce.current = setTimeout(() => apply('q', value), 300);
}}
/>
</label>
<div className="flex flex-wrap gap-2">
<select
aria-label="Établissement"
className={selectClass}
value={current('etablissement')}
onChange={(event) => apply('etablissement', event.target.value)}
>
<option value="">Tous les établissements</option>
{options.locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
<select
aria-label="Rôle"
className={selectClass}
value={current('role')}
onChange={(event) => apply('role', event.target.value)}
>
<option value="">Tous les rôles</option>
{options.roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<select
aria-label="Type de contrat"
className={selectClass}
value={current('contrat')}
onChange={(event) => apply('contrat', event.target.value)}
>
<option value="">Tous les types de contrats</option>
{options.contractTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<select
aria-label="Présence"
className={selectClass}
value={current('presence')}
onChange={(event) => apply('presence', event.target.value)}
>
<option value="">Utilisateurs actifs</option>
<option value="archived">Archivés</option>
<option value="all">Tous</option>
</select>
<select
aria-label="Tri"
className={selectClass}
value={current('tri')}
onChange={(event) => apply('tri', event.target.value)}
>
<option value="">Tri par nom</option>
<option value="number">Tri par matricule</option>
</select>
</div>
</div>
);
}
+96 -32
View File
@@ -1,59 +1,114 @@
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';
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 } : {}),
});
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 +121,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 +131,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 +155,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,7 +183,7 @@ export default async function EquipePage() {
</Card>
<Card>
<CardHeader title="Ajouter un salarié" />
<CardHeader title="Ajouter un collaborateur" />
<div className="p-4">
<AddEmployeeForm />
</div>