diff --git a/.claude/skills/agenza-api-contract/SKILL.md b/.claude/skills/agenza-api-contract/SKILL.md
index ca9cba5d..b1970b26 100644
--- a/.claude/skills/agenza-api-contract/SKILL.md
+++ b/.claude/skills/agenza-api-contract/SKILL.md
@@ -87,9 +87,11 @@ removed the harness) — the next one sets the precedent. The illustrative shape
- Add a `toDomain(dto)` mapper only when wire and domain genuinely diverge; then transform the `ok`
branch and forward the failure object unchanged.
-> Numeric fields generate as `number | string` (the backend accepts numbers from strings). If your
-> entity declares `price: number`, verbatim forwarding **will not compile** and you need a
-> `toDomain`. That is the mapper's most common real trigger, not a style choice.
+> A numeric field generates as `number`, although the OpenAPI document says `integer | string`: ASP.NET
+> also reads numbers from text but only ever writes a number, and `numberAcceptedAsText` in
+> `scripts/generateApiTypes.mjs` narrows it. An entity that declares `price: number` therefore forwards
+> verbatim. A `toDomain` is needed only when wire and domain genuinely diverge (a renamed field, a
+> different structure), not for a number.
## Where a `Result` becomes a rejection
diff --git a/apps/admin-frontend/docs/ARCHITECTURE.md b/apps/admin-frontend/docs/ARCHITECTURE.md
index 60470eab..ffd67adc 100644
--- a/apps/admin-frontend/docs/ARCHITECTURE.md
+++ b/apps/admin-frontend/docs/ARCHITECTURE.md
@@ -309,28 +309,27 @@ session means for navigation is deliberately not this hook's problem. That is **
expired session being handled somewhere else in the app — right now it isn't; §6 tracks it as an
open gap.
-**`list-section` renders its own empty and error states — neither is a prop the page configures.**
-Once `status` says `'ready'`, it checks `items.length` itself and shows one fixed "Nenhum item
-encontrado." instead of the rows or table; inferring emptiness this way is safe here specifically
-because the page has already confirmed success via `status` first — unlike inferring "empty" before
-knowing whether the fetch even succeeded, which is the original bug this component exists to
-prevent. `status === 'error'` renders one fixed "Não foi possível carregar." the same way, with no
-retry control at all.
-
-This is a deliberate departure from ADR 0020's failure standard (a stable code, a curated
-explanation, a recovery action) — a generic, reusable table component doesn't know a per-feature
-error message, code, or what "retry" should even do, so `list-section` doesn't attempt any of it.
-The cost is real and explicit: a failed `list-section` load has no code, no specific explanation,
-and no way to recover short of a full page reload; a "never created" catalog and a "search matched
-nothing" empty case now read identically, and there's no action slot for a per-feature "limpar
-busca". A feature that needs ADR 0020's fuller treatment builds it directly with
-`shared/ui/error-state` (the same primitive `list-section` uses internally) instead of routing it
-through `list-section`'s `status` prop. `TagListPage` does exactly that: on a failure it renders
-`ErrorState` itself — "Não foi possível carregar as etiquetas.", the problem's message and `code`,
-and **Tentar novamente**, which drops the failed result (back to the skeleton) and refetches the same
-query — and hands `list-section` only the loading and ready states (§5). Search stays page-owned too: `toolbar` is not a
-`list-section` prop, so a search box sits beside the component in the page's own markup, not
-inside it.
+**`list-section` decides _when_ to show the empty and the error state, and carries the variants every list has; the page
+supplies the words.** Once `status` says `'ready'`, it checks `items.length` itself; inferring emptiness this way is
+safe here specifically because the page has already confirmed success via `status` first — unlike inferring "empty"
+before knowing whether the fetch even succeeded, which is the original bug this component exists to prevent. An empty
+list is one of two states, chosen by the optional `search` prop (`{ term, onClear }`): with no term, "nothing yet",
+with the `title` and `description` of `emptyCopy`; with a term, "the search found nothing", with `searchTitle(term)`,
+`searchDescription` and a **Limpar busca** button that calls `onClear`. Every field of `emptyCopy` is optional and falls
+back to generic text ("Nenhum item encontrado.", "Nenhum item encontrado para «x»"). The copy is data the page passes
+because it cannot be derived: the noun, its gender ("Nenhuma pessoa", "Nenhum serviço"), a qualifier such as "ativa",
+and a hint like "busque pelo e-mail ou pelo CPF" belong to the feature. `status === 'error'` shows the error state the
+same way: the page passes `errorTitle` ("Não foi possível carregar as pessoas.") and `failure`
+(`{ message, code, onRetry }`, the problem's message and code), and the **Tentar novamente** button appears only when
+`onRetry` is given. Both are optional.
+
+The defaults are a deliberate departure from ADR 0020's failure standard (a stable code, a curated explanation, a
+recovery action) — a generic, reusable table component doesn't know a per-feature error message, code, or what
+"retry" should even do, so on its own it attempts none of it: without `errorTitle` and `failure` it shows "Não foi
+possível carregar." with no code and no way to recover short of a full page reload. A feature that needs the fuller
+treatment passes it in; `ClientListPage` and `TagListPage` both do. Search stays
+page-owned too: `toolbar` is not a `list-section` prop, so a search box sits beside the component in the page's own
+markup, not inside it.
Full wiring detail: [`contracts/api-client-contract.md`](../specs/001-oidc-shell-scaffold/contracts/api-client-contract.md).
Exemplos reais de request/response — sucesso, validação, conflito, 404, autenticação/tenant —
@@ -432,13 +431,17 @@ Chosen, and — just as important — tried and backed out of, so nobody re-liti
| `cpf.ts`, `birthDate.ts` and `contactFormats.ts` moved from `features/clients/model/` to `shared/format/`, next to `date.ts` (tests moved with them) | Asked for: none of them is specific to a client. A CPF identifies any person (the guardian's already uses it), a birth date is a person attribute any future module can carry, phone and e-mail are contact data any module can hold, and the backend already treats all of them as shared kernel (`CpfNumber`, `BirthDate`, `PhoneNumber` and `EmailAddress` in `Admin.SharedKernel.ValueObjects`). `birthDate.ts` already depended on `shared/format/date.ts`, so it now sits beside it. Pure move: no export was renamed. `birthDate.ts` is the one place where `shared/` holds a policy rather than a format (the 18 and 120 limits mirror the backend's), accepted because the backend keeps the same limits in its own shared kernel and a second copy per module would drift. `contactFormats.ts` was split into `phone.ts` and `email.ts` right after, one per backend value object (next row). |
| A value more than one form can carry (CPF, phone, e-mail, birth date) is reused through three layers: `shared/format/` parses it, `shared/form/schemas/` turns that into a Zod field, `shared/form/fields/` renders it | Asked for: using a CPF meant assembling two unrelated halves by hand, a Zod fragment written in the slice's `clientForm.ts` (trim, refine, strip, empty to `null`, the pt-BR message) and a `MaskedField` repeating `mask`, `inputMode`, `placeholder` and `autoComplete`; the phone input, repeated three times, had its placeholder in only one of them. (1) `parseCpf`, `parsePhone` and `parseEmail` return the canonical value or `null`, mirroring the backend's `Create` (trim, strip, lowercase and validate in one step), so a caller cannot validate without normalising; the old `isValidX` plus `stripX` left the trim to the caller, which differed from the backend. `maskCpf` is the typing-time mask, named after `maskDate`. (2) `cpfSchema`, `phoneSchema`, `emailSchema` and `birthDateSchema` are built with `optionalValue(parse)`, which owns the empty-field-is-`null` rule and turns `fail(message)` into a Zod issue, so a new value type (a CNPJ) is one `parse` function and one line; `shared/` imports Zod for the first time there. The e-mail schema checks the length itself, before `parseEmail`, to keep the backend's two distinct messages without reporting the same address twice. (3) `CpfField`, `PhoneField`, `EmailField` and `BirthDateField` pin the mask, input type, limit, placeholder, label and `opcional` hint and let the call site override any of them; `BirthDateField` also owns the calendar bounds and the age hint. A call site is ``. The layers stay apart because `model/` has no React: a schema can be imported from a model file, a field only from `ui/`. The guardians section's own `age < 18` comparison was replaced, so the minor rule exists once (`isMinorOn`), and the "a minor needs a guardian" rule built on it lives in the slice's model (`needsGuardian`, two rows below); `formatAge` moved to `shared/format/age.ts`. These presets are the exception to "fields have no domain knowledge": they know a value's format, never an entity. Not built: a required variant of the schemas, since nothing needs one yet; `optionalValue` is where it would go. |
| Calendar dates are `Date` at midnight UTC, from the form schema to the repository; text exists only where it is shown (`dd/mm/aaaa`, via `formatMaskedDate`) | Asked for: every date reference was a `string`, so an ISO date, a masked date and any other text were the same type. `utcDate(year, month, day)` is the one constructor (1-based month); comparisons, arithmetic and display read `getUTC*`, so the day never shifts with the device's zone. No code reads the device's zone: `todayUtc` is the same UTC calendar day the backend uses (ADR 0045), so a date the form accepts is one the API accepts. `DateField` passes `timeZone="UTC"` to the calendar. `clientsRepository.create` is a pass-through: `createApiClient`'s `bodySerializer` (`serializeBody`) writes **every** `Date` in a request body as `yyyy-MM-dd`, its UTC calendar day, which is what the backend's `DateOnly` converter reads (`DateValueObjectJsonConverter` answers 400 to an instant such as `1990-05-20T00:00:00.000Z`). So a `Date` on the wire always means a calendar day; an instant (`format: date-time`) is never mapped and travels as the ISO `string` it is typed as. That typechecks because `scripts/generateApiTypes.mjs` maps `format: date` to `Date` **only in schemas reached solely from request bodies**, following `$ref`s from each operation's `requestBody`; a schema a response or a parameter also uses stays `string`, since JSON cannot return a `Date` and only the body goes through `serializeBody`. That set was first picked by name (`*Command`, `*Input`), which would have left a `*Request` DTO as `string` without a word; it is derived from the document now, and the generated file came out byte-identical; `scripts/generateApiTypes.test.mjs` pins the rule against a fixture document. `Client.birthDate` is the response's `string | null`; the first screen that reads it converts it in its repository (`new Date(isoDate)` is midnight UTC). Not branded strings and not a `{ year, month, day }` object: the native type was asked for and the calendar library already speaks it. |
+| The type generator emits `number` for a numeric field that ASP.NET also reads from text (`numberAcceptedAsText` in `scripts/generateApiTypes.mjs`) | ASP.NET publishes every integer and decimal as `integer \| string` or `number \| string` with a numeric `pattern`, because it also reads numbers from text, but it only ever writes a JSON number. The generated type was therefore `number \| string` in 27 places (`totalCount`, `page`, `pageSize`, `price`, `durationMinutes`, `ApiProblem.status`, the `Page` and `PageSize` query parameters), and a hand-written entity that says `number` could not be forwarded verbatim: `clientsRepository.list` would have needed a mapper only to call `Number()`. The rule applies to any schema whose `type` list holds `string` and `integer` or `number`, keeps `null` (`null \| number`), and covers requests, responses and parameters alike, since the typed client only ever sends numbers. It composes with the `Date` rule through `firstTransformThatApplies`. `generateApiTypes.test.mjs` pins it on the fixture: an integer, a decimal, a nullable integer and a query parameter become `number`, while a plain integer and a nullable string are left alone. One test moved with it: `servicesFacade.test.ts` pinned a `status` sent as text, which the type no longer allows, so it now casts explicitly (`isTransientProblem` still runs `Number(problem.status)`). Regenerate with `npm run generate:api-types`. |
| `ClientGuardiansSection` and `ClientReferenceContactsSection` own their `useFieldArray` (control from `useFormContext`) instead of receiving `fieldIds`/`onAdd`/`onRemove` from `useClientFormPage` | Asked for. The page hook created both arrays and handed them down as `fields.map((field) => field.id)` plus callbacks, so a list's name, limit, empty factory and item paths were split across hook, section and item, the page did data shaping in its JSX, and every append/remove re-rendered the whole page. The sections already sit inside `FormProvider`, so taking `control` from it adds no coupling; item identity is `key={field.id}`, and the list-level error comes from `useFormState({ control, name })` rather than `useFormContext().formState.errors`, because the latter is the page's own `useForm` proxy (reading it re-renders the page on every error change) while `useFormState` keeps its state and subscription local to the section. `UseClientFormPageResult` lost `guardians`, `referenceContacts`, `addGuardian`, `addReferenceContact`, `age` and `isMinor` (the last two were pure derivations of `birthDate` that only the person and guardians sections consumed, so each now `useWatch`es `birthDate` itself instead of the page re-rendering on every keystroke of the date), so none of the three sections takes props. The hook's `onInvalid` and `addGuardianButtonRef` were removed too, on request: they focused the guardians' add button when the missing guardian was the only error, because RHF cannot focus a list-level error (`setFocus` needs a registered ref with `focus`; `useFieldArray` registers the array root only when given `rules`, and with a placeholder `{ name }` ref). A submit that fails only on the guardian now leaves focus on **Salvar** and the section's `role="alert"` is the signal; failures on inputs still focus the first invalid input through RHF's own `shouldFocusError`. There must be exactly one `useFieldArray` per name: keeping one in the hook as well would put two instances over the same state. |
| An aggregate's limits and invariants live in the slice's `model/`, beside its types; the Zod schema, its pt-BR messages and the empty form values live in the page that owns the form (`ui/pages//Form.ts`) | Asked for, in review of #174. Both form schemas had already moved from `model/Form.ts` into their pages (`tagForm.ts`, `clientForm.ts`), but the numbers and rules went with them: the limits mirrored from `Client`/`ClientContact` and the "a minor needs a guardian" invariant sat in `ClientFormPage/clientForm.ts`, and the invariant was written twice with two APIs, the schema's `refine` (`isMinorOn` over a `Date`) and the guardians section's notice (`isMinorBirthDate` over the masked text, a non-schema export of `shared/form/schemas`). The next readers of the rule (the list in #155, a detail page, editing in #156) would have imported from inside a page or copied it. Now `features/clients/model/client.ts` holds `CLIENT_FULL_NAME_MIN_LENGTH`/`_MAX_LENGTH`, `CLIENT_NOTES_MAX_LENGTH`, `MAX_GUARDIANS`, `MAX_REFERENCE_CONTACTS` and `needsGuardian(birthDate, guardianCount, today = todayUtc())`, the same check as the backend's `Client` (`birthDate.IsMinorOn(today) && guardianCount == 0`), and `model/clientContact.ts` holds the contact's name and relationship limits, which only happen to equal the person's today (`ClientContact` and `FullName` are separate in the backend). The schema composes `needsGuardian`; the section calls it on `parseMaskedDate(birthDate)`, which gives the same answer the schema-based check gave, since `isMinorOn` already requires a past date and an age under 18; `isMinorBirthDate` was deleted. `model/` stays free of React, Zod and copy: numbers and pure functions over the plain types, not entity classes, which every repository would have to hydrate. The limits are a UX pre-check; the backend's response stays the source of truth. Tags got the same move (`TAG_NAME_MAX_LENGTH`, `TAG_DESCRIPTION_MAX_LENGTH` in `model/tag.ts`). `FullName` and `AdministrativeNotes` are shared kernel in the backend; their limits stay in the clients model until a second slice needs them, then move to `shared/` as the CPF did. |
| `widgets/form-footer` exports `DialogFormFooter` and `PageFormFooter` instead of one `FormFooter` that switched on `cancelTo` | Asked for, in review of #174. Passing `cancelTo` or not chose between two different footers, `DialogClose` inside `DialogFooter` or a `Link` back that becomes a disabled button while saving, so `` in `TagFormPage` read as generic while it only works inside a `Dialog`, and a page that forgot `cancelTo` silently got a dialog footer. Each footer now names where it belongs and `PageFormFooter` requires `cancelTo`; the **Salvar**/**Salvando…** button both render is the folder's internal `FormSubmitButton`. One folder, because it is one concept with two placements. |
| 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 `ListSection`, with `clientColumns` (Nome, Telefone, E-mail, CPF, Situação) and, through `list-section`, `errorTitle` and `failure` for the error state, plus `search` and `emptyCopy` for the two empty states. 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 (20 people) with no sign that there are more (a "Exibindo N de M" line was built and then removed), 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 that `list-section` itself renders from the page's `emptyCopy` and `search` instead of its fixed message: nobody yet (no button, the header already has **Nova pessoa**) and a search that found nobody (**Limpar busca**). `list` forwards the whole page verbatim, like `create` (`{ items, totalCount, page, pageSize }`); the model types the three paging fields as `number`, and nothing reads them yet. 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. |
+| `shared/ui/optional-text`: one way to show a value that may be missing, with an optional `fallback` | Asked for, right after the Pessoas list, when its page-local `OptionalText` (a dash hidden from assistive tech plus "Não informado" for it) turned out to be a second spelling of what `tagColumns` already wrote by hand for the description ("Sem descrição", faded italic, nothing for a screen reader). Nothing in `shared/ui`, `widgets` or `shared/format` covered it: `EmptyState` is a block for an empty list and `shared/values/optionalValue` parses an optional form field. One component, two looks: with no `fallback` it shows the dash, so a table of fixed-format values (phone, e-mail, CPF) stays aligned and a screen reader still hears that the value is missing; with a `fallback` it shows that text, visible, faded and italic, for a free-text field where a word reads better than a dash. `className` goes to every look, so the caller decides things the component cannot know: `whitespace-nowrap` for a phone, `text-sm text-muted-foreground` for a description (the old Pessoas copy forced `nowrap`, which would have been wrong for free text). Only `null` and `undefined` count as missing, as `tag.description ?? 'Sem descrição'` already did. Pessoas and Etiquetas both use it; the Etiquetas tests are unchanged and prove the text stayed. Not excluded from the coverage gate: it has a branch per look and its own test asserts each one. |
+| `list-section` gains the error state and the two empty variants every list has, driven by `errorTitle` and `failure` (`{ message, code, onRetry }`), `search` (`{ term, onClear }`) and `emptyCopy`; `ClientListResults` and `ClientListEmpty` removed | Asked for, right after the Pessoas list, in three steps. First, the same "ready and zero items" condition was written twice (`ListSectionReady` for the fixed message, `ClientListResults` for the page's own), so `list-section` got `empty` and `error` slots. Then the empty slot was folded into the widget: "nothing yet" versus "the search found nothing, with **Limpar busca**" is behaviour every list has, and Tags would otherwise write a second copy. Then the error slot got the same treatment: the page was building an `ErrorState` from the failure's message, code and retry, which every list that can fail repeats. The widget still decides _when_ (`status` and `items.length`, the check that must not run before the fetch is known to have succeeded), and now _which variant_ (a non-empty `search.term`) and how a failure is laid out; the page supplies the words as ready phrases, not a noun plus a gender, because the copy cannot be derived ("Nenhuma pessoa" against "Nenhum serviço", "ativa", the e-mail and CPF hint) and a grammar helper would still need the same escape hatches. Every field is optional, so a list that passes nothing keeps "Nenhum item encontrado." and "Não foi possível carregar.". `TagListPage` moved onto `errorTitle`, `failure`, `search` and `emptyCopy` right after Pessoas ("Nenhuma etiqueta ainda"; "Nenhuma etiqueta encontrada para «x»" with **Limpar busca**), so its own `ErrorState` ternary and the generic empty message are gone, and `onClearSearch` joined its hook. Both slots were removed once the built-in variants left them without a reader, which §1 treats as dead API. This reverses the §2 sentence that neither state is a prop the page configures, and the cost it listed (no action for a per-feature "Limpar busca", no retry on a failed load). Known boundary: a status filter will make "nothing yet" depend on the filter ("Nenhuma pessoa inativa ainda"), which the page can express through `emptyCopy.title`; clearing a filter, as opposed to a search, is not covered yet. Tests assert each effect: copy and details replace the defaults in their own state, `onClear` and `onRetry` fire, the retry button exists only with an `onRetry`, and nothing shows while loading or ready. |
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 +467,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 +498,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 only the first page and nothing says there are more (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/scripts/generateApiTypes.mjs b/apps/admin-frontend/scripts/generateApiTypes.mjs
index 14bbc9bb..a83acd1e 100644
--- a/apps/admin-frontend/scripts/generateApiTypes.mjs
+++ b/apps/admin-frontend/scripts/generateApiTypes.mjs
@@ -95,10 +95,39 @@ function requestDateAsDate(requestOnly) {
};
}
+// ASP.NET publishes every number as `integer | string` (or `number | string`) because it also reads
+// numbers from text, but it only ever writes a JSON number, so the TypeScript type is `number`.
+function numberAcceptedAsText(schemaObject) {
+ const types = schemaObject.type;
+ if (!Array.isArray(types) || !types.includes('string')) return undefined;
+ if (!types.includes('integer') && !types.includes('number')) return undefined;
+
+ const number = ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
+ if (!types.includes('null')) return number;
+
+ return ts.factory.createUnionTypeNode([
+ ts.factory.createLiteralTypeNode(ts.factory.createNull()),
+ number,
+ ]);
+}
+
+function firstTransformThatApplies(...transforms) {
+ return (schemaObject, options) => {
+ for (const transform of transforms) {
+ const node = transform(schemaObject, options);
+ if (node !== undefined) return node;
+ }
+ return undefined;
+ };
+}
+
export async function typesFromDocument(openApiDocument) {
const document = stripTenantHeaderParam(openApiDocument);
const ast = await openapiTS(document, {
- transform: requestDateAsDate(requestBodyOnlySchemas(document)),
+ transform: firstTransformThatApplies(
+ requestDateAsDate(requestBodyOnlySchemas(document)),
+ numberAcceptedAsText,
+ ),
});
return astToString(ast);
}
diff --git a/apps/admin-frontend/scripts/generateApiTypes.test.mjs b/apps/admin-frontend/scripts/generateApiTypes.test.mjs
index 80122c92..99df0b83 100644
--- a/apps/admin-frontend/scripts/generateApiTypes.test.mjs
+++ b/apps/admin-frontend/scripts/generateApiTypes.test.mjs
@@ -26,6 +26,7 @@ function openApiDocument() {
get: {
parameters: [
{ name: 'filter', in: 'query', schema: { $ref: '#/components/schemas/AgendaFilter' } },
+ { name: 'Page', in: 'query', schema: { type: ['integer', 'string'], format: 'int32' } },
],
responses: ok('PersonResponse'),
},
@@ -51,8 +52,15 @@ function openApiDocument() {
},
PersonResponse: {
type: 'object',
- required: ['birthDate'],
- properties: { birthDate: { type: ['null', 'string'], format: 'date' } },
+ required: ['birthDate', 'visits', 'balance', 'rank', 'age', 'nickname'],
+ properties: {
+ birthDate: { type: ['null', 'string'], format: 'date' },
+ visits: { type: ['integer', 'string'], format: 'int32', pattern: '^-?(?:0|[1-9]\d*)$' },
+ balance: { type: ['number', 'string'], format: 'double' },
+ rank: { type: ['null', 'integer', 'string'], format: 'int32' },
+ age: { type: 'integer', format: 'int32' },
+ nickname: { type: ['null', 'string'] },
+ },
},
EventBody: {
type: 'object',
@@ -108,4 +116,32 @@ describe('typesFromDocument', () => {
expect(typeOf(types, 'CreatePersonRequest', 'createdAt')).toBe('string');
});
+
+ it.each([
+ ['an integer', 'visits', 'number'],
+ ['a decimal', 'balance', 'number'],
+ ['a nullable integer', 'rank', 'null | number'],
+ ])(
+ 'types %s that ASP.NET also reads from text as a plain number',
+ async (_, property, expected) => {
+ const types = await typesFromDocument(openApiDocument());
+
+ expect(typeOf(types, 'PersonResponse', property)).toBe(expected);
+ },
+ );
+
+ it('types a numeric query parameter as a plain number', async () => {
+ const types = await typesFromDocument(openApiDocument());
+
+ expect(types).toMatch(/\bPage\?: number;/);
+ });
+
+ it.each([
+ ['a plain integer', 'age', 'number'],
+ ['a nullable string', 'nickname', 'null | string'],
+ ])('leaves %s as it was', async (_, property, expected) => {
+ const types = await typesFromDocument(openApiDocument());
+
+ expect(typeOf(types, 'PersonResponse', property)).toBe(expected);
+ });
});
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.
-
);
@@ -28,4 +41,9 @@ function ListSection(props: ListSectionProps) {
export { ListSection };
export { ListSectionStatus } from './list-section.types';
-export type { ListSectionColumn, ListSectionProps } from './list-section.types';
+export type {
+ ListSectionColumn,
+ ListSectionEmptyCopy,
+ ListSectionFailure,
+ ListSectionProps,
+} from './list-section.types';
diff --git a/apps/admin-frontend/src/widgets/list-section/list-section.test.tsx b/apps/admin-frontend/src/widgets/list-section/list-section.test.tsx
index e1dc5a93..bfa85583 100644
--- a/apps/admin-frontend/src/widgets/list-section/list-section.test.tsx
+++ b/apps/admin-frontend/src/widgets/list-section/list-section.test.tsx
@@ -1,5 +1,6 @@
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { ListSection } from './index';
interface Item {
@@ -76,6 +77,169 @@ describe('ListSection', () => {
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
+ describe('empty copy and search', () => {
+ const COPY = {
+ title: 'Nenhum cadastro ainda',
+ description: 'Use o botão Novo para cadastrar o primeiro.',
+ searchTitle: (term: string) => `Nada encontrado para «${term}»`,
+ searchDescription: 'Confira a grafia.',
+ };
+
+ function renderEmpty(props: {
+ emptyCopy?: typeof COPY;
+ search?: { term: string; onClear: () => void };
+ status?: 'ready' | 'loading';
+ items?: Item[];
+ }) {
+ const { status = 'ready', items = [] } = props;
+ return render(
+ item.id}
+ renderItem={(item) => item.name}
+ emptyCopy={props.emptyCopy}
+ search={props.search}
+ aria-label="Itens"
+ />,
+ );
+ }
+
+ it('shows the title and description given for an empty list nobody searched', () => {
+ renderEmpty({ emptyCopy: COPY, search: { term: '', onClear: vi.fn() } });
+
+ expect(screen.getByText('Nenhum cadastro ainda')).toBeInTheDocument();
+ expect(screen.getByText('Use o botão Novo para cadastrar o primeiro.')).toBeInTheDocument();
+ expect(screen.queryByText('Nenhum item encontrado.')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Limpar busca' })).not.toBeInTheDocument();
+ });
+
+ it('names the searched term, with its description and a way to clear it, when a search finds nothing', () => {
+ renderEmpty({ emptyCopy: COPY, search: { term: 'ana', onClear: vi.fn() } });
+
+ expect(screen.getByText('Nada encontrado para «ana»')).toBeInTheDocument();
+ expect(screen.getByText('Confira a grafia.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Limpar busca' })).toBeInTheDocument();
+ expect(screen.queryByText('Nenhum cadastro ainda')).not.toBeInTheDocument();
+ });
+
+ it('calls onClear when "Limpar busca" is pressed', async () => {
+ const onClear = vi.fn();
+ renderEmpty({ emptyCopy: COPY, search: { term: 'ana', onClear } });
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Limpar busca' }));
+
+ expect(onClear).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to generic copy for a search that finds nothing when no copy is given', () => {
+ renderEmpty({ search: { term: 'ana', onClear: vi.fn() } });
+
+ expect(screen.getByText('Nenhum item encontrado para «ana»')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Limpar busca' })).toBeInTheDocument();
+ });
+
+ it('shows neither variant while there are items or before the list is ready', () => {
+ const search = { term: 'ana', onClear: vi.fn() };
+ const { rerender } = renderEmpty({ emptyCopy: COPY, search, items: ITEMS });
+ expect(screen.queryByText('Nada encontrado para «ana»')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Limpar busca' })).not.toBeInTheDocument();
+
+ rerender(
+ item.id}
+ renderItem={(item) => item.name}
+ emptyCopy={COPY}
+ search={search}
+ aria-label="Itens"
+ />,
+ );
+ expect(screen.queryByText('Nada encontrado para «ana»')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Limpar busca' })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('error title and failure', () => {
+ const FAILURE = {
+ message: 'O servidor está instável.',
+ code: 'Server.Unavailable',
+ };
+
+ function renderError(props: {
+ errorTitle?: string;
+ failure?: { message: string; code?: string; onRetry?: () => void } | null;
+ status?: 'error' | 'loading' | 'ready';
+ items?: Item[];
+ }) {
+ const { status = 'error', items = [] } = props;
+ return render(
+ item.id}
+ renderItem={(item) => item.name}
+ errorTitle={props.errorTitle}
+ failure={props.failure}
+ aria-label="Itens"
+ />,
+ );
+ }
+
+ it('shows the given title in place of the generic one', () => {
+ renderError({ errorTitle: 'Não foi possível carregar as pessoas.' });
+
+ const alert = screen.getByRole('alert');
+ expect(alert).toHaveTextContent('Não foi possível carregar as pessoas.');
+ expect(alert).not.toHaveTextContent('Não foi possível carregar.');
+ });
+
+ it('shows the failure message and code under the title', () => {
+ renderError({ errorTitle: 'Não foi possível carregar as pessoas.', failure: FAILURE });
+
+ const alert = screen.getByRole('alert');
+ expect(alert).toHaveTextContent('O servidor está instável.');
+ expect(alert).toHaveTextContent('Código: Server.Unavailable');
+ });
+
+ it('offers "Tentar novamente" only when the failure has a retry, and calls it', async () => {
+ const onRetry = vi.fn();
+ const { unmount } = renderError({ failure: FAILURE });
+ expect(screen.queryByRole('button', { name: 'Tentar novamente' })).not.toBeInTheDocument();
+ unmount();
+
+ renderError({ failure: { ...FAILURE, onRetry } });
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Tentar novamente' }));
+
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the generic title with no details when no failure is given', () => {
+ renderError({ failure: null });
+
+ expect(screen.getByRole('alert')).toHaveTextContent('Não foi possível carregar.');
+ expect(screen.queryByText(/Código:/)).not.toBeInTheDocument();
+ });
+
+ it('shows nothing of it while loading or when ready', () => {
+ const { rerender } = renderError({ status: 'loading', failure: FAILURE });
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+
+ rerender(
+ item.id}
+ renderItem={(item) => item.name}
+ failure={FAILURE}
+ aria-label="Itens"
+ />,
+ );
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+ });
+
it('renders one listitem per item via renderItem in list mode', () => {
render(
=
| { renderItem: (item: T) => ReactNode; columns?: never }
| { columns: ListSectionColumn[]; renderItem?: never };
+interface ListSectionEmptyCopy {
+ title?: string | undefined;
+ description?: string | undefined;
+ searchTitle?: ((term: string) => string) | undefined;
+ searchDescription?: string | undefined;
+}
+
+interface ListSectionSearch {
+ term: string;
+ onClear: () => void;
+}
+
+interface ListSectionFailure {
+ message: string;
+ code?: string | undefined;
+ onRetry?: (() => void) | undefined;
+}
+
type ListSectionProps = ListSectionRenderMode & {
status: ListSectionStatus;
items: readonly T[];
getKey: (item: T) => string;
skeletonRowCount?: number | undefined;
+ emptyCopy?: ListSectionEmptyCopy | undefined;
+ search?: ListSectionSearch | undefined;
+ errorTitle?: string | undefined;
+ failure?: ListSectionFailure | null | undefined;
'aria-label': string;
className?: string | undefined;
};
@@ -40,6 +62,18 @@ interface ListSectionItemsProps {
interface ListSectionReadyProps extends ListSectionItemsProps {
renderMode: ListSectionRenderMode;
+ emptyCopy?: ListSectionEmptyCopy | undefined;
+ search?: ListSectionSearch | undefined;
+}
+
+interface ListSectionEmptyProps {
+ copy?: ListSectionEmptyCopy | undefined;
+ search?: ListSectionSearch | undefined;
+}
+
+interface ListSectionErrorProps {
+ title?: string | undefined;
+ failure?: ListSectionFailure | null | undefined;
}
interface ListSectionTableProps extends ListSectionItemsProps {
@@ -52,6 +86,11 @@ interface ListSectionListProps extends ListSectionItemsProps {
export type {
ListSectionColumn,
+ ListSectionEmptyCopy,
+ ListSectionEmptyProps,
+ ListSectionErrorProps,
+ ListSectionFailure,
+ ListSectionSearch,
ListSectionProps,
ListSectionRenderMode,
ListSectionSkeletonProps,
diff --git a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs
index 12f6cc44..ed2c2e6e 100644
--- a/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs
+++ b/backend/services/services-service/ServicesService.Application/Clients/ListClients/ListClientsQuery.cs
@@ -4,7 +4,7 @@ namespace ServicesService.Application.Clients.ListClients;
public sealed record ListClientsQuery(
SearchText? Search = null,
- ClientStatusFilter? Status = ClientStatusFilter.Active) : PagedQuery, IQuery>
+ ClientStatusFilter? Status = null) : PagedQuery, IQuery>
{
public ClientListFilter ToFilter()
{
@@ -33,7 +33,7 @@ public ClientListFilter ToFilter()
private static ClientStatus? ToClientStatus(ClientStatusFilter? status)
{
- if (status == ClientStatusFilter.All)
+ if (status is null or ClientStatusFilter.All)
{
return null;
}
diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs
index dc50ec69..468ded1d 100644
--- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs
+++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryHandlerTests.cs
@@ -134,7 +134,7 @@ public async Task Handle_EchoesThePageTheTotalAndThePageSize()
result.Value.PageSize.Should().Be(2);
result.Value.TotalCount.Should().Be(7);
result.Value.Items.Should().HaveCount(2);
- await AssertListed(null, null, ClientStatus.Active, page: 2, pageSize: 2);
+ await AssertListed(null, null, null, page: 2, pageSize: 2);
}
[Fact]
@@ -151,11 +151,11 @@ public async Task Handle_WithAPagePastTheLast_ReturnsAnEmptyPageAndTheTotal()
}
[Fact]
- public async Task Handle_WithoutFilters_AsksForTheActiveOnesWithoutSearch()
+ public async Task Handle_WithoutFilters_AsksForEveryoneWithoutSearch()
{
await _handler.Handle(new ListClientsQuery(), CancellationToken.None);
- await AssertListed(null, null, ClientStatus.Active);
+ await AssertListed(null, null, null);
}
[Theory]
@@ -167,7 +167,7 @@ public async Task Handle_SearchesTheNormalizedTextOfTheSearch(string search, str
{
await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(search)), CancellationToken.None);
- await AssertListed(expectedText, null, ClientStatus.Active);
+ await AssertListed(expectedText, null, null);
}
[Theory]
@@ -177,7 +177,7 @@ public async Task Handle_WithDigitsAndCpfMask_AlsoSearchesTheCpfWithoutTheMask(s
{
await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(search)), CancellationToken.None);
- await AssertListed(expectedText, expectedDigits, ClientStatus.Active);
+ await AssertListed(expectedText, expectedDigits, null);
}
[Theory]
@@ -187,7 +187,7 @@ public async Task Handle_WithTextThatIsNotADigitSequence_DoesNotSearchTheCpf(str
{
await _handler.Handle(new ListClientsQuery(Search: SearchTextOf(search)), CancellationToken.None);
- await AssertListed(search, null, ClientStatus.Active);
+ await AssertListed(search, null, null);
}
[Fact]
diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs
index 51352e0d..8106957e 100644
--- a/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs
+++ b/backend/services/services-service/ServicesService.Tests/Clients/ListClients/ListClientsQueryTests.cs
@@ -18,24 +18,25 @@ private static ClientListFilter FilterOf(string raw)
[Theory]
[InlineData(ClientStatusFilter.Active, ClientStatus.Active)]
[InlineData(ClientStatusFilter.Inactive, ClientStatus.Inactive)]
- [InlineData(null, ClientStatus.Active)]
- public void ToFilter_MapsTheStatusFilterToTheClientStatus(ClientStatusFilter? status, ClientStatus expected)
+ public void ToFilter_MapsTheStatusFilterToTheClientStatus(ClientStatusFilter status, ClientStatus expected)
{
new ListClientsQuery(Status: status).ToFilter().Status.Should().Be(expected);
}
- [Fact]
- public void ToFilter_WithTheAllFilter_HasNoStatusRestriction()
+ [Theory]
+ [InlineData(ClientStatusFilter.All)]
+ [InlineData(null)]
+ public void ToFilter_WithTheAllFilterOrNoStatus_HasNoStatusRestriction(ClientStatusFilter? status)
{
- new ListClientsQuery(Status: ClientStatusFilter.All).ToFilter().Status.Should().BeNull();
+ new ListClientsQuery(Status: status).ToFilter().Status.Should().BeNull();
}
[Fact]
- public void ToFilter_OfTheDefaultQuery_AsksForTheFirstPageOfTheActiveOnesWithoutSearch()
+ public void ToFilter_OfTheDefaultQuery_AsksForTheFirstPageOfEveryoneWithoutSearch()
{
var filter = new ListClientsQuery().ToFilter();
- filter.Should().Be(new ClientListFilter(1, 20, null, null, ClientStatus.Active));
+ filter.Should().Be(new ClientListFilter(1, 20, null, null, null));
}
[Fact]
diff --git a/docs/API.md b/docs/API.md
index 88382aee..7d4f1629 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -71,8 +71,9 @@ o único campo que muda por endpoint.
{"data":{"items":[{"id":"...","code":1,"name":"Teste","description":null,"durationMinutes":2,"minDurationMinutes":1,"maxDurationMinutes":3,"price":10.00,"maxDiscountPercentage":10.00,"categoryId":null,"categoryName":null,"tags":[]}],"totalCount":1,"page":1,"pageSize":20},"success":true, "...":"..."}
```
-**Lista paginada com busca e filtro** — o mesmo `PagedResult`, com `search` (texto) e `status` (string) além de
-`page`/`pageSize`. Exemplo, `GET /api/v1/clients` (padrão: `page=1`, `pageSize=20`, `status=active`, sem `search`),
+**Lista paginada com busca e filtro** — o mesmo `PagedResult`, com `search` (texto) e `status` (`active`, `inactive`
+ou `all`) além de `page`/`pageSize`. Exemplo, `GET /api/v1/clients` (padrão: `page=1`, `pageSize=20`, sem `search`, e sem
+`status`, que lista todas as situações, [ADR 0067](adr/0067-admin-frontend-clients-list.md)),
verificado em 2026-10-12 num PostgreSQL descartável com a stack real em portas privadas
([ADR 0063](adr/0063-clients-list-and-search-contract.md)):
diff --git a/docs/adr/0063-clients-list-and-search-contract.md b/docs/adr/0063-clients-list-and-search-contract.md
index 8c40f9da..58374366 100644
--- a/docs/adr/0063-clients-list-and-search-contract.md
+++ b/docs/adr/0063-clients-list-and-search-contract.md
@@ -2,7 +2,8 @@
Status: accepted (2026-10); extends [ADR 0044](0044-clients-aggregate-uniqueness-and-conflict-contract.md) (the
situation) and [ADR 0060](0060-clients-read-by-id-contract.md) (the CPF on the wire); corrects the guidance of
-[`ARCHITECTURE`](../../backend/docs/ARCHITECTURE.md) §5 on `EF.Property` over a value-object column
+[`ARCHITECTURE`](../../backend/docs/ARCHITECTURE.md) §5 on `EF.Property` over a value-object column; amended by
+[ADR 0067](0067-admin-frontend-clients-list.md) (an omitted `status` lists everyone)
## Context
@@ -45,8 +46,10 @@ has no roles, every authenticated user of the tenant sees the whole CPF in the d
of reader. What changes is volume: up to 100 whole CPFs per response.
**Situation.** `status` is a plain enum, `ClientStatusFilter { Active = 1, Inactive = 2, All = 3 }`, named in
-camelCase on the wire ([ADR 0058](0058-closed-set-members-are-plain-enums.md)): `active` (default), `inactive` or
-`all`, in any case. Omitted or blank is `active`. `all` is "no restriction", and soft-deleted people never appear,
+camelCase on the wire ([ADR 0058](0058-closed-set-members-are-plain-enums.md)): `active`, `inactive` or `all`
+(default), in any case. Omitted or blank is `all`: the default was `active` until
+[ADR 0067](0067-admin-frontend-clients-list.md), so that a screen without the situation filter still reaches an inactive
+person. `all` is "no restriction", and soft-deleted people never appear,
whatever the value. `ClientStatus` cannot be the type: it has no `All` and it is persisted. The generated contract
publishes `'active' | 'inactive' | 'all'`.
@@ -135,8 +138,9 @@ and an ADR of its own.
- The InMemory persistence tests do not compute generated columns: they fill the three columns the way PostgreSQL
would, and a model test pins the expressions. What the real columns produce was verified by hand.
- Accent-insensitive search is a known limit. `jose` does not find `José`; typing the accent does.
-- The frontend (#155 screen) is another change. It sends `status` (the generated union) and `search` and branches on the error
- codes above; a bad `status` cannot come from its typed client.
+- The frontend (#155 screen) is another change. It sends `search` only, with no `status`, so by
+ [ADR 0067](0067-admin-frontend-clients-list.md) it lists active and inactive people; it branches on the error codes
+ above, and a bad `status` cannot come from its typed client.
Verified by hand on a disposable PostgreSQL 18.3 (`agenza-scratch-pg`, port 5433, `log_statement=all`), with the real
identity and services on private ports and a real login, 97 checks on the list: the default list, the exact item keys and values
@@ -144,8 +148,10 @@ identity and services on private ports and a real login, 97 checks on the list:
name, e-mail, masked and partial CPF searches; `%`, `_` and `\` as literal characters; a term with quotes; a term past
the limit (refused at binding, with the message of the value object and without echoing it), a padded one of exactly
254 characters, `search` given twice, `?Search=` in another case and the old `?q=` ignored; paging with no repeat or omission and a stable total; ties of the same name ordered by id; a page past the
-last; every validation code; `401` without a token and `403` for another tenant's header; people of another tenant,
-inactive and soft-deleted people never leaking into a list, a total or a search; and a list writing nothing. The
+last; every validation code; `401` without a token and `403` for another tenant's header; people of another tenant
+and soft-deleted people never leaking into a list, a total or a search, and inactive people appearing only for
+`status=inactive` and `status=all` (the default was `active` when this was verified, and is `all` since
+[ADR 0067](0067-admin-frontend-clients-list.md)); and a list writing nothing. The
migration was applied over five legacy rows (mixed-case name and e-mail, no e-mail, no CPF, inactive, deleted,
another tenant), reverted and applied again, with the rows intact in between, and all three migrations were applied
from an empty database when the service started. Ten more checks cover the edit, deactivate and reactivate path with
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..c1110763
--- /dev/null
+++ b/docs/adr/0067-admin-frontend-clients-list.md
@@ -0,0 +1,87 @@
+# ADR 0067 — admin-frontend: the Pessoas list
+
+Status: accepted (2026-10); consumes the contract of [ADR 0063](0063-clients-list-and-search-contract.md) and amends it
+(an omitted `status` lists everyone, point 9), 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 apply: `page=1`, `pageSize=20` and no `status`, which lists everyone
+ (point 9).
+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 (20 people) and nothing on it says there are more. A status line,
+ "Exibindo 20 de 87 pessoas", was built and then removed by the owner. `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 goes through `shared/ui/optional-text` (a dash, with "Não
+ informado" for assistive tech). The situation is a `Badge` ("Ativa", "Inativa"); labels live in `model/clientStatus.ts`. Ativa is
+ green (new `success` variant, from the `--success` token) and Inativa neutral gray (new `muted` variant): inactive
+ is a normal, reversible state, so it must not read as an error, and the label carries the meaning, not the color
+ alone. The two variants are tuned per theme for text contrast of at least 4.5:1 (the green text is mixed with
+ `--foreground`, which darkens it on light and lightens it on dark); the light theme has a border and a stronger tint
+ so the chips do not vanish on the white card.
+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 yet ("Nenhuma pessoa ainda", no button,
+ the header already has **Nova pessoa**) and a search that found nobody ("Nenhuma pessoa encontrada para «ana»" with
+ **Limpar busca**). `list-section` renders both itself, from `search` (`{ term, onClear }`) and `emptyCopy`
+ (phrases the page writes, all optional, with generic defaults), so every list shares the logic and supplies only
+ the words. The error state works the same way: the page passes `errorTitle` and a `failure` (the problem's message,
+ its code and an `onRetry`), and the widget shows them with **Tentar novamente** (ARCHITECTURE §2 and §5).
+ `TagListPage` was moved onto the same props right after.
+7. **The repository forwards the whole page, like `create`.** `list` returns the facade's result verbatim:
+ `{ items, totalCount, page, pageSize }`, with no mapper, and the model types the three paging fields as `number`.
+ That compiles because the type generator now emits `number` for a numeric field that ASP.NET also reads from text
+ (ARCHITECTURE §5); before that rule the wire type was `number | string` and forwarding verbatim would not compile.
+ Nothing reads the paging data 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`.
+9. **An omitted `status` lists everyone, active and inactive.** ADR 0063 made `active` the default; with no situation
+ filter on screen, a deactivated person would vanish from the list with no way back to her. The owner decided on
+ 2026-10-13 that the default is every situation, and the `Ativa`/`Inativa` badge tells them apart. In the backend,
+ `ListClientsQuery.Status` defaults to `null`, which `ToFilter()` reads as `all`; `?status=active` and
+ `?status=inactive` keep filtering. The contract does not change: the parameter was already optional and has no
+ default in the OpenAPI document, so the generated types are the same.
+
+## Consequences
+
+- Acceptance criteria of #155 **not met by this change**: the situation filter itself ("cadastro inativo aparece quando
+ o filtro o inclui" as a choice on screen), "alterar busca/filtro reinicia na página 1" for the filter, and "navegação
+ entre páginas não repete nem omite registros". A person past the 20th one is reachable only through the search, with
+ no sign on screen that there are more.
+- Criteria met: list, search by name, e-mail or CPF with or without the mask, the loading,
+ empty and failure states, the situation indicator (an inactive person is listed with the `Inativa` badge, point 9), 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 `