From e7515603ad03e6ceb7e92adcbb9b9b898ba3d375 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Tue, 13 Oct 2026 20:24:31 -0300 Subject: [PATCH 01/12] asdads --- apps/admin-frontend/docs/ARCHITECTURE.md | 13 +- apps/admin-frontend/e2e/clients.spec.ts | 99 ++++ .../src/app/pages/ClientDetails.test.tsx | 17 + .../src/app/pages/ClientDetails.tsx | 12 + .../src/app/pages/Clients.test.tsx | 20 - apps/admin-frontend/src/app/pages/Clients.tsx | 22 - apps/admin-frontend/src/app/routes.tsx | 8 +- .../clients/api/clientsRepository.test.ts | 72 ++- .../features/clients/api/clientsRepository.ts | 15 +- .../src/features/clients/index.ts | 1 + .../src/features/clients/model/client.ts | 1 + .../src/features/clients/model/clientList.ts | 15 + .../features/clients/model/clientStatus.ts | 6 + .../src/features/clients/ui/clientsPaths.ts | 5 + .../ClientListPage/ClientListPage.test.tsx | 429 ++++++++++++++++++ .../pages/ClientListPage/ClientListPage.tsx | 74 +++ .../components/ClientListEmpty.tsx | 30 ++ .../components/ClientListResults.tsx | 44 ++ .../components/OptionalText.tsx | 14 + .../components/clientColumns.tsx | 49 ++ .../pages/ClientListPage/useClientListPage.ts | 82 ++++ .../ClientListPage/useClientListPage.types.ts | 30 ++ docs/adr/0067-admin-frontend-clients-list.md | 69 +++ docs/adr/README.md | 4 +- 24 files changed, 1079 insertions(+), 52 deletions(-) create mode 100644 apps/admin-frontend/src/app/pages/ClientDetails.test.tsx create mode 100644 apps/admin-frontend/src/app/pages/ClientDetails.tsx delete mode 100644 apps/admin-frontend/src/app/pages/Clients.test.tsx delete mode 100644 apps/admin-frontend/src/app/pages/Clients.tsx create mode 100644 apps/admin-frontend/src/features/clients/model/clientList.ts create mode 100644 apps/admin-frontend/src/features/clients/model/clientStatus.ts create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.test.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListEmpty.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListResults.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/OptionalText.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/clientColumns.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.ts create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.types.ts create mode 100644 docs/adr/0067-admin-frontend-clients-list.md diff --git a/apps/admin-frontend/docs/ARCHITECTURE.md b/apps/admin-frontend/docs/ARCHITECTURE.md index 60470eab..88a3e597 100644 --- a/apps/admin-frontend/docs/ARCHITECTURE.md +++ b/apps/admin-frontend/docs/ARCHITECTURE.md @@ -438,7 +438,8 @@ Chosen, and — just as important — tried and backed out of, so nobody re-liti | A value more than one form carries (CPF, phone, e-mail, birth date) is one file in `shared/values/`: its `parseX`, limits, rules and Zod schema together. `shared/format/` keeps only the policy-free `date.ts` and `age.ts`; `shared/form/schemas/` is gone | Asked for, in review of #174, collapsing the first two of the three layers above. Reading or changing a CPF meant `shared/format/cpf.ts`, `shared/form/schemas/cpf.ts` with its barrel, and `cpf-field.tsx`; `format/` also held policy (`ADULT_AGE_IN_YEARS`, `MAX_AGE_IN_YEARS`), not only formatting; and `schemas/birthDate.ts` exported `ageFromBirthDate`, which is not a schema and ran Zod on every render of `BirthDateField`. `shared/values/.ts` mirrors the backend's `Admin.SharedKernel.ValueObjects` one file per value object (`CpfNumber` ↔ `cpf.ts`), so a new value type (a CNPJ) is one file plus, if several forms configure it the same way, a preset field. `ageFromBirthDate(masked, today = todayUtc())` composes `parseMaskedDate`, `isInThePast` and `isWithinMaxAge` itself. The preset fields stay in `shared/form/fields/`: keeping the React half apart lets `model/` import a value's rules (`features/clients/model/client.ts` imports `isMinorOn`) without pulling React in; it does pull Zod, which the three-layer row already accepted. No barrel, as `shared/format/` never had one: imports are `@/shared/values/cpf`. Tests are merged per value (`cpf.test.ts` covers `parseCpf`, `maskCpf` and `cpfSchema`). | | `DateField` has no key handler of its own: the keyboard reaches the calendar through its **Abrir calendário** button (Tab, Enter or Space; Esc closes and returns focus to the button), and the arrow keys stay with the input | Asked for, in review of #174. An `onKeyDown` on the input opened the calendar on `↓`, the only key handler in `src/`, against AGENTS.md's "prefer the primitive's behaviour to a key handler of your own". `↓`-opens is the combobox pattern, which needs `role="combobox"`, `aria-expanded`, `aria-haspopup` and `aria-controls` on the input; without them a screen reader user had no way to learn it existed, and adding them would have been more hand-written behaviour, not less. It also replaced any `onKeyDown` a caller passed, since the spread came first. The input plus a button that opens the calendar as a dialog is the WAI-ARIA date picker dialog pattern, and Base UI's `PopoverTrigger` already gives it all. A test pins both halves: the keyboard path through the button, and `↓` in the input opening nothing. | | `ControlledField` takes the field's path as an optional second type argument, `ControlledField = Path>` | Asked for, in review of #174. With only `T`, `field` was typed over every path of the form, so `field.value` was the union of all its values and the reference contact's purposes needed `field.value as string[]`. Passing the path (``ControlledField``) types `field.value` as that field's own value, `ContactPurpose[]`. The default keeps every other call site as it was. The cost is the second explicit argument: TypeScript does not infer one type argument when another is given. | -| A slice names its own URL once, in `ui/Paths.ts` (`CLIENTS_PATH`, `TAGS_PATH`); `app/`keeps writing the path | Asked for, in review of #174:`/pessoas`was written three times inside`features/clients`, `/tags`twice inside`features/tags`. The constant sits in `ui/`rather than in one page's folder, because the list (#155) and editing (#156) navigate to the same place.`app/routes.tsx`, `app/shell/navigation.ts`and`app/pages/Clients.tsx`keep the literal:`app/` owns the route table, and taking the constant from the slice would mean the shell importing the slice barrel, which risks pulling a lazily loaded slice into the main bundle. Tests and e2e keep the literal too, since they check the URL contract. | +| A slice names its own URL once, in `ui/Paths.ts` (`CLIENTS_PATH`, `TAGS_PATH`); `app/`keeps writing the path | Asked for, in review of #174:`/pessoas`was written three times inside`features/clients`, `/tags`twice inside`features/tags`. The constant sits in `ui/`rather than in one page's folder, because the list (#155) and editing (#156) navigate to the same place.`app/routes.tsx`and`app/shell/navigation.ts`keep the literal:`app/` owns the route table, and taking the constant from the slice would mean the shell importing the slice barrel, which risks pulling a lazily loaded slice into the main bundle. Tests and e2e keep the literal too, since they check the URL contract. | +| `features/clients` — the Pessoas list (`/pessoas`, issue #155): search only, with the situation filter and the paging controls deferred | Second list screen, on the shape Tags settled: `useClientListPage` reads `?q=`, calls `clientsRepository.list(search, signal)` from a plain `useEffect` with an `AbortController`, and derives `status` by comparing the in-flight query with the last resolved one. The page is a shell over `ClientListResults` (failure → `ErrorState` with **Tentar novamente**; ready and empty → `ClientListEmpty`; otherwise `ListSection`) and `clientColumns` (Nome, Telefone, E-mail, CPF, Situação). Decided with the owner on 2026-10-13, before any code ([ADR 0067](../../../docs/adr/0067-admin-frontend-clients-list.md)): the CPF shows **whole and formatted** (`maskCpf`, which formats and hides nothing), following ADR 0063 over the text of #155 and #139; the filter and the paging controls wait for the owner to define them, so the screen lists the first page of active people and says "Exibindo 20 de 87 pessoas ativas." when it is cut off, the repository takes only `search`, and `status`/`page` arrive together with their controls; each name links to `/pessoas/:id`, a provisional "Em breve" page (`app/pages/ClientDetails.tsx`) until #167 replaces it. Empty is two states built on `EmptyState` instead of `list-section`'s fixed message: nobody yet (no button, the header already has **Nova pessoa**) and a search that found nobody (**Limpar busca**). `list` maps only `totalCount`, which is `number \| string` on the wire so verbatim forwarding does not compile, and drops `page`/`pageSize`. The search form and the hook's URL and abort logic are the second copy of Tags' and stay copied until a third list (§6). `app/pages/Clients.tsx` is retired. Acceptance criteria of #155 not met yet: an inactive person appearing through the filter, and moving between pages. | The palette opens from the header button. The single-use `LinkButton` wrapper was removed; its remaining link uses `buttonVariants` directly. The combobox keeps only the parts used by the palette, and those parts are in the coverage gate. @@ -464,9 +465,10 @@ because it didn't need to be yet. This is the compiled view across the whole app `specs/002-ui-foundation/`'s explicit scope (D6) — chrome, theme, routing and "Em breve" stubs first. Retirement trigger, per page: **the first real feature slice replaces the stub it corresponds to** (e.g. a `features/services/` slice replaces `app/pages/Services.tsx` and moves - under `ui/pages//` per §1), once its backend exists. `app/pages/Clients.tsx` is already - half-way: it is the Pessoas hub (title plus a **Nova pessoa** link), no longer an "Em breve" screen, - while the registration form lives in `features/clients`; the list from issue #155 replaces its body. + under `ui/pages//` per §1), once its backend exists. `app/pages/Clients.tsx` went that way: + the Pessoas list lives in `features/clients` (`ClientListPage`, issue #155) and the stub is gone. + `app/pages/ClientDetails.tsx` is the one new placeholder, the "Em breve" page behind `/pessoas/:id` + until issue #167 builds the detail in `features/clients`. - **The API layer (`servicesApi`, `apiClient`, `unwrap`, `servicesFacade`, the generated types) stood with zero call sites for a while, on purpose — not dead code.** `features/tags/api/tagsRepository.ts` is its first real consumer (`specs/003-tags-crud/`); see @@ -494,7 +496,8 @@ because it didn't need to be yet. This is the compiled view across the whole app | Shared handler for a session that goes invalid mid-visit (`Session.Missing` / `Authorization.Unauthorized` from a live API response) | Confirmed end to end: nothing reacts to this today, `ProtectedRoute` included — it only redirects off the OIDC snapshot, which an API 401 never touches (§5 has the audit and why it was removed from `useTagListPage` rather than left there) | A second page starts fetching past a session that can go stale mid-visit — build it once, in `shared/api`/`shared/session`, not per page | | `identity-service` typed client | Consumed purely through the OIDC protocol | Never — it's protocol, not REST | | External telemetry / observability backend | `shared/logger.ts` → `console` is enough | A real ops requirement appears | -| Pagination / infinite scroll in `list-section` | `/tags` has no `page`/`pageSize` on the backend yet, and its spec explicitly excludes pagination (small catalog) | A second real list screen needs it — `/services` already returns `page`/`pageSize`/`totalCount`, unconsumed today | +| Pagination in `list-section` and a situation filter on the Pessoas list | `/clients` already pages and filters (ADR 0063) and Pessoas is the second list screen, but the owner defines the select and the paging controls separately; meanwhile the list shows the first page of active people and a "Exibindo N de M" line (ADR 0067) | The owner defines the controls (follow-up of #155): `status` and `page` then enter `clientsRepository.list`, the URL and the hook together, with a `shared/ui` select and a `widgets/` pagination | +| `widgets/search-form`: the `role="search"` form and the URL-and-abort search logic of the list hooks | `TagListPage` and `ClientListPage` repeat about fifteen lines of markup and the same `?q=` / `AbortController` logic; two copies are too few to fix a shared shape | A third list screen (Serviços is the next candidate) needs the same search: extract the widget and a shared hook shape, then move Tags and Pessoas onto it | | `list-section`'s `renderItem` (list, no header) mode exercised by a real screen | `TagListPage` uses the `columns` mode for its own three-field table | A listing without natural columns (e.g. a simple feed or timeline) needs it | | `run()` classifying an aborted request's own `AbortError` as a distinct outcome, instead of folding it into `NETWORK_PROBLEM` | `servicesFacade.ts`'s `CallOptions`/`buildInit` accept an optional `signal` (added for `useTagListPage`'s `fetchTags`, ADR 0033's scoped exception, §5), but the one consumer that passes one checks `controller.signal.aborted` itself, before ever looking at the `ApiResult` — it never needs `.error.code` to say "cancelled" correctly | A second `signal`-aware caller needs to tell "I cancelled this" apart from "the network actually failed" via the `ApiResult` itself, not by holding its own `AbortController` reference the way `fetchTags` does | | `widgets/repeatable-list`: one frame for an item of a dynamic list (`role="group"` named by an `h3`, plus a **Remover** button) | Two items repeat that header today, `ClientGuardianItem` and `ClientReferenceContactItem`, both on the same page, and editing (#156) reuses that page, so it is not a third. Two examples are too few to fix the shape of a shared widget, and the copy is about fifteen lines of markup | A third repeatable list appears in any slice: build `RepeatableItem({ title, removeLabel, onRemove, children })` and move the two client items onto it | diff --git a/apps/admin-frontend/e2e/clients.spec.ts b/apps/admin-frontend/e2e/clients.spec.ts index d60fa01d..cda19f18 100644 --- a/apps/admin-frontend/e2e/clients.spec.ts +++ b/apps/admin-frontend/e2e/clients.spec.ts @@ -99,3 +99,102 @@ test.describe('Register a person', () => { await expect(page).toHaveURL(/\/pessoas\/nova$/); }); }); + +function formatCpf(digits: string): string { + return `${digits.slice(0, 3)}.${digits.slice(3, 6)}.${digits.slice(6, 9)}-${digits.slice(9)}`; +} + +async function registerPerson( + page: Page, + person: { name: string; email: string; cpf: string }, +): Promise { + await openNewPersonForm(page); + const section = page.getByRole('region', { name: 'Dados da pessoa' }); + await section.getByLabel('Nome completo').fill(person.name); + await section.getByLabel('E-mail').fill(person.email); + await section.getByLabel('CPF').fill(person.cpf); + await page.getByRole('button', { name: 'Salvar' }).click(); + await expect(page).toHaveURL(/\/pessoas$/); +} + +async function searchFor(page: Page, term: string): Promise { + const search = page.getByLabel('Buscar pessoas por nome, e-mail ou CPF'); + await search.fill(term); + await search.press('Enter'); +} + +test.describe('People list', () => { + test.beforeEach(async ({ page }) => { + await loginAsDemoUser(page); + }); + + test('finds a registered person by name, e-mail and masked CPF, showing the whole CPF and the situation', async ({ + page, + }) => { + const person = { + name: uniqueName('Lista E2E'), + email: `lista.${Date.now()}@example.com`, + cpf: validCpf(), + }; + await registerPerson(page, person); + + for (const term of [person.name, person.email, formatCpf(person.cpf)]) { + await searchFor(page, term); + const row = page.getByRole('row', { name: new RegExp(person.name) }); + await expect(row).toBeVisible(); + await expect(row).toContainText(person.email); + await expect(row).toContainText(formatCpf(person.cpf)); + await expect(row).toContainText('Ativa'); + } + }); + + test('keeps the search in the URL, so it survives a reload', async ({ page }) => { + const person = { + name: uniqueName('Recarga E2E'), + email: `recarga.${Date.now()}@example.com`, + cpf: validCpf(), + }; + await registerPerson(page, person); + + await searchFor(page, person.name); + await expect(page).toHaveURL(/\/pessoas\?q=/); + await page.reload(); + + await expect(page.getByLabel('Buscar pessoas por nome, e-mail ou CPF')).toHaveValue( + person.name, + ); + await expect(page.getByRole('row', { name: new RegExp(person.name) })).toBeVisible(); + }); + + test('names a search that finds nobody and clears it', async ({ page }) => { + await page.getByRole('link', { name: 'Pessoas' }).click(); + await expect(page).toHaveURL(/\/pessoas$/); + const term = `inexistente-${Date.now()}`; + + await searchFor(page, term); + + await expect(page.getByText(`Nenhuma pessoa encontrada para «${term}»`)).toBeVisible(); + await page.getByRole('button', { name: 'Limpar busca' }).click(); + await expect(page).toHaveURL(/\/pessoas$/); + await expect(page.getByLabel('Buscar pessoas por nome, e-mail ou CPF')).toHaveValue(''); + await expect(page.getByText(/Nenhuma pessoa encontrada para/)).toHaveCount(0); + }); + + test('opens the person details from the name, which still says it is coming', async ({ + page, + }) => { + const person = { + name: uniqueName('Detalhe E2E'), + email: `detalhe.${Date.now()}@example.com`, + cpf: validCpf(), + }; + await registerPerson(page, person); + await searchFor(page, person.name); + + await page.getByRole('link', { name: person.name }).click(); + + await expect(page).toHaveURL(/\/pessoas\/[0-9a-f-]{36}$/); + await expect(page.getByRole('heading', { name: 'Detalhes da pessoa' })).toBeVisible(); + await expect(page.getByText('Em breve', { exact: true })).toBeVisible(); + }); +}); diff --git a/apps/admin-frontend/src/app/pages/ClientDetails.test.tsx b/apps/admin-frontend/src/app/pages/ClientDetails.test.tsx new file mode 100644 index 00000000..26058f57 --- /dev/null +++ b/apps/admin-frontend/src/app/pages/ClientDetails.test.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { ClientDetails } from './ClientDetails'; + +describe('ClientDetails', () => { + it('explains that the person details are coming, instead of presenting a working screen', () => { + render( + + + , + ); + + expect(screen.getByRole('heading', { name: 'Detalhes da pessoa' })).toBeInTheDocument(); + expect(screen.getByText('Em breve')).toBeInTheDocument(); + }); +}); diff --git a/apps/admin-frontend/src/app/pages/ClientDetails.tsx b/apps/admin-frontend/src/app/pages/ClientDetails.tsx new file mode 100644 index 00000000..9a6ee10d --- /dev/null +++ b/apps/admin-frontend/src/app/pages/ClientDetails.tsx @@ -0,0 +1,12 @@ +import { Users } from 'lucide-react'; +import { ComingSoon } from './ComingSoon'; + +export function ClientDetails() { + return ( + + ); +} diff --git a/apps/admin-frontend/src/app/pages/Clients.test.tsx b/apps/admin-frontend/src/app/pages/Clients.test.tsx deleted file mode 100644 index 731b2bb0..00000000 --- a/apps/admin-frontend/src/app/pages/Clients.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router'; -import { Clients } from './Clients'; - -describe('Clients', () => { - it('renders the Pessoas title and a way to register a new person', () => { - render( - - - , - ); - - expect(screen.getByRole('heading', { name: 'Pessoas' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Nova pessoa' })).toHaveAttribute( - 'href', - '/pessoas/nova', - ); - }); -}); diff --git a/apps/admin-frontend/src/app/pages/Clients.tsx b/apps/admin-frontend/src/app/pages/Clients.tsx deleted file mode 100644 index 0f835354..00000000 --- a/apps/admin-frontend/src/app/pages/Clients.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Plus } from 'lucide-react'; -import { Link } from 'react-router'; -import { buttonVariants } from '@/shared/ui/button'; - -export function Clients() { - return ( -
-
-
-

