diff --git a/src/app/(app)/equipe/DirectoryFilters.tsx b/src/app/(app)/equipe/DirectoryFilters.tsx new file mode 100644 index 0000000..3428827 --- /dev/null +++ b/src/app/(app)/equipe/DirectoryFilters.tsx @@ -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 | 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 ( +
+ + +
+ + + + + + + + + +
+
+ ); +} diff --git a/src/app/(app)/equipe/page.tsx b/src/app/(app)/equipe/page.tsx index c07a9e1..9f7ba89 100644 --- a/src/app/(app)/equipe/page.tsx +++ b/src/app/(app)/equipe/page.tsx @@ -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 = { - ACTIVE: { label: 'Actif', tone: 'ok' }, - INVITED: { label: 'Invitation en attente', tone: 'info' }, - ARCHIVED: { label: 'Archivé', tone: 'neutral' }, +const INVITATION_TONES: Record = { + 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>; +}) { + 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 ( 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={ + + Registre unique du personnel + + } /> + {/* `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. */} + }> + + + - - {employees.length === 0 ? ( + {directory.rows.length === 0 ? ( ) : (
- +
- - - - + + + + - {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 ( {employee.firstName.charAt(0)} {employee.lastName.charAt(0)} @@ -76,13 +131,13 @@ export default async function EquipePage() { {employee.firstName} {employee.lastName} - {employee.email ?? 'Sans compte applicatif'} + {employee.email ?? `Matricule ${employee.employeeNumber}`} - + - ); @@ -119,7 +183,7 @@ export default async function EquipePage() { - +
diff --git a/src/server/employees/queries.ts b/src/server/employees/queries.ts index 3838d70..f63098e 100644 --- a/src/server/employees/queries.ts +++ b/src/server/employees/queries.ts @@ -23,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; @@ -34,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 = { @@ -52,22 +84,104 @@ export function contractLabel(type: string): string { return CONTRACT_LABELS[type] ?? type; } -export async function listEmployees(): Promise { +export async function listEmployees( + filters: EmployeeFilters = {}, +): Promise { 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( @@ -76,20 +190,28 @@ export async function listEmployees(): Promise { .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, @@ -105,8 +227,33 @@ export async function listEmployees(): Promise { 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, + }; }); } @@ -205,6 +352,9 @@ export const getEmployee = cache(async function getEmployee( user: { select: { email: true } }, role: { select: { name: true } }, profile: true, + teams: { + select: { isPrimary: true, team: { select: { name: true } } }, + }, contracts: { orderBy: { startDate: 'desc' }, include: { @@ -279,10 +429,19 @@ export const getEmployee = cache(async function getEmployee( 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, diff --git a/tests/e2e/registre.spec.ts b/tests/e2e/registre.spec.ts index 8c98047..58fc4c5 100644 --- a/tests/e2e/registre.spec.ts +++ b/tests/e2e/registre.spec.ts @@ -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. @@ -64,9 +69,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(
CollaborateurMatriculeContratÉtablissement RôleStatutContratMobileRattachementInvitation
- {employee.employeeNumber} + + {employee.roleName} {employee.contract ? ( @@ -100,14 +155,23 @@ export default async function EquipePage() { Sans contrat )} + {employee.phone ?? ( + Non renseigné + )} + {employee.locationName ?? '—'} - - {employee.roleName} + {employee.teamName ? ` / ${employee.teamName}` : ''} - {status.label} + {invitation ? ( + {invitation.label} + ) : ( + + Sans accès applicatif + + )}