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. -

-
- -
-
- ); -} 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..d46a714a 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,78 @@ 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 (everyone, 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('forwards the whole page verbatim, with the items and the paging data', async () => { + const page = { items: [LIST_ITEM], totalCount: 21, page: 1, pageSize: 20 }; + mockGet.mockResolvedValue({ ok: true, data: page }); + + const result = await clientsRepository.list(); + + expect(result).toEqual({ ok: true, data: page }); + }); + + 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..2e8d2a26 100644 --- a/apps/admin-frontend/src/features/clients/api/clientsRepository.ts +++ b/apps/admin-frontend/src/features/clients/api/clientsRepository.ts @@ -1,9 +1,17 @@ import { servicesApi } from '@/shared/api/servicesApi'; import type { ApiResult } from '@/shared/api/servicesFacade'; import type { Client, ClientInput } from '../model/client'; +import type { ClientList } from '../model/clientList'; + +function list(search?: string, signal?: AbortSignal): Promise> { + return servicesApi.get('/api/v{version}/clients', { + query: search ? { Search: search } : undefined, + signal, + }); +} 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..dcbf47db --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/clientList.ts @@ -0,0 +1,17 @@ +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; + page: number; + pageSize: 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..67fe896a --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.test.tsx @@ -0,0 +1,418 @@ +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[]): { ok: true; data: ClientList } { + return { ok: true, data: { items, totalCount: items.length, page: 1, pageSize: 20 } }; +} + +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 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('colors the situation by its status: green for active, neutral gray for inactive', 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')).toHaveAttribute('data-variant', 'success'); + expect(within(brunoRow).getByText('Inativa')).toHaveAttribute('data-variant', 'muted'); + }); + + 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('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 ainda')).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 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 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..ae13fd3b --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/ClientListPage.tsx @@ -0,0 +1,58 @@ +import { PlusIcon } from 'lucide-react'; +import { Link } from 'react-router'; +import { buttonVariants } from '@/shared/ui/button'; +import { ListSection, type ListSectionEmptyCopy } from '@/widgets/list-section'; +import { NEW_CLIENT_PATH } from '../../clientsPaths'; +import { clientColumns } from './components/clientColumns'; +import { ClientListSearch } from './components/ClientListSearch'; +import { useClientListPage } from './useClientListPage'; + +const EMPTY_COPY: ListSectionEmptyCopy = { + title: 'Nenhuma pessoa ainda', + description: 'Use o botão Nova pessoa para cadastrar a primeira.', + searchTitle: (term) => `Nenhuma pessoa encontrada para «${term}»`, + searchDescription: 'Confira a grafia ou busque pelo e-mail ou pelo CPF.', +}; + +export function ClientListPage() { + const { + clients, + status, + failure, + query, + searchInputRef, + onSearchSubmit, + onClearSearch, + onRetry, + } = useClientListPage(); + + return ( +
+
+

Pessoas

+ +
+ + + + client.id} + aria-label="Pessoas" + columns={clientColumns()} + emptyCopy={EMPTY_COPY} + search={{ term: query, onClear: onClearSearch }} + errorTitle="Não foi possível carregar as pessoas." + failure={failure && { ...failure, onRetry }} + /> +
+ ); +} diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListSearch.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListSearch.tsx new file mode 100644 index 00000000..2f442236 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/ClientListSearch.tsx @@ -0,0 +1,37 @@ +import { SearchIcon } from 'lucide-react'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from '@/shared/ui/input-group'; +import { CLIENT_SEARCH_MAX_LENGTH } from '../../../../model/client'; +import type { UseClientListPageResult } from '../useClientListPage.types'; + +type ClientListSearchProps = Pick< + UseClientListPageResult, + 'query' | 'searchInputRef' | 'onSearchSubmit' +>; + +export function ClientListSearch({ query, searchInputRef, onSearchSubmit }: ClientListSearchProps) { + return ( +
+ + + + + + Buscar + + +
+ ); +} 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..52ac3aa5 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/components/clientColumns.tsx @@ -0,0 +1,59 @@ +import { Link } from 'react-router'; +import { maskCpf } from '@/shared/values/cpf'; +import { Badge } from '@/shared/ui/badge'; +import { OptionalText } from '@/shared/ui/optional-text'; +import type { ListSectionColumn } from '@/widgets/list-section'; +import type { ClientListItem } from '../../../../model/clientList'; +import { CLIENT_STATUS_LABELS, type ClientStatus } from '../../../../model/clientStatus'; +import { clientDetailPath } from '../../../clientsPaths'; + +const STATUS_BADGE_VARIANT: Record = { + active: 'success', + inactive: 'muted', +}; + +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..e99d6742 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.ts @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useRef, useState, type SubmitEvent } from 'react'; +import { useSearchParams } from 'react-router'; +import { extractErrorMessage, type ApiResult } from '@/shared/api/servicesFacade'; +import { ListSectionStatus } from '@/widgets/list-section'; +import { clientsRepository } from '../../../api/clientsRepository'; +import type { ClientList } from '../../../model/clientList'; +import type { + ClientListState, + LoadResult, + UseClientListPageResult, +} from './useClientListPage.types'; + +export function useClientListPage(): UseClientListPageResult { + const [searchParams, setSearchParams] = useSearchParams(); + const query = searchParams.get('q') ?? ''; + const [loaded, setLoaded] = 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; + setLoaded({ query: forQuery, apiResult }); + }); + }, []); + + 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 } : {}); + } + + function onClearSearch() { + setSearchParams({}); + } + + function onRetry() { + setLoaded(null); + fetchClients(query); + } + + function toListState(apiResult: ApiResult | null): ClientListState { + if (apiResult === null) { + return { status: ListSectionStatus.Loading, clients: [], failure: null }; + } + + if (!apiResult.ok) { + const { error } = apiResult; + return { + status: ListSectionStatus.Error, + clients: [], + failure: { message: extractErrorMessage(error), code: error.code ?? undefined }, + }; + } + + return { status: ListSectionStatus.Ready, clients: apiResult.data.items, failure: null }; + } + + const current = loaded?.query === query ? loaded.apiResult : null; + + return { + ...toListState(current), + query, + searchInputRef, + onSearchSubmit, + onClearSearch, + onRetry, + }; +} 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..17cae9fd --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientListPage/useClientListPage.types.ts @@ -0,0 +1,28 @@ +import type { RefObject, SubmitEvent } from 'react'; +import type { ApiResult } from '@/shared/api/servicesFacade'; +import type { ListSectionStatus } from '@/widgets/list-section'; +import type { ClientList, ClientListItem } from '../../../model/clientList'; + +export interface ClientListFailure { + message: string; + code: string | undefined; +} + +export interface ClientListState { + status: ListSectionStatus; + clients: ClientListItem[]; + failure: ClientListFailure | null; +} + +export interface UseClientListPageResult extends ClientListState { + query: string; + searchInputRef: RefObject; + onSearchSubmit: (event: SubmitEvent) => void; + onClearSearch: () => void; + onRetry: () => void; +} + +export interface LoadResult { + query: string; + apiResult: ApiResult; +} diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.test.tsx b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.test.tsx index 9fba3d91..b86218e5 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.test.tsx +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.test.tsx @@ -410,19 +410,34 @@ describe('TagListPage', () => { expect(screen.getByText('VIP')).toBeInTheDocument(); }); - it('shows a generic empty message when the catalog is empty (spec US1)', async () => { + it('says there are no tags yet, without a search action, when the catalog is empty (spec US1)', async () => { renderPage([]); - expect(await screen.findByText('Nenhum item encontrado.')).toBeInTheDocument(); + expect(await screen.findByText('Nenhuma etiqueta ainda')).toBeInTheDocument(); + expect(screen.getByText('Use o botão Nova etiqueta para cadastrar a primeira.')).toBeVisible(); + expect(screen.queryByText('Nenhum item encontrado.')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Limpar busca' })).not.toBeInTheDocument(); }); - it('shows the same generic empty message when a submitted search finds nothing (spec US1)', async () => { + it('names the searched term, and offers to clear it, when a submitted search finds nothing (spec US1)', async () => { const user = userEvent.setup(); renderPage(TAGS); await screen.findByText('Promoção'); await user.type(screen.getByLabelText('Buscar etiquetas por nome'), 'zzz{Enter}'); - await waitFor(() => expect(screen.getByText('Nenhum item encontrado.')).toBeInTheDocument()); + expect(await screen.findByText('Nenhuma etiqueta encontrada para «zzz»')).toBeInTheDocument(); + expect(screen.queryByText('Nenhuma etiqueta ainda')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Limpar busca' })).toBeInTheDocument(); + }); + + it('clears the search, the URL and the field, and lists every tag again, from "Limpar busca"', async () => { + const user = userEvent.setup(); + renderPage(TAGS, ['/tags?q=zzz']); + await user.click(await screen.findByRole('button', { name: 'Limpar busca' })); + + expect(await screen.findByText('Promoção')).toBeInTheDocument(); + expect(screen.getByTestId('location').textContent).toBe('location: /tags'); + expect(screen.getByLabelText('Buscar etiquetas por nome')).toHaveValue(''); }); }); diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.tsx b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.tsx index 13bdd3e0..feb19ebd 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.tsx +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/TagListPage.tsx @@ -1,6 +1,5 @@ import { PlusIcon, SearchIcon } from 'lucide-react'; import { Link, Outlet } from 'react-router'; -import { ErrorState } from '@/shared/ui/error-state'; import { InputGroup, InputGroupAddon, @@ -8,13 +7,29 @@ import { InputGroupInput, } from '@/shared/ui/input-group'; import { buttonVariants } from '@/shared/ui/button'; -import { ListSection } from '@/widgets/list-section'; +import { ListSection, type ListSectionEmptyCopy } from '@/widgets/list-section'; import { tagColumns } from './components/tagColumns'; import { useTagListPage } from './useTagListPage'; +const EMPTY_COPY: ListSectionEmptyCopy = { + title: 'Nenhuma etiqueta ainda', + description: 'Use o botão Nova etiqueta para cadastrar a primeira.', + searchTitle: (term) => `Nenhuma etiqueta encontrada para «${term}»`, + searchDescription: 'Confira a grafia do nome.', +}; + export function TagListPage() { - const { tags, status, failure, query, searchInputRef, onSearchSubmit, onRetry, newTagTo } = - useTagListPage(); + const { + tags, + status, + failure, + query, + searchInputRef, + onSearchSubmit, + onClearSearch, + onRetry, + newTagTo, + } = useTagListPage(); return (
@@ -44,22 +59,17 @@ export function TagListPage() { - {failure ? ( - - ) : ( - tag.id} - aria-label="Etiquetas" - columns={tagColumns()} - /> - )} + tag.id} + aria-label="Etiquetas" + columns={tagColumns()} + emptyCopy={EMPTY_COPY} + search={{ term: query, onClear: onClearSearch }} + errorTitle="Não foi possível carregar as etiquetas." + failure={failure && { ...failure, onRetry }} + />
diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/tagColumns.tsx b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/tagColumns.tsx index 0a0cc09b..bdd437dd 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/tagColumns.tsx +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/components/tagColumns.tsx @@ -1,4 +1,5 @@ import type { CSSProperties } from 'react'; +import { OptionalText } from '@/shared/ui/optional-text'; import type { ListSectionColumn } from '@/widgets/list-section'; import { TagDeleteLink } from './TagDeleteLink'; import { TagEditLink } from './TagEditLink'; @@ -19,15 +20,11 @@ export function tagColumns(): ListSectionColumn[] { key: 'description', header: 'Descrição', cell: (tag) => ( - - {tag.description ?? 'Sem descrição'} - + ), }, { diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.ts b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.ts index c011b3ee..f72318a5 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.ts +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.ts @@ -65,6 +65,7 @@ export function useTagListPage(): UseTagListPageResult { query, searchInputRef, onSearchSubmit, + onClearSearch: () => setSearchParams({}), onRetry: () => { setResult(null); fetchTags(query); diff --git a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.types.ts b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.types.ts index d8374799..1491f2cf 100644 --- a/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.types.ts +++ b/apps/admin-frontend/src/features/tags/ui/pages/TagListPage/useTagListPage.types.ts @@ -15,6 +15,7 @@ export interface UseTagListPageResult { query: string; searchInputRef: RefObject; onSearchSubmit: (event: SubmitEvent) => void; + onClearSearch: () => void; onRetry: () => void; newTagTo: { pathname: string; search: string }; } diff --git a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts index 900a1b6b..67f209b7 100644 --- a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts +++ b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts @@ -464,8 +464,8 @@ export interface paths { query?: { Search?: string; Status?: components['schemas']['ClientStatusFilter']; - Page?: number | string; - PageSize?: number | string; + Page?: number; + PageSize?: number; }; header?: never; path: { @@ -1040,8 +1040,8 @@ export interface paths { Search?: string; CategoryId?: string; TagId?: string; - Page?: number | string; - PageSize?: number | string; + Page?: number; + PageSize?: number; }; header?: never; path: { @@ -1855,7 +1855,7 @@ export interface components { type?: null | string; title?: null | string; /** Format: int32 */ - status?: null | number | string; + status?: null | number; detail?: null | string; instance?: null | string; code?: null | string; @@ -1983,15 +1983,15 @@ export interface components { name: string; description: null | string; /** Format: int32 */ - durationMinutes: number | string; + durationMinutes: number; /** Format: int32 */ - minDurationMinutes: number | string; + minDurationMinutes: number; /** Format: int32 */ - maxDurationMinutes: number | string; + maxDurationMinutes: number; /** Format: double */ - price: number | string; + price: number; /** Format: double */ - maxDiscountPercentage: number | string; + maxDiscountPercentage: number; /** Format: uuid */ categoryId: null | string; tagIds: null | string[]; @@ -2025,20 +2025,20 @@ export interface components { PagedResultOfClientListItemResponse: { items: components['schemas']['ClientListItemResponse'][]; /** Format: int32 */ - totalCount: number | string; + totalCount: number; /** Format: int32 */ - page: number | string; + page: number; /** Format: int32 */ - pageSize: number | string; + pageSize: number; }; PagedResultOfServiceResponse: { items: components['schemas']['ServiceResponse'][]; /** Format: int32 */ - totalCount: number | string; + totalCount: number; /** Format: int32 */ - page: number | string; + page: number; /** Format: int32 */ - pageSize: number | string; + pageSize: number; }; ReferenceContactInput: { name: string; @@ -2058,19 +2058,19 @@ export interface components { /** Format: uuid */ id: string; /** Format: int32 */ - code: number | string; + code: number; name: string; description: null | string; /** Format: int32 */ - durationMinutes: number | string; + durationMinutes: number; /** Format: int32 */ - minDurationMinutes: number | string; + minDurationMinutes: number; /** Format: int32 */ - maxDurationMinutes: number | string; + maxDurationMinutes: number; /** Format: double */ - price: number | string; + price: number; /** Format: double */ - maxDiscountPercentage: number | string; + maxDiscountPercentage: number; /** Format: uuid */ categoryId: null | string; categoryName: null | string; @@ -2125,15 +2125,15 @@ export interface components { name: string; description: null | string; /** Format: int32 */ - durationMinutes: number | string; + durationMinutes: number; /** Format: int32 */ - minDurationMinutes: number | string; + minDurationMinutes: number; /** Format: int32 */ - maxDurationMinutes: number | string; + maxDurationMinutes: number; /** Format: double */ - price: number | string; + price: number; /** Format: double */ - maxDiscountPercentage: number | string; + maxDiscountPercentage: number; /** Format: uuid */ categoryId: null | string; tagIds: null | string[]; diff --git a/apps/admin-frontend/src/shared/api/servicesFacade.test.ts b/apps/admin-frontend/src/shared/api/servicesFacade.test.ts index e8b8b8ef..e89265b9 100644 --- a/apps/admin-frontend/src/shared/api/servicesFacade.test.ts +++ b/apps/admin-frontend/src/shared/api/servicesFacade.test.ts @@ -141,10 +141,15 @@ describe('isTransientProblem', () => { it.each([ ['a backend 500 carrying its own problem body', { status: 500, code: 'Unexpected.Error' }], ['a 503 with no code', { status: 503 }], - ['a status sent as a string', { status: '502' }], ])('treats %s as transient, so it can be retried', (_name, fields) => { const problem: ApiProblem = { ...fields, title: '…' }; expect(isTransientProblem(problem)).toBe(true); }); + + it('treats a status that arrives as text, which the generated type no longer allows, as transient', () => { + const problem = { status: '502', title: '…' } as unknown as ApiProblem; + + expect(isTransientProblem(problem)).toBe(true); + }); }); diff --git a/apps/admin-frontend/src/shared/ui/badge/index.tsx b/apps/admin-frontend/src/shared/ui/badge/index.tsx index c85a9bff..8ecde969 100644 --- a/apps/admin-frontend/src/shared/ui/badge/index.tsx +++ b/apps/admin-frontend/src/shared/ui/badge/index.tsx @@ -12,6 +12,10 @@ const badgeVariants = cva( secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', destructive: 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20', + muted: + 'border-muted-foreground/25 bg-muted-foreground/15 text-[color-mix(in_oklab,var(--muted-foreground)_70%,var(--foreground))] dark:border-transparent dark:bg-muted dark:text-muted-foreground [a]:hover:bg-muted-foreground/25 dark:[a]:hover:bg-muted/80', + success: + 'border-success/30 bg-success/20 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))] focus-visible:ring-success/20 dark:border-transparent dark:bg-success/15 dark:text-[color-mix(in_oklab,var(--success)_80%,var(--foreground))] dark:focus-visible:ring-success/40 [a]:hover:bg-success/30 dark:[a]:hover:bg-success/20', outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', link: 'text-primary underline-offset-4 hover:underline', diff --git a/apps/admin-frontend/src/shared/ui/optional-text/index.tsx b/apps/admin-frontend/src/shared/ui/optional-text/index.tsx new file mode 100644 index 00000000..ed3e66fe --- /dev/null +++ b/apps/admin-frontend/src/shared/ui/optional-text/index.tsx @@ -0,0 +1,24 @@ +import { cn } from '@/shared/lib/utils'; +import type { OptionalTextProps } from './optional-text.types'; + +function OptionalText({ value, fallback, className }: OptionalTextProps) { + if (value !== null && value !== undefined) { + return {value}; + } + + if (fallback) { + return {fallback}; + } + + return ( + <> + + Não informado + + ); +} + +export { OptionalText }; +export type { OptionalTextProps }; diff --git a/apps/admin-frontend/src/shared/ui/optional-text/optional-text.test.tsx b/apps/admin-frontend/src/shared/ui/optional-text/optional-text.test.tsx new file mode 100644 index 00000000..3cfa9826 --- /dev/null +++ b/apps/admin-frontend/src/shared/ui/optional-text/optional-text.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { OptionalText } from './index'; + +describe('OptionalText', () => { + it('shows the value as it is when there is one', () => { + render(); + + expect(screen.getByText('(11) 91234-5678')).toBeInTheDocument(); + expect(screen.queryByText('—')).not.toBeInTheDocument(); + expect(screen.queryByText('Não informado')).not.toBeInTheDocument(); + }); + + it.each([[null], [undefined]] as const)( + 'shows a dash hidden from assistive tech, and says "Não informado" to it, when the value is %s', + (value) => { + render(); + + expect(screen.getByText('—')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByText('Não informado')).toHaveClass('sr-only'); + }, + ); + + it('shows the fallback as visible, faded italic text instead of the dash when the value is missing', () => { + render(); + + expect(screen.getByText('Sem descrição')).toHaveClass('italic', 'opacity-70'); + expect(screen.queryByText('—')).not.toBeInTheDocument(); + expect(screen.queryByText('Não informado')).not.toBeInTheDocument(); + }); + + it('ignores the fallback when there is a value', () => { + render(); + + expect(screen.getByText('Desconto temporário')).not.toHaveClass('italic'); + expect(screen.queryByText('Sem descrição')).not.toBeInTheDocument(); + }); + + it('applies className to the value, to the fallback and to the dash', () => { + const { rerender } = render( + , + ); + expect(screen.getByText('ana@example.com')).toHaveClass('whitespace-nowrap'); + + rerender(); + expect(screen.getByText('Sem descrição')).toHaveClass('whitespace-nowrap'); + + rerender(); + expect(screen.getByText('—')).toHaveClass('whitespace-nowrap'); + }); +}); diff --git a/apps/admin-frontend/src/shared/ui/optional-text/optional-text.types.ts b/apps/admin-frontend/src/shared/ui/optional-text/optional-text.types.ts new file mode 100644 index 00000000..099a6c4e --- /dev/null +++ b/apps/admin-frontend/src/shared/ui/optional-text/optional-text.types.ts @@ -0,0 +1,7 @@ +interface OptionalTextProps { + value: string | null | undefined; + fallback?: string | undefined; + className?: string | undefined; +} + +export type { OptionalTextProps }; diff --git a/apps/admin-frontend/src/widgets/list-section/components/list-section-empty.tsx b/apps/admin-frontend/src/widgets/list-section/components/list-section-empty.tsx new file mode 100644 index 00000000..8569db2f --- /dev/null +++ b/apps/admin-frontend/src/widgets/list-section/components/list-section-empty.tsx @@ -0,0 +1,25 @@ +import { Button } from '@/shared/ui/button'; +import { EmptyState } from '@/shared/ui/empty-state'; +import type { ListSectionEmptyProps } from '../list-section.types'; + +function ListSectionEmpty({ copy, search }: ListSectionEmptyProps) { + if (search?.term) { + return ( + + Limpar busca + + } + /> + ); + } + + return ( + + ); +} + +export { ListSectionEmpty }; diff --git a/apps/admin-frontend/src/widgets/list-section/components/list-section-error.tsx b/apps/admin-frontend/src/widgets/list-section/components/list-section-error.tsx new file mode 100644 index 00000000..5c9aaab8 --- /dev/null +++ b/apps/admin-frontend/src/widgets/list-section/components/list-section-error.tsx @@ -0,0 +1,15 @@ +import { ErrorState } from '@/shared/ui/error-state'; +import type { ListSectionErrorProps } from '../list-section.types'; + +function ListSectionError({ title, failure }: ListSectionErrorProps) { + return ( + + ); +} + +export { ListSectionError }; diff --git a/apps/admin-frontend/src/widgets/list-section/components/list-section-ready.tsx b/apps/admin-frontend/src/widgets/list-section/components/list-section-ready.tsx index 3b9d6642..646b9108 100644 --- a/apps/admin-frontend/src/widgets/list-section/components/list-section-ready.tsx +++ b/apps/admin-frontend/src/widgets/list-section/components/list-section-ready.tsx @@ -1,12 +1,19 @@ -import { EmptyState } from '@/shared/ui/empty-state'; import type { ListSectionReadyProps } from '../list-section.types'; +import { ListSectionEmpty } from './list-section-empty'; import { ListSectionTable } from './list-section-table'; import { ListSectionList } from './list-section-list'; -function ListSectionReady({ items, getKey, renderMode, ariaLabel }: ListSectionReadyProps) { +function ListSectionReady({ + items, + getKey, + renderMode, + ariaLabel, + emptyCopy, + search, +}: ListSectionReadyProps) { if (items.length === 0) { - return ; + return ; } if (renderMode.columns) { diff --git a/apps/admin-frontend/src/widgets/list-section/index.tsx b/apps/admin-frontend/src/widgets/list-section/index.tsx index 4160a88a..92326f0f 100644 --- a/apps/admin-frontend/src/widgets/list-section/index.tsx +++ b/apps/admin-frontend/src/widgets/list-section/index.tsx @@ -1,8 +1,8 @@ -import { ErrorState } from '@/shared/ui/error-state'; import { cn } from '@/shared/lib/utils'; import { ListSectionStatus, type ListSectionProps } from './list-section.types'; import { ListSectionSkeleton } from './components/list-section-skeleton'; +import { ListSectionError } from './components/list-section-error'; import { ListSectionReady } from './components/list-section-ready'; function ListSection(props: ListSectionProps) { @@ -11,6 +11,10 @@ function ListSection(props: ListSectionProps) { items, getKey, skeletonRowCount = 10, + emptyCopy, + search, + errorTitle, + failure, 'aria-label': ariaLabel, className, } = props; @@ -18,9 +22,18 @@ function ListSection(props: ListSectionProps) { return (
{status === ListSectionStatus.Loading && } - {status === ListSectionStatus.Error && } + {status === ListSectionStatus.Error && ( + + )} {status === ListSectionStatus.Ready && ( - + )}
); @@ -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 `