Pessoas

-

- A lista das pessoas atendidas vai aparecer aqui. -

-
- -
-
- ); -} diff --git a/apps/admin-frontend/src/app/routes.tsx b/apps/admin-frontend/src/app/routes.tsx index d3b9ec02..ec574c13 100644 --- a/apps/admin-frontend/src/app/routes.tsx +++ b/apps/admin-frontend/src/app/routes.tsx @@ -27,13 +27,19 @@ export const router = createBrowserRouter([ children: [ { index: true, - lazy: () => import('./pages/Clients').then((m) => ({ Component: m.Clients })), + lazy: () => + import('@/features/clients').then((m) => ({ Component: m.ClientListPage })), }, { path: 'nova', lazy: () => import('@/features/clients').then((m) => ({ Component: m.ClientFormPage })), }, + { + path: ':id', + lazy: () => + import('./pages/ClientDetails').then((m) => ({ Component: m.ClientDetails })), + }, ], }, { diff --git a/apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts b/apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts index ebf9d781..9e520b86 100644 --- a/apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts +++ b/apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts @@ -3,12 +3,80 @@ import { utcDate } from '@/shared/format/date'; import type { ClientInput } from '../model/client'; import { clientsRepository } from './clientsRepository'; -const { mockPost } = vi.hoisted(() => ({ mockPost: vi.fn() })); +const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() })); vi.mock('@/shared/api/servicesApi', () => ({ - servicesApi: { post: mockPost }, + servicesApi: { get: mockGet, post: mockPost }, })); +const LIST_ITEM = { + id: 'abc', + fullName: 'Maria Souza', + birthDate: null, + phone: null, + email: 'maria@example.com', + cpf: '52998224725', + status: 'active', +}; + +describe('clientsRepository.list', () => { + it('sends the search term as Search and nothing else, leaving the backend defaults (active, first page) in charge', async () => { + mockGet.mockResolvedValue({ ok: true, data: { items: [], totalCount: 0 } }); + + await clientsRepository.list('maria'); + + expect(mockGet).toHaveBeenCalledWith('/api/v{version}/clients', { + query: { Search: 'maria' }, + signal: undefined, + }); + }); + + it('sends no query at all without a search term', async () => { + mockGet.mockResolvedValue({ ok: true, data: { items: [], totalCount: 0 } }); + + await clientsRepository.list(); + + expect(mockGet).toHaveBeenCalledWith('/api/v{version}/clients', { + query: undefined, + signal: undefined, + }); + }); + + it('hands the abort signal over to the request', async () => { + mockGet.mockResolvedValue({ ok: true, data: { items: [], totalCount: 0 } }); + const controller = new AbortController(); + + await clientsRepository.list('maria', controller.signal); + + const [, options] = mockGet.mock.calls.at(-1) as [string, { signal: AbortSignal }]; + expect(options.signal).toBe(controller.signal); + }); + + it('keeps the items as they came and turns the wire total, which may be a string, into a number', async () => { + mockGet.mockResolvedValue({ + ok: true, + data: { items: [LIST_ITEM], totalCount: '21', page: 1, pageSize: 20 }, + }); + + const result = await clientsRepository.list(); + + expect(result).toEqual({ ok: true, data: { items: [LIST_ITEM], totalCount: 21 } }); + }); + + it('forwards a failed result verbatim, without throwing (e.g. a search past the limit)', async () => { + const error = { + status: 400, + code: 'Validation.Failed', + title: 'A busca deve ter no máximo 254 caracteres.', + }; + mockGet.mockResolvedValue({ ok: false, error }); + + const result = await clientsRepository.list('x'); + + expect(result).toEqual({ ok: false, error }); + }); +}); + const INPUT: ClientInput = { fullName: 'Maria Souza', birthDate: utcDate(2015, 3, 10), diff --git a/apps/admin-frontend/src/features/clients/api/clientsRepository.ts b/apps/admin-frontend/src/features/clients/api/clientsRepository.ts index 1db9bc71..9ec54c5d 100644 --- a/apps/admin-frontend/src/features/clients/api/clientsRepository.ts +++ b/apps/admin-frontend/src/features/clients/api/clientsRepository.ts @@ -1,9 +1,22 @@ import { servicesApi } from '@/shared/api/servicesApi'; import type { ApiResult } from '@/shared/api/servicesFacade'; +import { ok } from '@/shared/result'; import type { Client, ClientInput } from '../model/client'; +import type { ClientList } from '../model/clientList'; + +async function list(search?: string, signal?: AbortSignal): Promise> { + const result = await servicesApi.get('/api/v{version}/clients', { + query: search ? { Search: search } : undefined, + signal, + }); + + if (!result.ok) return result; + + return ok({ items: result.data.items, totalCount: Number(result.data.totalCount) }); +} function create(input: ClientInput): Promise> { return servicesApi.post('/api/v{version}/clients', { body: input }); } -export const clientsRepository = { create }; +export const clientsRepository = { list, create }; diff --git a/apps/admin-frontend/src/features/clients/index.ts b/apps/admin-frontend/src/features/clients/index.ts index 323d4b58..0f609bbe 100644 --- a/apps/admin-frontend/src/features/clients/index.ts +++ b/apps/admin-frontend/src/features/clients/index.ts @@ -1 +1,2 @@ export { ClientFormPage } from './ui/pages/ClientFormPage/ClientFormPage'; +export { ClientListPage } from './ui/pages/ClientListPage/ClientListPage'; diff --git a/apps/admin-frontend/src/features/clients/model/client.ts b/apps/admin-frontend/src/features/clients/model/client.ts index deaff8cb..6e430965 100644 --- a/apps/admin-frontend/src/features/clients/model/client.ts +++ b/apps/admin-frontend/src/features/clients/model/client.ts @@ -6,6 +6,7 @@ import type { ClientReferenceContact } from './clientReferenceContact'; export const CLIENT_FULL_NAME_MIN_LENGTH = 2; export const CLIENT_FULL_NAME_MAX_LENGTH = 150; export const CLIENT_NOTES_MAX_LENGTH = 500; +export const CLIENT_SEARCH_MAX_LENGTH = 254; export const MAX_GUARDIANS = 10; export const MAX_REFERENCE_CONTACTS = 10; diff --git a/apps/admin-frontend/src/features/clients/model/clientList.ts b/apps/admin-frontend/src/features/clients/model/clientList.ts new file mode 100644 index 00000000..80d24997 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/clientList.ts @@ -0,0 +1,15 @@ +import type { ClientStatus } from './clientStatus'; + +export interface ClientListItem { + id: string; + fullName: string; + phone: string | null; + email: string | null; + cpf: string | null; + status: ClientStatus; +} + +export interface ClientList { + items: ClientListItem[]; + totalCount: number; +} diff --git a/apps/admin-frontend/src/features/clients/model/clientStatus.ts b/apps/admin-frontend/src/features/clients/model/clientStatus.ts new file mode 100644 index 00000000..6e4b5f55 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/clientStatus.ts @@ -0,0 +1,6 @@ +export type ClientStatus = 'active' | 'inactive'; + +export const CLIENT_STATUS_LABELS: Record = { + active: 'Ativa', + inactive: 'Inativa', +}; diff --git a/apps/admin-frontend/src/features/clients/ui/clientsPaths.ts b/apps/admin-frontend/src/features/clients/ui/clientsPaths.ts index d60e0c7a..d7c643bc 100644 --- a/apps/admin-frontend/src/features/clients/ui/clientsPaths.ts +++ b/apps/admin-frontend/src/features/clients/ui/clientsPaths.ts @@ -1 +1,6 @@ export const CLIENTS_PATH = '/pessoas'; +export const NEW_CLIENT_PATH = `${CLIENTS_PATH}/nova`; + +export function clientDetailPath(id: string): string { + return `${CLIENTS_PATH}/${id}`; +} diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.test.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.test.tsx new file mode 100644 index 00000000..f4af8df7 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.test.tsx @@ -0,0 +1,429 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createMemoryRouter, MemoryRouter, RouterProvider, useLocation } from 'react-router'; +import { ClientListPage } from './ClientListPage'; +import type { ClientList, ClientListItem } from '../../../model/clientList'; + +const { mockList } = vi.hoisted(() => ({ mockList: vi.fn() })); + +vi.mock('../../../api/clientsRepository', () => ({ + clientsRepository: { list: mockList }, +})); + +const SEARCH_LABEL = 'Buscar pessoas por nome, e-mail ou CPF'; + +const ANA: ClientListItem = { + id: 'c1', + fullName: 'Ana Souza', + phone: '(11) 91234-5678', + email: 'ana@example.com', + cpf: '52998224725', + status: 'active', +}; + +const BRUNO: ClientListItem = { + id: 'c2', + fullName: 'Bruno Lima', + phone: null, + email: null, + cpf: null, + status: 'inactive', +}; + +const CLIENTS = [ANA, BRUNO]; + +function listOf( + items: ClientListItem[], + totalCount = items.length, +): { ok: true; data: ClientList } { + return { ok: true, data: { items, totalCount } }; +} + +function LocationProbe() { + const location = useLocation(); + return
{`location: ${location.pathname}${location.search}`}
; +} + +function renderClientListPage(initialEntries: string[] = ['/pessoas']) { + return render( + + + + , + ); +} + +function renderPage(clients: ClientListItem[], initialEntries?: string[]) { + mockList.mockImplementation((search?: string) => { + const needle = search?.toLowerCase(); + const filtered = needle + ? clients.filter((client) => client.fullName.toLowerCase().includes(needle)) + : clients; + return Promise.resolve(listOf(filtered)); + }); + return renderClientListPage(initialEntries); +} + +describe('ClientListPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows the table loading skeleton on first render, before the initial fetch resolves', async () => { + let resolveInitial!: (value: ReturnType) => void; + mockList.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveInitial = resolve; + }), + ); + + const { container } = renderClientListPage(); + + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(screen.queryByText('Nenhuma pessoa ativa ainda')).not.toBeInTheDocument(); + + resolveInitial(listOf(CLIENTS)); + + await waitFor(() => expect(screen.getByText('Ana Souza')).toBeInTheDocument()); + expect(container.querySelector('[aria-busy="true"]')).toBeNull(); + }); + + it('asks for the first page of active people with no search on the first load', async () => { + renderPage(CLIENTS); + await screen.findByText('Ana Souza'); + + expect(mockList).toHaveBeenCalledTimes(1); + expect(mockList).toHaveBeenCalledWith(undefined, expect.any(AbortSignal)); + }); + + it('renders the Pessoas title and a "Nova pessoa" link pointing at the registration form', async () => { + renderPage(CLIENTS); + + expect(await screen.findByRole('heading', { name: 'Pessoas' })).toBeInTheDocument(); + const newClient = screen.getByRole('link', { name: 'Nova pessoa' }); + expect(newClient).toHaveAttribute('href', '/pessoas/nova'); + expect(newClient.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); + }); + + it('lists name, phone, e-mail, CPF and situation under their own column headers', async () => { + renderPage(CLIENTS); + + const table = await screen.findByRole('table', { name: 'Pessoas' }); + const headers = within(table) + .getAllByRole('columnheader') + .map((header) => header.textContent); + expect(headers).toEqual(['Nome', 'Telefone', 'E-mail', 'CPF', 'Situação']); + + const anaRow = screen.getByRole('row', { name: /Ana Souza/ }); + expect(within(anaRow).getByText('(11) 91234-5678')).toBeInTheDocument(); + expect(within(anaRow).getByText('ana@example.com')).toBeInTheDocument(); + expect(within(anaRow).getByText('Ativa')).toBeInTheDocument(); + }); + + it('shows the whole CPF, formatted, as the backend sends it (ADR 0067)', async () => { + renderPage(CLIENTS); + + expect(await screen.findByText('529.982.247-25')).toBeInTheDocument(); + expect(screen.queryByText(/\*/)).not.toBeInTheDocument(); + }); + + it('marks an inactive person as inactive and an active one as active', async () => { + renderPage(CLIENTS); + + const anaRow = await screen.findByRole('row', { name: /Ana Souza/ }); + const brunoRow = screen.getByRole('row', { name: /Bruno Lima/ }); + expect(within(anaRow).getByText('Ativa')).toBeInTheDocument(); + expect(within(brunoRow).getByText('Inativa')).toBeInTheDocument(); + expect(within(brunoRow).queryByText('Ativa')).not.toBeInTheDocument(); + }); + + it('says "Não informado" to assistive tech, and shows a dash, for each missing phone, e-mail and CPF', async () => { + renderPage([BRUNO]); + + const brunoRow = await screen.findByRole('row', { name: /Bruno Lima/ }); + + expect(within(brunoRow).getAllByText('Não informado')).toHaveLength(3); + expect(within(brunoRow).getAllByText('—')).toHaveLength(3); + }); + + it('links each name to that person under /pessoas/:id', async () => { + renderPage(CLIENTS); + + expect(await screen.findByRole('link', { name: 'Ana Souza' })).toHaveAttribute( + 'href', + '/pessoas/c1', + ); + expect(screen.getByRole('link', { name: 'Bruno Lima' })).toHaveAttribute('href', '/pessoas/c2'); + }); + + it('tells how many people are shown out of how many exist when the list was cut off', async () => { + mockList.mockResolvedValue(listOf(CLIENTS, 1234)); + + renderClientListPage(); + + expect(await screen.findByRole('status')).toHaveTextContent( + 'Exibindo 2 de 1.234 pessoas ativas.', + ); + }); + + it('shows no count line when every person fits in the list', async () => { + renderPage(CLIENTS); + await screen.findByText('Ana Souza'); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('limits the search field to what the backend accepts', async () => { + renderPage(CLIENTS); + + expect(screen.getByLabelText(SEARCH_LABEL)).toHaveAttribute('maxlength', '254'); + await screen.findByText('Ana Souza'); + }); + + it('shows the failure with the backend message and its code, not an empty list, when the fetch fails', async () => { + mockList.mockResolvedValue({ + ok: false, + error: { + status: 0, + code: 'Server.Unavailable', + title: 'O servidor está instável. Tente novamente em instantes.', + }, + }); + + renderClientListPage(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Não foi possível carregar as pessoas.'); + expect(alert).toHaveTextContent('O servidor está instável. Tente novamente em instantes.'); + expect(alert).toHaveTextContent('Código: Server.Unavailable'); + expect(screen.queryByText('Nenhuma pessoa ativa ainda')).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('retries the same search from the failure, showing the skeleton while it reloads', async () => { + const user = userEvent.setup(); + mockList.mockResolvedValueOnce({ + ok: false, + error: { title: 'Sem conexão com o servidor. Tente novamente.' }, + }); + let resolveRetry!: (value: ReturnType) => void; + mockList.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve; + }), + ); + const { container } = renderClientListPage(['/pessoas?q=ana']); + + await user.click(await screen.findByRole('button', { name: 'Tentar novamente' })); + + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(mockList).toHaveBeenLastCalledWith('ana', expect.any(AbortSignal)); + + resolveRetry(listOf([ANA])); + + expect(await screen.findByText('Ana Souza')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('says there is nobody yet, without a search action, when the list is empty and nothing was searched', async () => { + renderPage([]); + + expect(await screen.findByText('Nenhuma pessoa ativa ainda')).toBeInTheDocument(); + expect(screen.getByText('Use o botão Nova pessoa para cadastrar a primeira.')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Limpar busca' })).not.toBeInTheDocument(); + }); + + it('names the search term, and offers to clear it, when a search finds nobody', async () => { + const user = userEvent.setup(); + renderPage(CLIENTS); + await screen.findByText('Ana Souza'); + + await user.type(screen.getByLabelText(SEARCH_LABEL), 'zzz{Enter}'); + + expect(await screen.findByText('Nenhuma pessoa encontrada para «zzz»')).toBeInTheDocument(); + expect(screen.queryByText('Nenhuma pessoa ativa ainda')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Limpar busca' })).toBeInTheDocument(); + }); + + it('clears the search, the URL and the field, and lists everyone again, from "Limpar busca"', async () => { + const user = userEvent.setup(); + renderPage(CLIENTS, ['/pessoas?q=zzz']); + await user.click(await screen.findByRole('button', { name: 'Limpar busca' })); + + expect(await screen.findByText('Ana Souza')).toBeInTheDocument(); + expect(screen.getByTestId('location').textContent).toBe('location: /pessoas'); + expect(screen.getByLabelText(SEARCH_LABEL)).toHaveValue(''); + }); + + it('does not search while typing — only once submitted', async () => { + const user = userEvent.setup(); + renderPage(CLIENTS); + await screen.findByText('Ana Souza'); + + await user.type(screen.getByLabelText(SEARCH_LABEL), 'bruno'); + + expect(mockList).toHaveBeenCalledTimes(1); + expect(screen.getByText('Ana Souza')).toBeInTheDocument(); + }); + + it('narrows the list to the submitted term and puts it in the URL as ?q=', async () => { + const user = userEvent.setup(); + renderPage(CLIENTS); + await screen.findByText('Ana Souza'); + + await user.type(screen.getByLabelText(SEARCH_LABEL), 'bruno{Enter}'); + + await waitFor(() => expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument()); + expect(screen.getByText('Bruno Lima')).toBeInTheDocument(); + expect(mockList).toHaveBeenLastCalledWith('bruno', expect.any(AbortSignal)); + expect(screen.getByTestId('location').textContent).toBe('location: /pessoas?q=bruno'); + }); + + it('searches from the Buscar button too', async () => { + const user = userEvent.setup(); + renderPage(CLIENTS); + await screen.findByText('Ana Souza'); + + await user.type(screen.getByLabelText(SEARCH_LABEL), 'bruno'); + await user.click(screen.getByRole('button', { name: 'Buscar' })); + + await waitFor(() => expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument()); + expect(screen.getByText('Bruno Lima')).toBeInTheDocument(); + }); + + it('reads the initial search from the URL, pre-filling the field and filtering the first fetch', async () => { + renderPage(CLIENTS, ['/pessoas?q=bruno']); + + expect(screen.getByLabelText(SEARCH_LABEL)).toHaveValue('bruno'); + await waitFor(() => expect(mockList).toHaveBeenCalledWith('bruno', expect.any(AbortSignal))); + expect(await screen.findByText('Bruno Lima')).toBeInTheDocument(); + expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument(); + }); + + it('removes ?q= instead of leaving it empty when the search is submitted blank', async () => { + const user = userEvent.setup(); + renderPage(CLIENTS, ['/pessoas?q=bruno']); + await screen.findByText('Bruno Lima'); + + await user.clear(screen.getByLabelText(SEARCH_LABEL)); + await user.keyboard('{Enter}'); + + await waitFor(() => + expect(screen.getByTestId('location').textContent).toBe('location: /pessoas'), + ); + }); + + it('shows the loading skeleton while a new search is in flight, replacing the stale rows', async () => { + const user = userEvent.setup(); + mockList.mockResolvedValueOnce(listOf(CLIENTS)); + const { container } = renderClientListPage(); + await screen.findByText('Ana Souza'); + + let resolveSearch!: (value: ReturnType) => void; + mockList.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSearch = resolve; + }), + ); + + await user.type(screen.getByLabelText(SEARCH_LABEL), 'bruno{Enter}'); + + await waitFor(() => expect(container.querySelector('[aria-busy="true"]')).not.toBeNull()); + expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument(); + expect(screen.queryByText('Nenhuma pessoa encontrada para «bruno»')).not.toBeInTheDocument(); + + resolveSearch(listOf([BRUNO])); + + await waitFor(() => expect(screen.getByText('Bruno Lima')).toBeInTheDocument()); + expect(container.querySelector('[aria-busy="true"]')).toBeNull(); + }); + + it('keeps the newer search result even if the older, superseded request resolves later', async () => { + const user = userEvent.setup(); + renderPage(CLIENTS); + await screen.findByText('Ana Souza'); + + let resolveFirst!: (value: ReturnType) => void; + mockList.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const searchInput = screen.getByLabelText(SEARCH_LABEL); + await user.type(searchInput, 'bruno{Enter}'); + await waitFor(() => expect(mockList).toHaveBeenCalledTimes(2)); + + let resolveSecond!: (value: ReturnType) => void; + mockList.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + await user.clear(searchInput); + await user.type(searchInput, 'ana{Enter}'); + await waitFor(() => expect(mockList).toHaveBeenCalledTimes(3)); + + await act(async () => { + resolveSecond(listOf([ANA])); + await Promise.resolve(); + }); + expect(screen.getByText('Ana Souza')).toBeInTheDocument(); + + await act(async () => { + resolveFirst(listOf([BRUNO])); + await Promise.resolve(); + }); + expect(screen.getByText('Ana Souza')).toBeInTheDocument(); + expect(screen.queryByText('Bruno Lima')).not.toBeInTheDocument(); + }); + + it('keeps keyboard focus on the search field after submitting, so the person can keep typing', async () => { + const user = userEvent.setup(); + renderPage(CLIENTS); + await screen.findByText('Ana Souza'); + + const searchInput = screen.getByLabelText(SEARCH_LABEL); + await user.type(searchInput, 'bruno{Enter}'); + + await waitFor(() => expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument()); + expect(searchInput).toHaveFocus(); + }); + + it('shows the search term of the current URL again when Back and Forward change ?q=', async () => { + const user = userEvent.setup(); + mockList.mockImplementation((search?: string) => + Promise.resolve( + listOf( + search + ? CLIENTS.filter((client) => client.fullName.toLowerCase().includes(search)) + : CLIENTS, + ), + ), + ); + const router = createMemoryRouter([{ path: '/pessoas', element: }], { + initialEntries: ['/pessoas'], + }); + render(); + await screen.findByText('Ana Souza'); + + const searchInput = screen.getByLabelText(SEARCH_LABEL); + await user.type(searchInput, 'bruno{Enter}'); + await waitFor(() => expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument()); + + await act(() => router.navigate(-1)); + + expect(await screen.findByText('Ana Souza')).toBeInTheDocument(); + expect(searchInput).toHaveValue(''); + + await act(() => router.navigate(1)); + + await waitFor(() => expect(screen.queryByText('Ana Souza')).not.toBeInTheDocument()); + expect(searchInput).toHaveValue('bruno'); + }); +}); diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.tsx new file mode 100644 index 00000000..6c7fab9e --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.tsx @@ -0,0 +1,74 @@ +import { PlusIcon, SearchIcon } from 'lucide-react'; +import { Link } from 'react-router'; +import { buttonVariants } from '@/shared/ui/button'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from '@/shared/ui/input-group'; +import { CLIENT_SEARCH_MAX_LENGTH } from '../../../model/client'; +import { NEW_CLIENT_PATH } from '../../clientsPaths'; +import { ClientListResults } from './components/ClientListResults'; +import { useClientListPage } from './useClientListPage'; + +export function ClientListPage() { + const { + clients, + totalCount, + isTruncated, + status, + failure, + query, + searchInputRef, + onSearchSubmit, + onClearSearch, + onRetry, + } = useClientListPage(); + + return ( +
+
+

Pessoas

+ +
+ +
+ + + + + + Buscar + + +
+ + + + {isTruncated && ( +

+ Exibindo {clients.length} de {totalCount.toLocaleString('pt-BR')} pessoas ativas. +

+ )} +
+ ); +} diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListEmpty.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListEmpty.tsx new file mode 100644 index 00000000..035c1eff --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListEmpty.tsx @@ -0,0 +1,30 @@ +import { Button } from '@/shared/ui/button'; +import { EmptyState } from '@/shared/ui/empty-state'; + +interface ClientListEmptyProps { + query: string; + onClearSearch: () => void; +} + +export function ClientListEmpty({ query, onClearSearch }: ClientListEmptyProps) { + if (query) { + return ( + + Limpar busca + + } + /> + ); + } + + return ( + + ); +} diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListResults.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListResults.tsx new file mode 100644 index 00000000..36e8c916 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListResults.tsx @@ -0,0 +1,44 @@ +import { ErrorState } from '@/shared/ui/error-state'; +import { ListSection, ListSectionStatus } from '@/widgets/list-section'; +import type { UseClientListPageResult } from '../useClientListPage.types'; +import { clientColumns } from './clientColumns'; +import { ClientListEmpty } from './ClientListEmpty'; + +type ClientListResultsProps = Pick< + UseClientListPageResult, + 'status' | 'clients' | 'failure' | 'query' | 'onClearSearch' | 'onRetry' +>; + +export function ClientListResults({ + status, + clients, + failure, + query, + onClearSearch, + onRetry, +}: ClientListResultsProps) { + if (failure) { + return ( + + ); + } + + if (status === ListSectionStatus.Ready && clients.length === 0) { + return ; + } + + return ( + client.id} + aria-label="Pessoas" + columns={clientColumns()} + /> + ); +} diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/OptionalText.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/OptionalText.tsx new file mode 100644 index 00000000..fe11e924 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/OptionalText.tsx @@ -0,0 +1,14 @@ +export function OptionalText({ value }: { value: string | null }) { + if (value === null) { + return ( + <> + + Não informado + + ); + } + + return {value}; +} diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/clientColumns.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/clientColumns.tsx new file mode 100644 index 00000000..7809b9d1 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/clientColumns.tsx @@ -0,0 +1,49 @@ +import { Link } from 'react-router'; +import { maskCpf } from '@/shared/values/cpf'; +import { Badge } from '@/shared/ui/badge'; +import type { ListSectionColumn } from '@/widgets/list-section'; +import type { ClientListItem } from '../../../../model/clientList'; +import { CLIENT_STATUS_LABELS } from '../../../../model/clientStatus'; +import { clientDetailPath } from '../../../clientsPaths'; +import { OptionalText } from './OptionalText'; + +export function clientColumns(): ListSectionColumn[] { + return [ + { + key: 'fullName', + header: 'Nome', + cell: (client) => ( + + {client.fullName} + + ), + }, + { + key: 'phone', + header: 'Telefone', + cell: (client) => , + }, + { + key: 'email', + header: 'E-mail', + cell: (client) => , + }, + { + key: 'cpf', + header: 'CPF', + cell: (client) => , + }, + { + key: 'status', + header: 'Situação', + cell: (client) => ( + + {CLIENT_STATUS_LABELS[client.status]} + + ), + }, + ]; +} diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.ts b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.ts new file mode 100644 index 00000000..1d396048 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useRef, useState, type SubmitEvent } from 'react'; +import { useSearchParams } from 'react-router'; +import { extractErrorMessage } from '@/shared/api/servicesFacade'; +import { ListSectionStatus } from '@/widgets/list-section'; +import { clientsRepository } from '../../../api/clientsRepository'; +import type { LoadResult, UseClientListPageResult } from './useClientListPage.types'; + +export function useClientListPage(): UseClientListPageResult { + const [searchParams, setSearchParams] = useSearchParams(); + const query = searchParams.get('q') ?? ''; + const [result, setResult] = useState(null); + const searchInputRef = useRef(null); + const abortControllerRef = useRef(null); + + const fetchClients = useCallback((forQuery: string) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + void clientsRepository.list(forQuery || undefined, controller.signal).then((apiResult) => { + if (controller.signal.aborted) return; + + if (!apiResult.ok) { + setResult({ + query: forQuery, + status: ListSectionStatus.Error, + clients: [], + totalCount: 0, + problem: apiResult.error, + }); + return; + } + + setResult({ + query: forQuery, + status: ListSectionStatus.Ready, + clients: apiResult.data.items, + totalCount: apiResult.data.totalCount, + problem: null, + }); + }); + }, []); + + useEffect(() => { + fetchClients(query); + return () => { + abortControllerRef.current?.abort(); + }; + }, [query, fetchClients]); + + useEffect(() => { + const input = searchInputRef.current; + if (input && input.value !== query) input.value = query; + }, [query]); + + function onSearchSubmit(event: SubmitEvent) { + event.preventDefault(); + const nextQuery = searchInputRef.current?.value ?? ''; + setSearchParams(nextQuery ? { q: nextQuery } : {}); + } + + const current = result !== null && result.query === query ? result : null; + const clients = current?.clients ?? []; + const totalCount = current?.totalCount ?? 0; + const problem = current?.problem ?? null; + + return { + status: current?.status ?? ListSectionStatus.Loading, + clients, + totalCount, + isTruncated: totalCount > clients.length, + failure: problem && { message: extractErrorMessage(problem), code: problem.code ?? undefined }, + query, + searchInputRef, + onSearchSubmit, + onClearSearch: () => setSearchParams({}), + onRetry: () => { + setResult(null); + fetchClients(query); + }, + }; +} diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.types.ts b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.types.ts new file mode 100644 index 00000000..368f4da0 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.types.ts @@ -0,0 +1,30 @@ +import type { RefObject, SubmitEvent } from 'react'; +import type { ApiProblem } from '@/shared/api/servicesFacade'; +import type { ListSectionStatus } from '@/widgets/list-section'; +import type { ClientListItem } from '../../../model/clientList'; + +export interface ClientListFailure { + message: string; + code: string | undefined; +} + +export interface UseClientListPageResult { + status: ListSectionStatus; + clients: ClientListItem[]; + totalCount: number; + isTruncated: boolean; + failure: ClientListFailure | null; + query: string; + searchInputRef: RefObject; + onSearchSubmit: (event: SubmitEvent) => void; + onClearSearch: () => void; + onRetry: () => void; +} + +export interface LoadResult { + query: string; + status: Exclude; + clients: ClientListItem[]; + totalCount: number; + problem: ApiProblem | null; +} diff --git a/docs/adr/0067-admin-frontend-clients-list.md b/docs/adr/0067-admin-frontend-clients-list.md new file mode 100644 index 00000000..6adb117c --- /dev/null +++ b/docs/adr/0067-admin-frontend-clients-list.md @@ -0,0 +1,69 @@ +# ADR 0067 — admin-frontend: the Pessoas list + +Status: accepted (2026-10); consumes the contract of [ADR 0063](0063-clients-list-and-search-contract.md), follows +[ADR 0033](0033-admin-frontend-no-request-cancellation-layer.md) and +[ADR 0035](0035-admin-frontend-no-server-state-library.md) + +## Context + +#155 asks for the **Pessoas** screen: a list with a search box, a situation filter, paging, empty/loading/error states +and a situation indicator. Its backend half shipped first (ADR 0063, #176), as agreed for full-stack features. Four +points of the issue text disagree with that contract or with the state of the app, and the owner decided each on +2026-10-13, before any code: + +- the issue says "Mascarar CPF na lista", and #139 says "CPF não aparece integralmente em listagens", but ADR 0063 + records the owner's decision that the CPF comes whole from the API; +- the issue names the search parameter `q`; the contract calls it `search`; +- the detail screen the names must lead to (#167) does not exist in the frontend yet; +- `shared/ui` has no select and no pagination control, and the owner wants to define those controls separately. + +## Decision + +1. **The CPF is shown whole and formatted** (`529.982.247-25`), with `maskCpf` from `shared/values/cpf`. Despite its + name (the typing-time mask of the form field) it hides nothing. This follows ADR 0063 and overrides the text of #155 + and the criterion of #139. +2. **The screen reads `?q=` from the URL, as the tags list does, and sends `Search`** to the API. The repository takes + only the search term; the backend defaults (`status=active`, `page=1`, `pageSize=20`) apply. +3. **The situation filter and the paging controls are not part of this change.** They arrive with the controls the owner + will define. Until then the screen lists the first page of active people and, when there are more, + says so: "Exibindo 20 de 87 pessoas ativas." (a status line, never silent). `status` and `page` enter the repository, + the URL and the hook together with their controls, not before. +4. **Columns:** Nome, Telefone, E-mail, CPF, Situação. A missing phone, e-mail or CPF is a dash with "Não informado" for + assistive tech. The situation is a `Badge` ("Ativa", "Inativa"); labels live in `model/clientStatus.ts`. +5. **Each name links to `/pessoas/:id`**, which renders a provisional "Em breve" page (`app/pages/ClientDetails.tsx`) + until #167 replaces it. The link already points at its final URL. +6. **Two empty states and a failure, as the copy rules ask**: nobody active yet ("Nenhuma pessoa ativa ainda", no button, + the header already has **Nova pessoa**) and a search that found nobody ("Nenhuma pessoa encontrada para «ana»" with + **Limpar busca**). The page builds them with `EmptyState`/`ErrorState` and gives `list-section` only the loading + and ready-with-rows states, the way `TagListPage` already does for failures. A failure shows the problem's message, + its code and **Tentar novamente**. +7. **The repository maps the total and nothing else.** The wire `totalCount` is `number | string`, so verbatim + forwarding does not compile; `list` returns `{ items, totalCount }` with the total as a `number`, the items + untouched, and `page`/`pageSize` dropped (nothing reads them yet). +8. **Nothing is extracted yet.** The search form and the URL/abort-controller logic of `useClientListPage` are the + second copy of `TagListPage`'s. They are extracted into a `widgets/search-form` (and a shared pattern for the hook) + when a third list needs them, the rule ARCHITECTURE §6 already states for `repeatable-list`. + +## Consequences + +- Acceptance criteria of #155 **not met by this change**: "cadastro inativo aparece quando o filtro o inclui", "alterar + busca/filtro reinicia na página 1" for the filter, and "navegação entre páginas não repete nem omite registros". The + inactive badge is built and tested, but no screen can show an inactive person until the filter exists, and a person + past the 20th active one is reachable only through the search. The status line makes that visible. +- Criteria met: list, total (as the status line), search by name, e-mail or CPF with or without the mask, the loading, + empty and failure states, the situation indicator, the CPF, and navigation to the detail URL. +- The issues #155 and #139 still say "mascarar" and `q`; their text needs the same update ADR 0063 already asks for. + This change does not edit the issues. +- A search over 254 characters never reaches the API: the field has `maxLength` 254, the backend's limit. +- Whole CPFs for up to 20 people are on screen, a volume that ADR 0063 accepted for the API and the screen inherits. + +## Considered and rejected + +- **Masking in the browser** (`***.982.247-**`). It meets the issue text, but the whole CPF stays in the response and + the network tab, so the mask would only be cosmetic, and it contradicts the owner's decision recorded in ADR 0063. +- **No CPF column.** Receptionists could not tell people with the same name apart. +- **A native `