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:
1 parent
bb8f84696a
commit
5a113de02f
4 files changed
+405
-54
No files matched your search
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,59 +1,114 @@
|
|||||||
import Link from 'next/link';
|
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 { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||||
import { Badge, type Tone } from '@/components/ui/Badge';
|
import { Badge, type Tone } from '@/components/ui/Badge';
|
||||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||||
import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm';
|
|
||||||
import { listEmployees } from '@/server/employees/queries';
|
import { listEmployees } from '@/server/employees/queries';
|
||||||
|
|
||||||
export const metadata = { title: 'Équipe · PlanFlow' };
|
export const metadata = { title: 'Équipe · PlanFlow' };
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const STATUS_TONES: Record<string, { label: string; tone: Tone }> = {
|
const INVITATION_TONES: Record<string, { label: string; tone: Tone }> = {
|
||||||
ACTIVE: { label: 'Actif', tone: 'ok' },
|
ACCEPTED: { label: 'Acceptée', tone: 'ok' },
|
||||||
INVITED: { label: 'Invitation en attente', tone: 'info' },
|
PENDING: { label: 'En attente', tone: 'info' },
|
||||||
ARCHIVED: { label: 'Archivé', tone: 'neutral' },
|
EXPIRED: { label: 'Expirée', tone: 'warn' },
|
||||||
|
REVOKED: { label: 'Révoquée', tone: 'neutral' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
|
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
|
||||||
|
|
||||||
export default async function EquipePage() {
|
const one = (value: string | string[] | undefined): string | undefined =>
|
||||||
const employees = await listEmployees();
|
Array.isArray(value) ? value[0] : value;
|
||||||
const withoutAccount = employees.filter((e) => !e.hasAccount).length;
|
|
||||||
|
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 (
|
return (
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Équipe"
|
title="Équipe"
|
||||||
subtitle={`${employees.length} salarié${employees.length > 1 ? 's' : ''}${
|
subtitle={
|
||||||
withoutAccount > 0 ? ` · ${withoutAccount} sans compte applicatif` : ''
|
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>
|
<Card>
|
||||||
<CardHeader title="Effectif" />
|
{directory.rows.length === 0 ? (
|
||||||
{employees.length === 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Aucun salarié"
|
title={filtered ? 'Aucun résultat' : 'Aucun salarié'}
|
||||||
description="Ajoutez un premier salarié pour commencer à planifier."
|
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">
|
<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>
|
<thead>
|
||||||
<tr className="border-b border-line-2 bg-surface-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
<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">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">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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{employees.map((employee) => {
|
{directory.rows.map((employee) => {
|
||||||
const status =
|
const invitation = employee.invitationState
|
||||||
STATUS_TONES[employee.status] ?? STATUS_TONES.ACTIVE!;
|
? INVITATION_TONES[employee.invitationState]
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={employee.id}
|
key={employee.id}
|
||||||
@@ -66,7 +121,7 @@ export default async function EquipePage() {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
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.firstName.charAt(0)}
|
||||||
{employee.lastName.charAt(0)}
|
{employee.lastName.charAt(0)}
|
||||||
@@ -76,13 +131,13 @@ export default async function EquipePage() {
|
|||||||
{employee.firstName} {employee.lastName}
|
{employee.firstName} {employee.lastName}
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate text-micro text-ink-3">
|
<span className="block truncate text-micro text-ink-3">
|
||||||
{employee.email ?? 'Sans compte applicatif'}
|
{employee.email ?? `Matricule ${employee.employeeNumber}`}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="tnum px-4 py-2.5 text-ink-2">
|
<td className="px-4 py-2.5">
|
||||||
{employee.employeeNumber}
|
<Badge tone="accent">{employee.roleName}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
{employee.contract ? (
|
{employee.contract ? (
|
||||||
@@ -100,14 +155,23 @@ export default async function EquipePage() {
|
|||||||
<Badge tone="warn">Sans contrat</Badge>
|
<Badge tone="warn">Sans contrat</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="px-4 py-2.5 text-ink-2">
|
||||||
{employee.locationName ?? '—'}
|
{employee.locationName ?? '—'}
|
||||||
</td>
|
{employee.teamName ? ` / ${employee.teamName}` : ''}
|
||||||
<td className="px-4 py-2.5 text-ink-2">
|
|
||||||
{employee.roleName}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -119,7 +183,7 @@ export default async function EquipePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Ajouter un salarié" />
|
<CardHeader title="Ajouter un collaborateur" />
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<AddEmployeeForm />
|
<AddEmployeeForm />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+176
-17
@@ -23,6 +23,8 @@ export interface EmployeeListRow {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
/** Téléphone du dossier : la colonne « Mobile » de l'annuaire. */
|
||||||
|
phone: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
hasAccount: boolean;
|
hasAccount: boolean;
|
||||||
roleName: string;
|
roleName: string;
|
||||||
@@ -34,6 +36,36 @@ export interface EmployeeListRow {
|
|||||||
forfaitJours: boolean;
|
forfaitJours: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
locationName: string | 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> = {
|
const CONTRACT_LABELS: Record<string, string> = {
|
||||||
@@ -52,22 +84,104 @@ export function contractLabel(type: string): string {
|
|||||||
return CONTRACT_LABELS[type] ?? type;
|
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) => {
|
return query('members.view', async (db) => {
|
||||||
const memberships = await db.membership.findMany({
|
const now = new Date();
|
||||||
where: { archivedAt: null },
|
const search = filters.q?.trim();
|
||||||
include: {
|
|
||||||
profile: { select: { firstName: true, lastName: true } },
|
// Un archivé n'appartient plus à l'effectif : il faut le demander.
|
||||||
user: { select: { email: true } },
|
const presence =
|
||||||
role: { select: { name: true } },
|
filters.presence === 'all'
|
||||||
contracts: {
|
? {}
|
||||||
where: { status: 'ACTIVE' },
|
: filters.presence === 'archived'
|
||||||
orderBy: { startDate: 'desc' },
|
? { archivedAt: { not: null } }
|
||||||
take: 1,
|
: { 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 = [
|
const locationIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
@@ -76,20 +190,28 @@ export async function listEmployees(): Promise<EmployeeListRow[]> {
|
|||||||
.map((contract) => contract.locationId),
|
.map((contract) => contract.locationId),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
const locations = await db.location.findMany({
|
const contractLocations = await db.location.findMany({
|
||||||
where: { id: { in: locationIds } },
|
where: { id: { in: locationIds } },
|
||||||
select: { id: true, name: true },
|
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 contract = membership.contracts[0];
|
||||||
|
const team =
|
||||||
|
membership.teams.find((entry) => entry.isPrimary) ??
|
||||||
|
membership.teams[0];
|
||||||
|
const invitation = membership.invitations[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: membership.id,
|
id: membership.id,
|
||||||
employeeNumber: membership.employeeNumber,
|
employeeNumber: membership.employeeNumber,
|
||||||
firstName: membership.profile?.firstName ?? '',
|
firstName: membership.profile?.firstName ?? '',
|
||||||
lastName: membership.profile?.lastName ?? membership.employeeNumber,
|
lastName: membership.profile?.lastName ?? membership.employeeNumber,
|
||||||
email: membership.user?.email ?? null,
|
email: membership.user?.email ?? null,
|
||||||
|
phone: membership.profile?.phone ?? null,
|
||||||
status: membership.status,
|
status: membership.status,
|
||||||
hasAccount: membership.userId !== null,
|
hasAccount: membership.userId !== null,
|
||||||
roleName: membership.role.name,
|
roleName: membership.role.name,
|
||||||
@@ -105,8 +227,33 @@ export async function listEmployees(): Promise<EmployeeListRow[]> {
|
|||||||
locationName: contract
|
locationName: contract
|
||||||
? (locationNames.get(contract.locationId) ?? null)
|
? (locationNames.get(contract.locationId) ?? null)
|
||||||
: 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 } },
|
user: { select: { email: true } },
|
||||||
role: { select: { name: true } },
|
role: { select: { name: true } },
|
||||||
profile: true,
|
profile: true,
|
||||||
|
teams: {
|
||||||
|
select: { isPrimary: true, team: { select: { name: true } } },
|
||||||
|
},
|
||||||
contracts: {
|
contracts: {
|
||||||
orderBy: { startDate: 'desc' },
|
orderBy: { startDate: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
@@ -279,10 +429,19 @@ export const getEmployee = cache(async function getEmployee(
|
|||||||
firstName: membership.profile?.firstName ?? '',
|
firstName: membership.profile?.firstName ?? '',
|
||||||
lastName: membership.profile?.lastName ?? membership.employeeNumber,
|
lastName: membership.profile?.lastName ?? membership.employeeNumber,
|
||||||
email: membership.user?.email ?? null,
|
email: membership.user?.email ?? null,
|
||||||
|
phone: membership.profile?.phone ?? null,
|
||||||
status: membership.status,
|
status: membership.status,
|
||||||
hasAccount: membership.userId !== null,
|
hasAccount: membership.userId !== null,
|
||||||
roleName: membership.role.name,
|
roleName: membership.role.name,
|
||||||
locationName: location?.name ?? null,
|
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
|
contract: active
|
||||||
? {
|
? {
|
||||||
type: active.contractType,
|
type: active.contractType,
|
||||||
|
|||||||
@@ -43,8 +43,13 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
|
|||||||
await page.goto('/equipe');
|
await page.goto('/equipe');
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible();
|
||||||
// L'effectif vient de la base, pas du module de démonstration.
|
// L'effectif vient de la base, pas du module de démonstration : un annuaire
|
||||||
await expect(page.getByText('E0001')).toBeVisible();
|
// 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
|
// 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.
|
// 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
|
// Un salarié sans adresse doit exister : la plupart des équipes de vente ne
|
||||||
// se connectent jamais à l'outil.
|
// se connectent jamais à l'outil.
|
||||||
await expect(
|
// Sans compte applicatif, l'annuaire affiche le matricule sous le nom : c'est
|
||||||
page.getByRole('cell', { name: matricule, exact: true }),
|
// la seule adresse à laquelle on désigne ce salarié.
|
||||||
).toBeVisible();
|
await expect(page.getByText(`Matricule ${matricule}`)).toBeVisible();
|
||||||
// Le nom vit sur le dossier, pas sur le compte : un salarié sans accès
|
// 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.
|
// applicatif doit tout de même figurer nommément au registre du personnel.
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
Reference in new issue
Block a user