diff --git a/src/app/(app)/equipe/AddEmployeeDialog.tsx b/src/app/(app)/equipe/AddEmployeeDialog.tsx new file mode 100644 index 0000000..019f331 --- /dev/null +++ b/src/app/(app)/equipe/AddEmployeeDialog.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm'; +import { Button } from '@/components/ui/Button'; +import type { ContractLocation } from '@/server/employees/queries'; + +/** + * Ajout d'un collaborateur, en modale. + * + * Le formulaire d'embauche compte une quinzaine de champs : posé au bas de la + * liste, il occupait plus de place que l'effectif qu'on vient consulter. Il ne + * s'ouvre donc qu'à la demande. + * + * `` natif plutôt qu'un panneau maison : il apporte le piège de focus, + * la fermeture par Échap et le fond inerte, trois choses qu'une `
` doit + * réimplémenter et rate presque toujours. + */ +export function AddEmployeeDialog({ + locations, +}: { + locations: ContractLocation[]; +}) { + const dialog = useRef(null); + const [open, setOpen] = useState(false); + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open && !element.open) element.showModal(); + if (!open && element.open) element.close(); + }, [open]); + + return ( + <> + + + setOpen(false)} + className="m-auto w-[min(46rem,calc(100vw-2rem))] rounded-3 border border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]" + > +
+

+ Ajouter un collaborateur +

+ + +
+ +
+ {open ? ( + // Monté à l'ouverture : un formulaire démonté entre deux usages + // repart vide, sans traîner la saisie abandonnée la fois d'avant. + setOpen(false)} + /> + ) : null} +
+
+ + ); +} diff --git a/src/app/(app)/equipe/AddEmployeeForm.tsx b/src/app/(app)/equipe/AddEmployeeForm.tsx index 53079f9..e77b240 100644 --- a/src/app/(app)/equipe/AddEmployeeForm.tsx +++ b/src/app/(app)/equipe/AddEmployeeForm.tsx @@ -39,9 +39,12 @@ const selectClass = export function AddEmployeeForm({ locations, + onSaved, }: { /** Vide quand la capacité d'ouvrir un contrat manque : la section disparaît. */ locations: ContractLocation[]; + /** Appelé une fois l'ajout accepté — la modale s'y referme. */ + onSaved?: () => void; }) { const [state, formAction] = useActionState( createEmployeeAction, @@ -50,6 +53,14 @@ export function AddEmployeeForm({ const [withContract, setWithContract] = useState(locations.length > 0); const [locationId, setLocationId] = useState(locations[0]?.id ?? ''); const [forfait, setForfait] = useState(false); + const [acknowledged, setAcknowledged] = useState(empty); + + // Acquitté une fois pour toutes : sans cela, un succès resté en mémoire + // refermerait la modale à sa réouverture. + if (state !== acknowledged && state.ok) { + setAcknowledged(state); + onSaved?.(); + } const teams = locations.find((location) => location.id === locationId)?.teams ?? []; diff --git a/src/app/(app)/equipe/DirectoryFilters.tsx b/src/app/(app)/equipe/DirectoryFilters.tsx index 3428827..1a76ee2 100644 --- a/src/app/(app)/equipe/DirectoryFilters.tsx +++ b/src/app/(app)/equipe/DirectoryFilters.tsx @@ -11,8 +11,10 @@ import { useRef } from 'react'; * choix plutôt que de vider la page. */ +/** Pastilles plutôt que champs : une barre de filtres se survole, elle ne se + * remplit pas. */ 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'; + 'h-10 min-w-0 cursor-pointer rounded-full border border-line-2 bg-surface px-4 text-sm text-ink-1 outline-none hover:bg-surface-2 focus-visible:border-focus'; export interface FilterOptions { locations: Array<{ id: string; name: string }>; @@ -43,7 +45,7 @@ export function DirectoryFilters({ options }: { options: FilterOptions }) { 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" + className="h-11 rounded-full border border-line-2 bg-surface px-4 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. diff --git a/src/app/(app)/equipe/page.tsx b/src/app/(app)/equipe/page.tsx index 3825ff6..27502ac 100644 --- a/src/app/(app)/equipe/page.tsx +++ b/src/app/(app)/equipe/page.tsx @@ -1,11 +1,11 @@ import Link from 'next/link'; import { Suspense } from 'react'; -import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm'; +import { AddEmployeeDialog } from '@/app/(app)/equipe/AddEmployeeDialog'; 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 { EmptyState } from '@/components/ui/Card'; import { listContractLocations, listEmployees, @@ -21,8 +21,6 @@ const INVITATION_TONES: Record = { REVOKED: { label: 'Révoquée', tone: 'neutral' }, }; -const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }); - const one = (value: string | string[] | undefined): string | undefined => Array.isArray(value) ? value[0] : value; @@ -59,25 +57,28 @@ export default async function EquipePage({ 1 ? 's' : ''}` - } + // Le décompte n'apparaît que s'il apprend quelque chose : « 87 salariés » + // au-dessus d'une liste de 87 lignes ne dit rien que la liste ne dise. + {...(filtered + ? { subtitle: `${directory.rows.length} sur ${directory.total} salariés` } + : {})} actions={ - - Registre unique du personnel - + <> + + 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. */} - }> + }> - - {directory.rows.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - {directory.rows.map((employee) => { - const invitation = employee.invitationState - ? INVITATION_TONES[employee.invitationState] - : null; + {directory.rows.length === 0 ? ( + + ) : ( +
+
CollaborateurRôleContratMobileRattachementInvitation
+ + + + + + + + + + + + {directory.rows.map((employee) => { + const invitation = employee.invitationState + ? INVITATION_TONES[employee.invitationState] + : null; - return ( - - - - - - - - - ); - })} - -
CollaborateurRôleEmailMobileRattachementInvitation
- + + + - - {employee.firstName.charAt(0)} - {employee.lastName.charAt(0)} + {employee.firstName.charAt(0)} + {employee.lastName.charAt(0)} + + + + {employee.firstName} {employee.lastName} - - - {employee.firstName} {employee.lastName} - + {/* Le matricule ne double la colonne « Email » que + lorsqu'il n'y en a pas : c'est alors la seule + adresse à laquelle on désigne ce salarié. */} + {employee.email ? null : ( - {employee.email ?? `Matricule ${employee.employeeNumber}`} + Matricule {employee.employeeNumber} - - - - {employee.roleName} - - {employee.contract ? ( - - {employee.contract.label} - {employee.contract.forfaitJours ? ( - Forfait jours - ) : null} - - depuis le{' '} - {dateFormat.format(employee.contract.since)} - - - ) : ( - Sans contrat - )} - - {employee.phone ?? ( - Non renseigné - )} - - {employee.locationName ?? '—'} - {employee.teamName ? ` / ${employee.teamName}` : ''} - - {invitation ? ( - {invitation.label} - ) : ( - - Sans accès applicatif - - )} -
-
- )} -
- - - -
- + )} + + + + + {employee.roleName} + + + {employee.email ?? ( + Sans compte + )} + + + {employee.phone ?? ( + Non renseigné + )} + + + {employee.locationName ? ( + <> + {employee.locationName} + {employee.teamName ? ` / ${employee.teamName}` : ''} + + ) : ( + Sans contrat + )} + + + {invitation ? ( + {invitation.label} + ) : ( + — + )} + + + ); + })} + +
-
+ )}
); } diff --git a/tests/e2e/conservation.spec.ts b/tests/e2e/conservation.spec.ts index 004ee6f..149412d 100644 --- a/tests/e2e/conservation.spec.ts +++ b/tests/e2e/conservation.spec.ts @@ -113,6 +113,9 @@ test('une pièce sans politique n’est jamais purgée', async ({ page }) => { // compte, et une pièce partagée verrait son sort décidé par un autre test. const suffix = `${Date.now()}-garde`; await page.goto('/equipe'); + // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre + // en modale, à la demande. + await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); const create = page.locator('form').filter({ hasText: 'Ajouter' }); await create.getByLabel('Prénom').fill('Garde'); await create.getByLabel('Nom', { exact: true }).fill(`Garde${suffix}`); @@ -167,6 +170,9 @@ test('une pièce échue est effectivement effacée', async ({ page }) => { await expect(page.getByText('Durée enregistrée.')).toBeVisible(); await page.goto('/equipe'); + // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre + // en modale, à la demande. + await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); const create = page.locator('form').filter({ hasText: 'Ajouter' }); await create.getByLabel('Prénom').fill('Purge'); await create.getByLabel('Nom', { exact: true }).fill(`Purge${suffix}`); diff --git a/tests/e2e/documents.spec.ts b/tests/e2e/documents.spec.ts index 266b87a..70b091f 100644 --- a/tests/e2e/documents.spec.ts +++ b/tests/e2e/documents.spec.ts @@ -27,6 +27,9 @@ async function createEmployee(page: Page, tag: string) { const lastName = `Dossier${suffix}`; await page.goto('/equipe'); + // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre + // en modale, à la demande. + await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); const form = page.locator('form').filter({ hasText: 'Ajouter' }); await form.getByLabel('Prénom').fill('Inès'); await form.getByLabel('Nom', { exact: true }).fill(lastName); diff --git a/tests/e2e/fiche.spec.ts b/tests/e2e/fiche.spec.ts index 7702b97..0179b2b 100644 --- a/tests/e2e/fiche.spec.ts +++ b/tests/e2e/fiche.spec.ts @@ -14,6 +14,9 @@ async function createEmployee(page: Page) { const lastName = `Fiche${suffix}`; await page.goto('/equipe'); + // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre + // en modale, à la demande. + await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); const form = page.locator('form').filter({ hasText: 'Ajouter' }); await form.getByLabel('Prénom').fill('Awa'); await form.getByLabel('Nom', { exact: true }).fill(lastName); @@ -99,6 +102,9 @@ test('une embauche pose le contrat en même temps que le dossier', async ({ const lastName = `Embauche${suffix}`; await page.goto('/equipe'); + // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre + // en modale, à la demande. + await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); const form = page.locator('form').filter({ hasText: 'Ajouter' }); await form.getByLabel('Prénom').fill('Sofia'); await form.getByLabel('Nom', { exact: true }).fill(lastName); @@ -113,10 +119,11 @@ test('une embauche pose le contrat en même temps que le dossier', async ({ 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', - ); + // Le rattachement paraît dans l'annuaire : un dossier sans contrat y porte + // « Sans contrat », celui-ci porte son établissement. + await expect( + page.getByRole('row', { name: new RegExp(lastName) }), + ).not.toContainText('Sans contrat'); await page.getByRole('link', { name: new RegExp(lastName) }).click(); // Le bandeau porte le contrat : c'est ce qui distingue un dossier embauché diff --git a/tests/e2e/invitation.spec.ts b/tests/e2e/invitation.spec.ts index 9dad379..24a82bd 100644 --- a/tests/e2e/invitation.spec.ts +++ b/tests/e2e/invitation.spec.ts @@ -21,6 +21,9 @@ async function createEmployee(page: Page, tag: string) { const email = `sacha.${suffix}@exemple.test`; await page.goto('/equipe'); + // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre + // en modale, à la demande. + await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); const form = page.locator('form').filter({ hasText: 'Ajouter' }); await form.getByLabel('Prénom').fill(firstName); await form.getByLabel('Nom', { exact: true }).fill(lastName); diff --git a/tests/e2e/registre.spec.ts b/tests/e2e/registre.spec.ts index 2d75f8c..62867ef 100644 --- a/tests/e2e/registre.spec.ts +++ b/tests/e2e/registre.spec.ts @@ -55,6 +55,9 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => { // deux passages, et un nom fixe finirait par désigner plusieurs salariés. const matricule = `E9${Date.now() % 100000}`; const nom = `Sanscompte${matricule}`; + // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre + // en modale, à la demande. + await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); const form = page.locator('form').filter({ hasText: 'Ajouter' }); await form.locator('input[name="firstName"]').fill('Sans'); await form.locator('input[name="lastName"]').fill(nom); diff --git a/tests/e2e/roles.spec.ts b/tests/e2e/roles.spec.ts index 7f4e87e..b43e140 100644 --- a/tests/e2e/roles.spec.ts +++ b/tests/e2e/roles.spec.ts @@ -158,6 +158,9 @@ async function inviteWithRole(page: Page, roleLabel: string) { const lastName = `Role${suffix}`; await page.goto('/equipe'); + // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre + // en modale, à la demande. + await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); const create = page.locator('form').filter({ hasText: 'Ajouter' }); await create.getByLabel('Prénom').fill('Alix'); await create.getByLabel('Nom', { exact: true }).fill(lastName); diff --git a/tests/e2e/securite.spec.ts b/tests/e2e/securite.spec.ts index a84fc86..047900f 100644 --- a/tests/e2e/securite.spec.ts +++ b/tests/e2e/securite.spec.ts @@ -151,6 +151,9 @@ async function createAccessibleEmployee(page: Page) { const email = `facteur.${suffix}@exemple.test`; await page.goto('/equipe'); + // Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre + // en modale, à la demande. + await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click(); const form = page.locator('form').filter({ hasText: 'Ajouter' }); await form.getByLabel('Prénom').fill('Noé'); await form.getByLabel('Nom', { exact: true }).fill(lastName);