From 419ac64f62ba481677a236676e14d694fb27dacb Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Fri, 2 Oct 2026 17:54:47 -0300 Subject: [PATCH 01/30] feat(admin): register a served person under Pessoas (#154) Adds the "Nova pessoa" form (/pessoas/nova) to the admin panel. It posts to POST /api/v1/clients and registers the person together with guardians and reference contacts. - Navigation: the "Clientes" destination becomes "Pessoas" (/pessoas) and is no longer "Em breve"; the page is a small hub with a "Nova pessoa" link until the list arrives with #155. - features/clients: model (CPF check digits and mask, dd/mm/aaaa birth date and calculated age, the minor-needs-a-guardian rule in America/Sao_Paulo, the Zod schema that normalizes what the backend receives), repository, and a full-page form with field arrays. Field errors from the backend stay on the field they belong to (indexed keys such as Guardians[1].Name included) and the typed values are kept. - A CPF conflict reads the existing person's id from the typed FieldError.meta and offers "Abrir cadastro existente" (/pessoas/{id}, opened in a new tab so the form survives; that page arrives with #155). - Usability: focus moves to "Adicionar responsavel" when only the guardian is missing; the general error banner sits right above the buttons; adding more than 10 contacts of a kind is not offered. - shared: MaskedField (CPF and birth date), CheckboxGroup, toFormErrors for indexed keys, shared/format/date.ts (todayInSaoPaulo). Docs: ARCHITECTURE.md (decision row and sections 1 and 6), the UI foundation spec (Clientes -> Pessoas) and the agenza-form-field / agenza-api-contract skills. Tests: 561 unit tests (coverage 97%) and e2e/clients.spec.ts (create by name only, minor needing a guardian, duplicate CPF offering the existing record). Depends on the backend change (feat/154-clients-backend): the OpenAPI types file below is the same file that change commits and drops out of this diff once it is merged. The unit tests, tsc, lint and build do not need the backend; api-contract-check and the e2e only pass after it is merged. Co-Authored-By: Claude Sonnet 5.5 --- .../agenza-api-contract/references/errors.md | 12 +- .claude/skills/agenza-form-field/SKILL.md | 17 +- apps/admin-frontend/docs/ARCHITECTURE.md | 21 +- apps/admin-frontend/e2e/clients.spec.ts | 105 +++ .../specs/002-ui-foundation/spec.md | 11 +- .../src/app/pages/Clients.test.tsx | 20 + apps/admin-frontend/src/app/pages/Clients.tsx | 24 +- .../src/app/pages/ComingSoonPages.test.tsx | 2 - apps/admin-frontend/src/app/routes.tsx | 14 +- .../src/app/shell/BottomNav.test.tsx | 4 +- .../src/app/shell/BottomNav.tsx | 2 +- .../src/app/shell/navigation.ts | 2 +- .../clients/api/clientsRepository.test.ts | 58 ++ .../features/clients/api/clientsRepository.ts | 9 + .../clients/api/existingClientId.test.ts | 63 ++ .../features/clients/api/existingClientId.ts | 16 + .../src/features/clients/index.ts | 1 + .../features/clients/model/birthDate.test.ts | 116 +++ .../src/features/clients/model/birthDate.ts | 72 ++ .../src/features/clients/model/client.ts | 57 ++ .../features/clients/model/clientForm.test.ts | 338 +++++++++ .../src/features/clients/model/clientForm.ts | 184 +++++ .../clients/model/contactFormats.test.ts | 52 ++ .../features/clients/model/contactFormats.ts | 17 + .../src/features/clients/model/cpf.test.ts | 56 ++ .../src/features/clients/model/cpf.ts | 34 + .../ClientFormPage/ClientFormPage.test.tsx | 671 ++++++++++++++++++ .../pages/ClientFormPage/ClientFormPage.tsx | 49 ++ .../components/ClientFormFooter.tsx | 28 + .../components/ClientFormHeader.tsx | 22 + .../components/ClientFormSection.tsx | 32 + .../components/ClientGuardianItem.tsx | 82 +++ .../components/ClientGuardiansSection.tsx | 73 ++ .../components/ClientPersonSection.tsx | 108 +++ .../components/ClientReferenceContactItem.tsx | 93 +++ .../ClientReferenceContactsSection.tsx | 62 ++ .../pages/ClientFormPage/useClientFormPage.ts | 113 +++ .../ClientFormPage/useClientFormPage.types.ts | 17 + .../src/shared/api/formErrors.test.ts | 24 + .../src/shared/api/formErrors.ts | 4 +- .../shared/api/generated/services-api.d.ts | 167 +++++ .../fields/components/masked-field.test.tsx | 55 ++ .../form/fields/components/masked-field.tsx | 28 + .../src/shared/form/fields/fields.types.ts | 5 + .../src/shared/form/fields/index.tsx | 2 + .../src/shared/format/date.test.ts | 17 + apps/admin-frontend/src/shared/format/date.ts | 13 + .../ui/checkbox-group/checkbox-group.test.tsx | 131 ++++ .../src/shared/ui/checkbox-group/index.tsx | 61 ++ 49 files changed, 3131 insertions(+), 33 deletions(-) create mode 100644 apps/admin-frontend/e2e/clients.spec.ts create mode 100644 apps/admin-frontend/src/app/pages/Clients.test.tsx create mode 100644 apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts create mode 100644 apps/admin-frontend/src/features/clients/api/clientsRepository.ts create mode 100644 apps/admin-frontend/src/features/clients/api/existingClientId.test.ts create mode 100644 apps/admin-frontend/src/features/clients/api/existingClientId.ts create mode 100644 apps/admin-frontend/src/features/clients/index.ts create mode 100644 apps/admin-frontend/src/features/clients/model/birthDate.test.ts create mode 100644 apps/admin-frontend/src/features/clients/model/birthDate.ts create mode 100644 apps/admin-frontend/src/features/clients/model/client.ts create mode 100644 apps/admin-frontend/src/features/clients/model/clientForm.test.ts create mode 100644 apps/admin-frontend/src/features/clients/model/clientForm.ts create mode 100644 apps/admin-frontend/src/features/clients/model/contactFormats.test.ts create mode 100644 apps/admin-frontend/src/features/clients/model/contactFormats.ts create mode 100644 apps/admin-frontend/src/features/clients/model/cpf.test.ts create mode 100644 apps/admin-frontend/src/features/clients/model/cpf.ts create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormFooter.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormHeader.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormSection.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientGuardianItem.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientGuardiansSection.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientPersonSection.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientReferenceContactItem.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientReferenceContactsSection.tsx create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.ts create mode 100644 apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.types.ts create mode 100644 apps/admin-frontend/src/shared/form/fields/components/masked-field.test.tsx create mode 100644 apps/admin-frontend/src/shared/form/fields/components/masked-field.tsx create mode 100644 apps/admin-frontend/src/shared/format/date.test.ts create mode 100644 apps/admin-frontend/src/shared/format/date.ts create mode 100644 apps/admin-frontend/src/shared/ui/checkbox-group/checkbox-group.test.tsx create mode 100644 apps/admin-frontend/src/shared/ui/checkbox-group/index.tsx diff --git a/.claude/skills/agenza-api-contract/references/errors.md b/.claude/skills/agenza-api-contract/references/errors.md index 8071abcf..328d9f50 100644 --- a/.claude/skills/agenza-api-contract/references/errors.md +++ b/.claude/skills/agenza-api-contract/references/errors.md @@ -52,17 +52,23 @@ not read as "sem conexão", because the user's actual next step is entirely diff ## `errors` is not always field errors -This is the trap. `errors` is `Record` and gets filled **two +This is the trap. `errors` is `Record` and gets filled **two different ways** (`ApiProblemDetailsFactory`): 1. **Validation (400).** Keys come from FluentValidation's `PropertyName`, grouped in `Dispatcher.cs` — so they are **PascalCase C# property names** (`Name`, `DurationMinutes`), not the camelCase your JSON body and form fields use. A cross-field rule can be re-keyed onto one field with `OverridePropertyName` (see `CreateServiceCommandValidator.cs`), so the key set is not - mechanically the command's properties either. + mechanically the command's properties either. An item of a list inside the command carries its + index: `Guardians[1].Name` (`toFormErrors` turns that into `guardians.1.name`). 2. **Any other application error** (Conflict, NotFound, Failure). `CreateSingleErrorDictionary` puts the error itself under the **empty-string key `""`**. So a 409 arrives with a populated - `errors` that maps to no field at all. + `errors` that maps to no field at all. The exception: a handler may build the `Error` with its own + `FieldErrors`, and then the 409 is keyed by field (`Cpf`, `Email` in `CreateClient`). + +`meta` is an optional string map on one entry, absent when empty. It carries context for the client, +never prose: the CPF conflict puts `clientId` there (and only when that record can be opened), read in +`features/clients/api/existingClientId.ts`. Branch on the entry's `code` before trusting a `meta` key. Authorization and unexpected problems carry an empty `errors` object, never `null` in practice — but the type says nullable, so guard anyway. diff --git a/.claude/skills/agenza-form-field/SKILL.md b/.claude/skills/agenza-form-field/SKILL.md index 72d86139..12fa5eb2 100644 --- a/.claude/skills/agenza-form-field/SKILL.md +++ b/.claude/skills/agenza-form-field/SKILL.md @@ -5,12 +5,21 @@ description: Use when building or changing a form in admin-frontend — wiring R # Forms — React Hook Form + Zod + `shared/form/fields/` -`TagFormPage` (`src/features/tags/ui/pages/TagFormPage/`) is the reference implementation and, as of -this writing, the **only** consumer of everything described here. Read +`TagFormPage` (`src/features/tags/ui/pages/TagFormPage/`) is the reference implementation for a +dialog form. `ClientFormPage` (`src/features/clients/ui/pages/ClientFormPage/`) is the second +consumer and the reference for a full-page form with **field arrays** (`useFieldArray`, nested paths +such as `guardians.${number}.name`), masked inputs (`MaskedField`) and a custom multi-value control +(`CheckboxGroup` through a `ControlledField` render function). Read [`docs/ARCHITECTURE.md` §5](../../../apps/admin-frontend/docs/ARCHITECTURE.md) (the "Tag create/edit UI reintroduced on React Hook Form + Zod" row and the row right after it) for the decision history and the two gotchas below in their original context, and §6's "Provisional" section for what's still -unproven with only one consumer. +unproven. + +Backend errors on a list item arrive keyed `Guardians[1].Name`; `toFormErrors` normalizes that to +`guardians.1.name`, so the hook passes it the **current** field paths (top-level plus one per item), +not a static list — see `useClientFormPage.ts`'s `fieldPathsOf`. A field-array root error (e.g. +"informe ao menos um responsável") lives at `errors.guardians.message` / `errors.guardians.root.message` +and has no input to attach to: render it in the section. ## The three layers, one job each @@ -18,7 +27,7 @@ unproven with only one consumer. | --- | --- | --- | | Error mapping | `shared/api/formErrors.ts` | `toFormErrors(problem, fields)` — `ApiProblem` → `{fieldErrors, formError}`. Knows `ApiProblem`, nothing about RHF. | | RHF bridge | `shared/form/applyApiProblem.ts` | `applyApiProblem(problem, fields, setError)` — calls the above, then `setError` per field plus `setError('root.serverError', …)`. Knows both shapes; the only file that does. | -| Field components | `shared/form/fields/` | `TextField`/`TextareaField`, `ControlledField`, `ColorField`, `FormErrorBanner` — RHF wiring, zero domain knowledge. They render into `shared/ui/form-field/`'s `FormField` (label, hint, error, the `aria-*` wiring), which stays presentational and RHF-free like the rest of `shared/ui/` (`agenza-ui-primitive`). | +| Field components | `shared/form/fields/` | `TextField`/`TextareaField`, `MaskedField`, `ControlledField`, `ColorField`, `FormErrorBanner` — RHF wiring, zero domain knowledge. They render into `shared/ui/form-field/`'s `FormField` (label, hint, error, the `aria-*` wiring), which stays presentational and RHF-free like the rest of `shared/ui/` (`agenza-ui-primitive`). | Never let a field component or `shared/form/` read `problem.errors` directly — that parsing (PascalCase keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See `agenza-api-contract`'s diff --git a/apps/admin-frontend/docs/ARCHITECTURE.md b/apps/admin-frontend/docs/ARCHITECTURE.md index e5751e82..b16837f6 100644 --- a/apps/admin-frontend/docs/ARCHITECTURE.md +++ b/apps/admin-frontend/docs/ARCHITECTURE.md @@ -24,7 +24,7 @@ src/ │ ├── shell/ AppShell chrome: responsive nav (sidebar/rail/bottom), header, │ │ command palette │ └── pages/ One stub per route (§6) — not feature slices; no model, no api -├── features// One vertical slice per user-facing capability (auth, tags, …) +├── features// One vertical slice per user-facing capability (auth, tags, clients, …) │ ├── model/ Types + rules. No React. ( = domain + application ) │ ├── api/ Backend gateway — repositories. ( = infrastructure ) │ ├── ui/ Everything that imports React @@ -382,6 +382,7 @@ Chosen, and — just as important — tried and backed out of, so nobody re-liti | Review fixes on the tag dialogs (PR #125) | Two defects, one line of reasoning each. (1) `useRouteScrollReset` keys on the **section** (first path segment), not the whole `pathname`: `new`, `:id/edit` and `:id/delete` are child routes of `/tags`, so every dialog open/close used to reset `
`'s scroll and throw the list back to the top. (2) `useTagFormPage.onValid` and `ConfirmDialog.handleConfirm` skip closing when the dialog already unmounted mid-request (browser Back): react-router's `useNavigate` stays live after unmount, so the stale `navigate(-1)` used to pop one entry too many — out of the app, when `/tags` was the tab's first entry. FR-018 still does not block browser Back; no `useBlocker`, since the request finishes anyway and the toast still reports it. Deliberately left as is, both judged not worth the code: a row someone else already removed stays listed after its `Tag.NotFound` until the next reload or search — the spec's edge case only asks for the "não encontrada" message, which already shows; and `TagDeletePage` keeps its loaded tag if `:id` changed without a remount, because no UI path goes from `/tags/A/delete` straight to `/tags/B/delete` — the modal blocks the list, and closing it goes back to `/tags` first. A `key={id}` on the confirmation is the fix if a flow like "excluir a próxima" ever appears. | | `TagListPage` renders its own failure with a retry (`ErrorState`: title, the problem's message and `code`, **Tentar novamente**) instead of `list-section`'s fixed "Não foi possível carregar." | Asked for directly, reverting the accepted cost in §2 for tags only — a failed load had no way back short of a full reload, and resubmitting the same search did nothing because `?q=` didn't change. Done the way §2 already prescribed: the page builds the failure with `shared/ui/error-state` and passes `list-section` only loading/ready; `list-section` itself is unchanged. Retry drops the failed result so the skeleton shows while the same query reloads. | | `isTransientProblem` also treats `status >= 500` as transient | A real backend 500 arrives with its own problem body and code, which the three client-side sentinels never matched — `ConfirmDialog` classified it as a permanent block ("Não é possível excluir", no retry) instead of offering **Tentar novamente**. A 4xx without a code (ASP.NET's native model-binding 400) stays non-transient. | +| `features/clients` — the Pessoas registration form (`/pessoas/nova`, issue #154), on a full page rather than a dialog; nav destination "Clientes" renamed **Pessoas** (`/pessoas`) and no longer "Em breve" | Second real slice and second consumer of `shared/form/fields`. A page, not a dialog like Tags: the form holds two dynamic lists (guardians, reference contacts) built with RHF `useFieldArray`. What it added to `shared/`: `MaskedField` (a specialization of `ControlledField` once CPF, guardian CPF and birth date made three call sites), a `CheckboxGroup` primitive for the contact purposes, `toFormErrors` mapping indexed backend keys (`Guardians[1].Name` → `guardians.1.name`), and `shared/format/date.ts`'s `todayInSaoPaulo()` for the minor rule. Birth date is a masked `dd/mm/aaaa` text field, not ``: a half-typed native date reads as `''`, which would silently skip the minor rule. The form sets `noValidate` so the browser's own English bubbles never replace the pt-BR messages. A CPF conflict reads the existing record's id from the typed `FieldError.meta` and links to `/pessoas/{id}` in a new tab, so the typed values survive; that page arrives with #155. Full contract and the backend half: [ADR 0044](../../../docs/adr/0044-clients-aggregate-uniqueness-and-conflict-contract.md). | 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. @@ -407,19 +408,21 @@ 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. + 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. - **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 [ADR 0038](../../../docs/adr/0038-admin-frontend-remove-categories-harness.md) for why the layer was kept standing in the meantime. -- **`shared/form/fields/`'s genericity is asserted, not yet proven by a second caller.** - `TextField`/`TextareaField`/`ControlledField` are written generic over any RHF `FieldValues`, and - `ColorField` was deliberately kept as a specialization of `ControlledField` rather than the only - shape the family supports — but `TagFormPage` is still the one consumer exercising any of it. - Whether the family's actual shape holds (in particular whether `ControlledField`'s render-function - escape hatch is what the next custom control needs, or whether it wants its own specialization - immediately) is untested until the next entity's form is built — see `agenza-form-field`. +- **`shared/form/fields/`'s genericity now has a second caller.** `ClientFormPage` uses + `TextField`/`TextareaField`/`ControlledField` over a different schema, including nested field-array + paths (`guardians.${number}.name`), and it is what justified `MaskedField` as the second + specialization of `ControlledField` (three call sites) while the purposes checkboxes stay a plain + `ControlledField` render function (one call site) — see `agenza-form-field`. Still unproven: a form + that loads an existing record in a page rather than a dialog (Tags covers edit; Clients is + create-only until issue #156). ### Deliberately not built — no need yet diff --git a/apps/admin-frontend/e2e/clients.spec.ts b/apps/admin-frontend/e2e/clients.spec.ts new file mode 100644 index 00000000..b519903d --- /dev/null +++ b/apps/admin-frontend/e2e/clients.spec.ts @@ -0,0 +1,105 @@ +import { test, expect, type Page } from '@playwright/test'; +import { loginAsDemoUser } from './helpers'; + +function validCpf(): string { + const checkDigit = (digits: number[]) => { + const weightStart = digits.length + 1; + const sum = digits.reduce((total, digit, index) => total + digit * (weightStart - index), 0); + const remainder = (sum * 10) % 11; + return remainder === 10 ? 0 : remainder; + }; + + const base = Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)); + const first = checkDigit(base); + const second = checkDigit([...base, first]); + return [...base, first, second].join(''); +} + +function uniqueName(prefix: string): string { + return `${prefix} ${Date.now()}${Math.floor(Math.random() * 1000)}`; +} + +async function openNewPersonForm(page: Page) { + await page.getByRole('link', { name: 'Pessoas' }).click(); + await expect(page).toHaveURL(/\/pessoas$/); + await page.getByRole('link', { name: 'Nova pessoa' }).click(); + await expect(page).toHaveURL(/\/pessoas\/nova$/); + await expect(page.getByRole('heading', { name: 'Nova pessoa' })).toBeVisible(); +} + +test.describe('Register a person', () => { + test.beforeEach(async ({ page }) => { + await loginAsDemoUser(page); + }); + + test('reaches the form from the Pessoas area and saves a person described only by the name', async ({ + page, + }) => { + await openNewPersonForm(page); + + await page.getByLabel('Nome completo').fill(uniqueName('Pessoa E2E')); + await page.getByRole('button', { name: 'Salvar' }).click(); + + await expect(page).toHaveURL(/\/pessoas$/); + await expect(page.getByText('Pessoa cadastrada')).toBeVisible(); + }); + + test('asks for a guardian only when the birth date indicates a minor, then saves with one', async ({ + page, + }) => { + await openNewPersonForm(page); + const person = page.getByRole('region', { name: 'Dados da pessoa' }); + const tenYearsAgo = new Date().getFullYear() - 10; + + await person.getByLabel('Nome completo').fill(uniqueName('Menor E2E')); + await expect(page.getByRole('status')).toHaveCount(0); + await person.getByLabel('Data de nascimento').fill(`15/06/${tenYearsAgo}`); + await expect(page.getByRole('status')).toContainText('Informe ao menos um responsável'); + + await page.getByRole('button', { name: 'Salvar' }).click(); + await expect(page.getByRole('alert')).toContainText( + 'Informe ao menos um responsável para pessoas menores de 18 anos.', + ); + await expect(page).toHaveURL(/\/pessoas\/nova$/); + + await page.getByRole('button', { name: 'Adicionar responsável' }).click(); + const guardian = page.getByRole('group', { name: 'Responsável 1' }); + await guardian.getByLabel('Nome').fill('Responsável E2E'); + await guardian.getByLabel('Vínculo').fill('Mãe'); + await page.getByRole('button', { name: 'Adicionar pessoa de referência' }).click(); + const reference = page.getByRole('group', { name: 'Pessoa de referência 1' }); + await reference.getByLabel('Nome').fill('Contato E2E'); + await reference.getByLabel('Vínculo').fill('Tio'); + await reference.getByRole('checkbox', { name: 'Emergência' }).click(); + await page.getByRole('button', { name: 'Salvar' }).click(); + + await expect(page).toHaveURL(/\/pessoas$/); + await expect(page.getByText('Pessoa cadastrada')).toBeVisible(); + }); + + test('refuses a CPF that already belongs to someone and offers the existing record', async ({ + page, + }) => { + const cpf = validCpf(); + await openNewPersonForm(page); + await page.getByLabel('Nome completo').fill(uniqueName('Primeira E2E')); + await page.getByRole('region', { name: 'Dados da pessoa' }).getByLabel('CPF').fill(cpf); + await page.getByRole('button', { name: 'Salvar' }).click(); + await expect(page).toHaveURL(/\/pessoas$/); + + await page.getByRole('link', { name: 'Nova pessoa' }).click(); + const person = page.getByRole('region', { name: 'Dados da pessoa' }); + const secondName = uniqueName('Segunda E2E'); + await person.getByLabel('Nome completo').fill(secondName); + await person.getByLabel('CPF').fill(cpf); + await page.getByRole('button', { name: 'Salvar' }).click(); + + await expect(person.getByText('Já existe uma pessoa cadastrada com este CPF.')).toBeVisible(); + await expect(person.getByLabel('Nome completo')).toHaveValue(secondName); + await expect(page).toHaveURL(/\/pessoas\/nova$/); + await expect(person.getByRole('link', { name: /Abrir cadastro existente/ })).toHaveAttribute( + 'href', + /\/pessoas\/[0-9a-f-]{36}$/, + ); + }); +}); diff --git a/apps/admin-frontend/specs/002-ui-foundation/spec.md b/apps/admin-frontend/specs/002-ui-foundation/spec.md index 0a993202..77da28d9 100644 --- a/apps/admin-frontend/specs/002-ui-foundation/spec.md +++ b/apps/admin-frontend/specs/002-ui-foundation/spec.md @@ -27,6 +27,14 @@ A interface atual pode ser inteiramente descartada, mantendo apenas o esqueleto "Em breve" honesta. - Q: Aprovar o visual antes de escrever código? → A: **Sim**, protótipo clicável primeiro. Concluído. +### Session 2026-10-02 + +- Q: O destino "Clientes" mantém o nome quando o cadastro de pessoas atendidas chega (issues #139 e + #154)? → A: **Não.** O destino passa a se chamar **"Pessoas"**, na rota `/pessoas`, e deixa de ser + "Em breve" porque já tem backend (`POST /api/v1/clients`) e o formulário de cadastro em + `/pessoas/nova`. O identificador de código continua em inglês (`clients`). A lista e o detalhe + chegam com a issue #155. + ## User Scenarios & Testing *(mandatory)* ### User Story 1 — A pessoa escolhe como o painel se apresenta, e o painel obedece (Priority: P1) @@ -128,7 +136,8 @@ próprio escopo, sem texto genérico repetido. - **FR-004**: A preferência de tema **DEVE** ser compartilhada com o identity-service, de modo que a página de credenciais abra no mesmo tema (fecha a lacuna da ADR 0020). - **FR-005**: A navegação **DEVE** expor seis destinos — Início, Agenda, Clientes, Conversas, - Serviços, Ajustes — desde a primeira entrega. + Serviços, Ajustes — desde a primeira entrega. _(Desde 2026-10-02 o destino "Clientes" chama-se + "Pessoas"; ver Clarifications.)_ - **FR-006**: Destinos sem backend **DEVEM** renderizar uma explicação específica do destino, e **NÃO DEVEM** ser apresentados como funcionais. - **FR-007**: O shell **DEVE** apresentar barra lateral em telas largas e barra inferior em telas diff --git a/apps/admin-frontend/src/app/pages/Clients.test.tsx b/apps/admin-frontend/src/app/pages/Clients.test.tsx new file mode 100644 index 00000000..731b2bb0 --- /dev/null +++ b/apps/admin-frontend/src/app/pages/Clients.test.tsx @@ -0,0 +1,20 @@ +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 index bbedd8ba..0f835354 100644 --- a/apps/admin-frontend/src/app/pages/Clients.tsx +++ b/apps/admin-frontend/src/app/pages/Clients.tsx @@ -1,12 +1,22 @@ -import { Users } from 'lucide-react'; -import { ComingSoon } from './ComingSoon'; +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/pages/ComingSoonPages.test.tsx b/apps/admin-frontend/src/app/pages/ComingSoonPages.test.tsx index 2edca2db..106cfe9e 100644 --- a/apps/admin-frontend/src/app/pages/ComingSoonPages.test.tsx +++ b/apps/admin-frontend/src/app/pages/ComingSoonPages.test.tsx @@ -3,12 +3,10 @@ import { render } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import { Schedule } from './Schedule'; import { Settings } from './Settings'; -import { Clients } from './Clients'; import { Conversations } from './Conversations'; const PAGES = [ { name: 'Agenda', Component: Schedule }, - { name: 'Clientes', Component: Clients }, { name: 'Conversas', Component: Conversations }, { name: 'Ajustes', Component: Settings }, ]; diff --git a/apps/admin-frontend/src/app/routes.tsx b/apps/admin-frontend/src/app/routes.tsx index 98b9d2ce..d3b9ec02 100644 --- a/apps/admin-frontend/src/app/routes.tsx +++ b/apps/admin-frontend/src/app/routes.tsx @@ -23,8 +23,18 @@ export const router = createBrowserRouter([ lazy: () => import('./pages/Schedule').then((m) => ({ Component: m.Schedule })), }, { - path: 'clientes', - lazy: () => import('./pages/Clients').then((m) => ({ Component: m.Clients })), + path: 'pessoas', + children: [ + { + index: true, + lazy: () => import('./pages/Clients').then((m) => ({ Component: m.Clients })), + }, + { + path: 'nova', + lazy: () => + import('@/features/clients').then((m) => ({ Component: m.ClientFormPage })), + }, + ], }, { path: 'conversas', diff --git a/apps/admin-frontend/src/app/shell/BottomNav.test.tsx b/apps/admin-frontend/src/app/shell/BottomNav.test.tsx index b0c72d1c..db620ce0 100644 --- a/apps/admin-frontend/src/app/shell/BottomNav.test.tsx +++ b/apps/admin-frontend/src/app/shell/BottomNav.test.tsx @@ -7,13 +7,13 @@ import { BottomNav } from './BottomNav'; describe('BottomNav', () => { it('shows the four primary destinations directly and marks the active one', () => { render( - + , ); expect(screen.getByRole('link', { name: /Início/ })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /Clientes/ })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('link', { name: 'Pessoas' })).toHaveAttribute('aria-current', 'page'); expect(screen.getByRole('button', { name: 'Mais' })).toBeInTheDocument(); }); diff --git a/apps/admin-frontend/src/app/shell/BottomNav.tsx b/apps/admin-frontend/src/app/shell/BottomNav.tsx index 1b13e7c4..41661dea 100644 --- a/apps/admin-frontend/src/app/shell/BottomNav.tsx +++ b/apps/admin-frontend/src/app/shell/BottomNav.tsx @@ -5,7 +5,7 @@ import { cn } from '@/shared/lib/utils'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/shared/ui/sheet'; import { NAV_DESTINATIONS } from './navigation'; -const PRIMARY_HREFS = ['/', '/agenda', '/clientes', '/servicos']; +const PRIMARY_HREFS = ['/', '/agenda', '/pessoas', '/servicos']; const TAB_CLASS = 'flex min-h-11 flex-1 flex-col items-center justify-center gap-0.5 py-1 text-xs text-muted-foreground'; diff --git a/apps/admin-frontend/src/app/shell/navigation.ts b/apps/admin-frontend/src/app/shell/navigation.ts index 41957240..9c1c55fd 100644 --- a/apps/admin-frontend/src/app/shell/navigation.ts +++ b/apps/admin-frontend/src/app/shell/navigation.ts @@ -11,7 +11,7 @@ export interface NavDestination { export const NAV_DESTINATIONS: NavDestination[] = [ { label: 'Início', href: '/', icon: Home, comingSoon: false }, { label: 'Agenda', href: '/agenda', icon: CalendarDays, comingSoon: true }, - { label: 'Clientes', href: '/clientes', icon: Users, comingSoon: true }, + { label: 'Pessoas', href: '/pessoas', icon: Users, comingSoon: false }, { label: 'Conversas', href: '/conversas', icon: MessageCircle, comingSoon: true }, { label: 'Serviços', href: '/servicos', icon: Wrench, comingSoon: false }, { label: 'Etiquetas', href: '/tags', icon: Tag, comingSoon: false }, diff --git a/apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts b/apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts new file mode 100644 index 00000000..d0bf17b1 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/api/clientsRepository.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ClientInput } from '../model/client'; +import { clientsRepository } from './clientsRepository'; + +const { mockPost } = vi.hoisted(() => ({ mockPost: vi.fn() })); + +vi.mock('@/shared/api/servicesApi', () => ({ + servicesApi: { post: mockPost }, +})); + +const INPUT: ClientInput = { + fullName: 'Maria Souza', + birthDate: '2015-03-10', + phone: null, + email: 'maria@example.com', + cpf: '52998224725', + administrativeNotes: null, + guardians: [{ name: 'Ana Souza', relationship: 'Mãe', phone: null, cpf: null }], + referenceContacts: [ + { name: 'Carlos Lima', relationship: 'Tio', phone: null, purposes: ['emergency'] }, + ], +}; + +describe('clientsRepository.create', () => { + it('posts the whole person with its contacts to POST /clients and forwards the result verbatim', async () => { + const client = { id: 'abc', ...INPUT, status: 'active' }; + mockPost.mockResolvedValue({ ok: true, data: client }); + + const result = await clientsRepository.create(INPUT); + + expect(mockPost).toHaveBeenCalledWith('/api/v{version}/clients', { body: INPUT }); + expect(result).toEqual({ ok: true, data: client }); + }); + + it('never sends a tenant, only what the person typed', async () => { + mockPost.mockResolvedValue({ ok: true, data: {} }); + + await clientsRepository.create(INPUT); + + const [, options] = mockPost.mock.calls.at(-1) as [string, { body: Record }]; + expect(Object.keys(options.body).some((key) => key.toLowerCase().includes('tenant'))).toBe( + false, + ); + }); + + it('forwards a failed result verbatim, without throwing (e.g. duplicate CPF)', async () => { + const error = { + status: 409, + code: 'Client.DuplicateCpf', + title: 'Já existe uma pessoa cadastrada com este CPF.', + }; + mockPost.mockResolvedValue({ ok: false, error }); + + const result = await clientsRepository.create(INPUT); + + expect(result).toEqual({ ok: false, error }); + }); +}); diff --git a/apps/admin-frontend/src/features/clients/api/clientsRepository.ts b/apps/admin-frontend/src/features/clients/api/clientsRepository.ts new file mode 100644 index 00000000..1db9bc71 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/api/clientsRepository.ts @@ -0,0 +1,9 @@ +import { servicesApi } from '@/shared/api/servicesApi'; +import type { ApiResult } from '@/shared/api/servicesFacade'; +import type { Client, ClientInput } from '../model/client'; + +function create(input: ClientInput): Promise> { + return servicesApi.post('/api/v{version}/clients', { body: input }); +} + +export const clientsRepository = { create }; diff --git a/apps/admin-frontend/src/features/clients/api/existingClientId.test.ts b/apps/admin-frontend/src/features/clients/api/existingClientId.test.ts new file mode 100644 index 00000000..8508bff9 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/api/existingClientId.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import type { ApiProblem } from '@/shared/api/servicesFacade'; +import { existingClientIdFrom } from './existingClientId'; + +const EXISTING_ID = '0197f2a0-0000-7000-8000-000000000001'; + +describe('existingClientIdFrom', () => { + it('reads the id of the client that already owns the CPF', () => { + const problem: ApiProblem = { + status: 409, + code: 'Client.DuplicateCpf', + errors: { + Cpf: [ + { + code: 'Client.DuplicateCpf', + message: 'Já existe uma pessoa cadastrada com este CPF.', + meta: { clientId: EXISTING_ID }, + }, + ], + }, + }; + + expect(existingClientIdFrom(problem)).toBe(EXISTING_ID); + }); + + it('has nothing to open when the CPF belongs to a deleted client', () => { + const problem: ApiProblem = { + status: 409, + code: 'Client.DuplicateCpf', + errors: { + Cpf: [ + { code: 'Client.DuplicateCpf', message: 'Este CPF pertence a um cadastro excluído.' }, + ], + }, + }; + + expect(existingClientIdFrom(problem)).toBeNull(); + }); + + it('ignores a conflict that is not about the CPF', () => { + const problem: ApiProblem = { + status: 409, + code: 'Client.DuplicateEmail', + errors: { + Email: [ + { code: 'Client.DuplicateEmail', message: 'Já existe.', meta: { clientId: EXISTING_ID } }, + ], + }, + }; + + expect(existingClientIdFrom(problem)).toBeNull(); + }); + + it.each([ + ['no errors at all', { status: 0, title: 'Sem conexão.' } satisfies ApiProblem], + [ + 'a form-level error', + { status: 409, errors: { '': [{ message: 'Conflito.' }] } } satisfies ApiProblem, + ], + ])('returns null for %s', (_name, problem) => { + expect(existingClientIdFrom(problem)).toBeNull(); + }); +}); diff --git a/apps/admin-frontend/src/features/clients/api/existingClientId.ts b/apps/admin-frontend/src/features/clients/api/existingClientId.ts new file mode 100644 index 00000000..53a77001 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/api/existingClientId.ts @@ -0,0 +1,16 @@ +import type { ApiProblem } from '@/shared/api/servicesFacade'; + +const DUPLICATE_CPF_CODE = 'Client.DuplicateCpf'; +const CPF_FIELD = 'cpf'; +const CLIENT_ID_KEY = 'clientId'; + +export function existingClientIdFrom(problem: ApiProblem): string | null { + for (const [field, errors] of Object.entries(problem.errors ?? {})) { + if (field.toLowerCase() !== CPF_FIELD) continue; + + const conflict = errors.find((error) => error.code === DUPLICATE_CPF_CODE); + return conflict?.meta?.[CLIENT_ID_KEY] ?? null; + } + + return null; +} diff --git a/apps/admin-frontend/src/features/clients/index.ts b/apps/admin-frontend/src/features/clients/index.ts new file mode 100644 index 00000000..323d4b58 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/index.ts @@ -0,0 +1 @@ +export { ClientFormPage } from './ui/pages/ClientFormPage/ClientFormPage'; diff --git a/apps/admin-frontend/src/features/clients/model/birthDate.test.ts b/apps/admin-frontend/src/features/clients/model/birthDate.test.ts new file mode 100644 index 00000000..095dafec --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/birthDate.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateAge, + formatAge, + isInThePast, + isMinorOn, + isWithinMaxAge, + maskBirthDate, + parseBirthDate, +} from './birthDate'; + +const TODAY = '2026-10-02'; + +describe('maskBirthDate', () => { + it.each([ + ['', ''], + ['1', '1'], + ['10', '10'], + ['103', '10/3'], + ['1003', '10/03'], + ['10032', '10/03/2'], + ['10032015', '10/03/2015'], + ['100320159999', '10/03/2015'], + ['10/03/2015', '10/03/2015'], + ['ab10-03', '10/03'], + ] as const)('masks %j as %j while typing', (typed, expected) => { + expect(maskBirthDate(typed)).toBe(expected); + }); +}); + +describe('parseBirthDate', () => { + it.each([ + ['10/03/2015', '2015-03-10'], + ['01/01/1900', '1900-01-01'], + ['29/02/2000', '2000-02-29'], + [' 05/12/1990 ', '1990-12-05'], + ] as const)('reads %j as %s', (masked, expected) => { + expect(parseBirthDate(masked)).toBe(expected); + }); + + it.each([ + '', + '10/03', + '10/03/15', + '31/02/2020', + '29/02/2001', + '00/10/2020', + '10/00/2020', + '10/13/2020', + '32/01/2020', + '10/03/0999', + 'aa/bb/cccc', + '10-03-2015', + ])('rejects %j', (masked) => { + expect(parseBirthDate(masked)).toBeNull(); + }); +}); + +describe('calculateAge', () => { + it.each([ + ['2008-10-02', 18], + ['2008-10-03', 17], + ['2008-10-01', 18], + ['2026-10-01', 0], + ['2000-02-29', 26], + ['1906-10-02', 120], + ['1906-10-03', 119], + ['1905-10-02', 121], + ] as const)('counts completed years for %s as %i', (birthDate, expected) => { + expect(calculateAge(birthDate, TODAY)).toBe(expected); + }); + + it('treats a leap-day birthday as February 28 in common years, like the backend', () => { + expect(calculateAge('2008-02-29', '2026-02-27')).toBe(17); + expect(calculateAge('2008-02-29', '2026-02-28')).toBe(18); + expect(calculateAge('2008-02-29', '2028-02-28')).toBe(19); + expect(calculateAge('2008-02-29', '2028-02-29')).toBe(20); + }); +}); + +describe('birth date rules', () => { + it('requires a date strictly in the past', () => { + expect(isInThePast('2026-10-01', TODAY)).toBe(true); + expect(isInThePast('2026-10-02', TODAY)).toBe(false); + expect(isInThePast('2026-10-03', TODAY)).toBe(false); + }); + + it('accepts up to 120 years and rejects 121', () => { + expect(isWithinMaxAge('1906-10-02', TODAY)).toBe(true); + expect(isWithinMaxAge('1905-10-02', TODAY)).toBe(false); + }); + + it('is a minor until the eighteenth birthday', () => { + expect(isMinorOn('2008-10-03', TODAY)).toBe(true); + expect(isMinorOn('2008-10-02', TODAY)).toBe(false); + expect(isMinorOn('2026-10-01', TODAY)).toBe(true); + expect(isMinorOn('1990-01-01', TODAY)).toBe(false); + }); + + it('is never a minor on a date that is not in the past', () => { + expect(isMinorOn('2026-10-03', TODAY)).toBe(false); + expect(isMinorOn('2026-10-02', TODAY)).toBe(false); + }); +}); + +describe('formatAge', () => { + it.each([ + [0, 'menos de 1 ano'], + [1, '1 ano'], + [2, '2 anos'], + [17, '17 anos'], + [120, '120 anos'], + ] as const)('writes %i as %j', (age, expected) => { + expect(formatAge(age)).toBe(expected); + }); +}); diff --git a/apps/admin-frontend/src/features/clients/model/birthDate.ts b/apps/admin-frontend/src/features/clients/model/birthDate.ts new file mode 100644 index 00000000..f2e72191 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/birthDate.ts @@ -0,0 +1,72 @@ +export const ADULT_AGE_IN_YEARS = 18; +export const MAX_AGE_IN_YEARS = 120; + +const MASKED_LENGTH = 10; + +export function maskBirthDate(value: string): string { + const digits = value.replace(/\D/g, '').slice(0, 8); + + if (digits.length <= 2) return digits; + if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`; + return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`; +} + +export function parseBirthDate(masked: string): string | null { + const value = masked.trim(); + if (value.length !== MASKED_LENGTH) return null; + + const [day, month, year] = value.split('/').map(Number); + if (day === undefined || month === undefined || year === undefined) return null; + if (!Number.isInteger(day) || !Number.isInteger(month) || !Number.isInteger(year)) return null; + if (year < 1000 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) { + return null; + } + + return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`; +} + +export function calculateAge(birthDate: string, today: string): number { + const birth = toParts(birthDate); + const current = toParts(today); + + const age = current.year - birth.year; + const birthdayThisYear = { + month: birth.month, + day: Math.min(birth.day, daysInMonth(current.year, birth.month)), + }; + const hadBirthday = + current.month > birthdayThisYear.month || + (current.month === birthdayThisYear.month && current.day >= birthdayThisYear.day); + + return hadBirthday ? age : age - 1; +} + +export function isInThePast(birthDate: string, today: string): boolean { + return birthDate < today; +} + +export function isWithinMaxAge(birthDate: string, today: string): boolean { + return calculateAge(birthDate, today) <= MAX_AGE_IN_YEARS; +} + +export function isMinorOn(birthDate: string, today: string): boolean { + return isInThePast(birthDate, today) && calculateAge(birthDate, today) < ADULT_AGE_IN_YEARS; +} + +export function formatAge(age: number): string { + if (age < 1) return 'menos de 1 ano'; + return age === 1 ? '1 ano' : `${age} anos`; +} + +function toParts(isoDate: string): { year: number; month: number; day: number } { + const [year = 0, month = 0, day = 0] = isoDate.split('-').map(Number); + return { year, month, day }; +} + +function daysInMonth(year: number, month: number): number { + return new Date(Date.UTC(year, month, 0)).getUTCDate(); +} + +function pad(value: number, length: number): string { + return String(value).padStart(length, '0'); +} diff --git a/apps/admin-frontend/src/features/clients/model/client.ts b/apps/admin-frontend/src/features/clients/model/client.ts new file mode 100644 index 00000000..7dd0156a --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/client.ts @@ -0,0 +1,57 @@ +export interface ClientGuardian { + id: string; + name: string; + relationship: string; + phone: string | null; + cpf: string | null; +} + +export interface ClientReferenceContact { + id: string; + name: string; + relationship: string; + phone: string | null; + purposes: string[]; +} + +export interface Client { + id: string; + fullName: string; + birthDate: string | null; + phone: string | null; + email: string | null; + cpf: string | null; + administrativeNotes: string | null; + status: string; + guardians: ClientGuardian[]; + referenceContacts: ClientReferenceContact[]; +} + +export type GuardianInput = Pick; + +export type ReferenceContactInput = Pick< + ClientReferenceContact, + 'name' | 'relationship' | 'phone' | 'purposes' +>; + +export interface ClientInput { + fullName: string; + birthDate: string | null; + phone: string | null; + email: string | null; + cpf: string | null; + administrativeNotes: string | null; + guardians: GuardianInput[]; + referenceContacts: ReferenceContactInput[]; +} + +export interface ContactPurposeOption { + value: string; + label: string; +} + +export const CONTACT_PURPOSE_OPTIONS: readonly ContactPurposeOption[] = [ + { value: 'emergency', label: 'Emergência' }, + { value: 'operationalSupport', label: 'Apoio operacional' }, + { value: 'dailyCommunication', label: 'Comunicação cotidiana' }, +]; diff --git a/apps/admin-frontend/src/features/clients/model/clientForm.test.ts b/apps/admin-frontend/src/features/clients/model/clientForm.test.ts new file mode 100644 index 00000000..f356c2fa --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/clientForm.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + CLIENT_FORM_FIELDS, + GUARDIAN_FORM_FIELDS, + REFERENCE_CONTACT_FORM_FIELDS, + ageFromBirthDate, + clientFormSchema, + emptyGuardian, + emptyReferenceContact, + toClientFormFieldValues, + type ClientFormFieldValues, +} from './clientForm'; + +const NOON_IN_SAO_PAULO = new Date('2026-10-02T15:00:00Z'); + +function values(overrides: Partial = {}): ClientFormFieldValues { + return { ...toClientFormFieldValues(), fullName: 'Maria Souza', ...overrides }; +} + +function guardian(overrides: Partial> = {}) { + return { ...emptyGuardian(), name: 'Ana Souza', relationship: 'Mãe', ...overrides }; +} + +function reference(overrides: Partial> = {}) { + return { + ...emptyReferenceContact(), + name: 'Carlos Lima', + relationship: 'Tio', + purposes: ['emergency'], + ...overrides, + }; +} + +function issuesOf(input: ClientFormFieldValues): Record { + const result = clientFormSchema.safeParse(input); + const firstMessageByPath: Record = {}; + if (result.success) return firstMessageByPath; + + for (const issue of result.error.issues) { + firstMessageByPath[issue.path.join('.')] ??= issue.message; + } + return firstMessageByPath; +} + +beforeEach(() => { + vi.useFakeTimers({ now: NOON_IN_SAO_PAULO, toFake: ['Date'] }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('clientFormSchema', () => { + it('accepts a person described only by the full name', () => { + const result = clientFormSchema.safeParse(values()); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + fullName: 'Maria Souza', + birthDate: null, + phone: null, + email: null, + cpf: null, + administrativeNotes: null, + guardians: [], + referenceContacts: [], + }); + }); + + it('trims and normalizes every field to the shape the backend takes', () => { + const result = clientFormSchema.safeParse( + values({ + fullName: ' Maria Souza ', + birthDate: '20/05/1990', + phone: ' (11) 99999-0000 ', + email: ' Maria.Souza@Example.COM ', + cpf: '529.982.247-25', + administrativeNotes: ' Prefere contato pela manhã. ', + guardians: [ + guardian({ name: ' Ana Souza ', phone: ' 11 98888-0000', cpf: '123.456.789-09' }), + ], + referenceContacts: [reference({ purposes: ['emergency', 'dailyCommunication'] })], + }), + ); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + fullName: 'Maria Souza', + birthDate: '1990-05-20', + phone: '(11) 99999-0000', + email: 'maria.souza@example.com', + cpf: '52998224725', + administrativeNotes: 'Prefere contato pela manhã.', + guardians: [ + { name: 'Ana Souza', relationship: 'Mãe', phone: '11 98888-0000', cpf: '12345678909' }, + ], + referenceContacts: [ + { + name: 'Carlos Lima', + relationship: 'Tio', + phone: null, + purposes: ['emergency', 'dailyCommunication'], + }, + ], + }); + }); + + it('turns blank optional fields into null', () => { + const result = clientFormSchema.safeParse( + values({ birthDate: ' ', phone: ' ', email: ' ', cpf: ' ', administrativeNotes: ' ' }), + ); + + expect(result.data).toMatchObject({ + birthDate: null, + phone: null, + email: null, + cpf: null, + administrativeNotes: null, + }); + }); + + describe('full name', () => { + it('is required', () => { + expect(issuesOf(values({ fullName: ' ' }))['fullName']).toBe( + 'O nome completo é obrigatório.', + ); + }); + + it('needs at least two characters after trimming', () => { + expect(issuesOf(values({ fullName: ' A ' }))['fullName']).toBe( + 'O nome completo deve ter pelo menos 2 caracteres.', + ); + }); + + it('allows up to 150 characters after trimming', () => { + expect(issuesOf(values({ fullName: ` ${'a'.repeat(150)} ` }))).toEqual({}); + expect(issuesOf(values({ fullName: 'a'.repeat(151) }))['fullName']).toBe( + 'O nome completo deve ter no máximo 150 caracteres.', + ); + }); + }); + + describe('birth date', () => { + it.each([ + ['10/03', 'Informe uma data válida no formato dd/mm/aaaa.'], + ['31/02/2020', 'Informe uma data válida no formato dd/mm/aaaa.'], + ['02/10/2026', 'A data de nascimento deve estar no passado.'], + ['03/10/2026', 'A data de nascimento deve estar no passado.'], + ['01/01/2030', 'A data de nascimento deve estar no passado.'], + ['02/10/1905', 'A data de nascimento não pode indicar idade superior a 120 anos.'], + ] as const)('rejects %j', (birthDate, message) => { + expect(issuesOf(values({ birthDate, guardians: [guardian()] }))['birthDate']).toBe(message); + }); + + it('accepts a date in the past within 120 years', () => { + expect(issuesOf(values({ birthDate: '01/10/2026', guardians: [guardian()] }))).toEqual({}); + expect(issuesOf(values({ birthDate: '02/10/1906' }))).toEqual({}); + }); + }); + + describe('guardian required for minors', () => { + it('does not ask anything when there is no birth date', () => { + expect(issuesOf(values({ birthDate: '', guardians: [] }))).toEqual({}); + }); + + it('requires a guardian when the birth date indicates a minor', () => { + expect(issuesOf(values({ birthDate: '10/03/2015', guardians: [] }))['guardians']).toBe( + 'Informe ao menos um responsável para pessoas menores de 18 anos.', + ); + }); + + it('is satisfied by a guardian', () => { + expect(issuesOf(values({ birthDate: '10/03/2015', guardians: [guardian()] }))).toEqual({}); + }); + + it('is not required for adults, including on the eighteenth birthday', () => { + expect(issuesOf(values({ birthDate: '20/05/1990', guardians: [] }))).toEqual({}); + expect(issuesOf(values({ birthDate: '02/10/2008', guardians: [] }))).toEqual({}); + }); + + it('still requires one the day before the eighteenth birthday', () => { + expect( + issuesOf(values({ birthDate: '03/10/2008', guardians: [] }))['guardians'], + ).toBeDefined(); + }); + + it('does not require one when the birth date itself is invalid', () => { + expect( + issuesOf(values({ birthDate: '01/01/2030', guardians: [] }))['guardians'], + ).toBeUndefined(); + }); + + it('counts the São Paulo day, not the UTC day', () => { + vi.setSystemTime(new Date('2026-10-03T01:00:00Z')); + + expect( + issuesOf(values({ birthDate: '03/10/2008', guardians: [] }))['guardians'], + ).toBeDefined(); + }); + }); + + describe('contact details', () => { + it('rejects an invalid phone', () => { + expect(issuesOf(values({ phone: 'telefone' }))['phone']).toContain( + 'Informe um telefone válido', + ); + expect(issuesOf(values({ phone: '1'.repeat(21) }))['phone']).toContain( + 'Informe um telefone válido', + ); + }); + + it('rejects an invalid e-mail', () => { + expect(issuesOf(values({ email: 'maria@example' }))['email']).toBe( + 'Informe um e-mail válido.', + ); + }); + + it('rejects an e-mail longer than 254 characters', () => { + expect(issuesOf(values({ email: `${'a'.repeat(250)}@example.com` }))['email']).toBe( + 'O e-mail deve ter no máximo 254 caracteres.', + ); + }); + + it('rejects an invalid CPF', () => { + expect(issuesOf(values({ cpf: '529.982.247-24' }))['cpf']).toBe('Informe um CPF válido.'); + }); + + it('limits the administrative notes to 500 characters', () => { + expect(issuesOf(values({ administrativeNotes: 'n'.repeat(500) }))).toEqual({}); + expect( + issuesOf(values({ administrativeNotes: 'n'.repeat(501) }))['administrativeNotes'], + ).toBe('As observações administrativas devem ter no máximo 500 caracteres.'); + }); + }); + + describe('linked contacts', () => { + it('reports guardian problems on the guardian field', () => { + const issues = issuesOf( + values({ + guardians: [ + guardian(), + guardian({ name: '', relationship: '', phone: 'tel', cpf: '123' }), + ], + }), + ); + + expect(issues['guardians.1.name']).toBe('O nome do responsável é obrigatório.'); + expect(issues['guardians.1.relationship']).toBe('O vínculo do responsável é obrigatório.'); + expect(issues['guardians.1.phone']).toContain('Informe um telefone válido'); + expect(issues['guardians.1.cpf']).toBe('Informe um CPF válido.'); + expect(Object.keys(issues).some((path) => path.startsWith('guardians.0'))).toBe(false); + }); + + it('requires at least one purpose on a reference contact', () => { + const issues = issuesOf(values({ referenceContacts: [reference({ purposes: [] })] })); + + expect(issues['referenceContacts.0.purposes']).toBe( + 'Informe ao menos uma finalidade para a pessoa de referência.', + ); + }); + + it('reports reference contact problems on the contact field', () => { + const issues = issuesOf( + values({ referenceContacts: [reference({ name: ' ', relationship: ' ', phone: 'tel' })] }), + ); + + expect(issues['referenceContacts.0.name']).toBe( + 'O nome da pessoa de referência é obrigatório.', + ); + expect(issues['referenceContacts.0.relationship']).toBe( + 'O vínculo da pessoa de referência é obrigatório.', + ); + expect(issues['referenceContacts.0.phone']).toContain('Informe um telefone válido'); + }); + + it('limits how many contacts can be added', () => { + const issues = issuesOf( + values({ + guardians: Array.from({ length: 11 }, () => guardian()), + referenceContacts: Array.from({ length: 11 }, () => reference()), + }), + ); + + expect(issues['guardians']).toBe('Informe no máximo 10 responsáveis.'); + expect(issues['referenceContacts']).toBe('Informe no máximo 10 pessoas de referência.'); + }); + }); +}); + +describe('ageFromBirthDate', () => { + it.each([ + ['10/03/2015', 11], + ['03/10/2008', 17], + ['02/10/2008', 18], + ['20/05/1990', 36], + ['01/10/2026', 0], + ['02/10/1906', 120], + ] as const)('reads %j as %i years old today', (birthDate, expected) => { + expect(ageFromBirthDate(birthDate)).toBe(expected); + }); + + it.each(['', '10/03', '31/02/2020', '02/10/2026', '01/01/2030', '02/10/1905'])( + 'has no age for %j', + (birthDate) => { + expect(ageFromBirthDate(birthDate)).toBeNull(); + }, + ); +}); + +describe('form field lists', () => { + it('names every field the backend can report on', () => { + expect(CLIENT_FORM_FIELDS).toEqual([ + 'fullName', + 'birthDate', + 'phone', + 'email', + 'cpf', + 'administrativeNotes', + 'guardians', + 'referenceContacts', + ]); + expect(GUARDIAN_FORM_FIELDS).toEqual(['name', 'relationship', 'phone', 'cpf']); + expect(REFERENCE_CONTACT_FORM_FIELDS).toEqual(['name', 'relationship', 'phone', 'purposes']); + }); + + it('starts empty so nothing is asked up front', () => { + expect(toClientFormFieldValues()).toEqual({ + fullName: '', + birthDate: '', + phone: '', + email: '', + cpf: '', + administrativeNotes: '', + guardians: [], + referenceContacts: [], + }); + }); +}); diff --git a/apps/admin-frontend/src/features/clients/model/clientForm.ts b/apps/admin-frontend/src/features/clients/model/clientForm.ts new file mode 100644 index 00000000..3e54fbcc --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/clientForm.ts @@ -0,0 +1,184 @@ +import { z } from 'zod'; +import { todayInSaoPaulo } from '@/shared/format/date'; +import { + ADULT_AGE_IN_YEARS, + MAX_AGE_IN_YEARS, + calculateAge, + isInThePast, + isMinorOn, + isWithinMaxAge, + parseBirthDate, +} from './birthDate'; +import { EMAIL_MAX_LENGTH, PHONE_MAX_LENGTH, isValidEmail, isValidPhone } from './contactFormats'; +import { isValidCpf, stripCpfMask } from './cpf'; + +// Mirrors the backend limits (ServicesService.Domain.Entities.Client / ClientContact). UX pre-check +// only - the backend response stays the source of truth. +export const CLIENT_NAME_MIN_LENGTH = 2; +export const CLIENT_NAME_MAX_LENGTH = 150; +export const CLIENT_NOTES_MAX_LENGTH = 500; +export const CONTACT_RELATIONSHIP_MAX_LENGTH = 60; +export const MAX_GUARDIANS = 10; +export const MAX_REFERENCE_CONTACTS = 10; + +const nameField = (subject: string) => + z + .string() + .trim() + .min(1, `O nome ${subject} é obrigatório.`) + .min( + CLIENT_NAME_MIN_LENGTH, + `O nome ${subject} deve ter pelo menos ${CLIENT_NAME_MIN_LENGTH} caracteres.`, + ) + .max( + CLIENT_NAME_MAX_LENGTH, + `O nome ${subject} deve ter no máximo ${CLIENT_NAME_MAX_LENGTH} caracteres.`, + ); + +const relationshipField = (subject: string) => + z + .string() + .trim() + .min(1, `O vínculo ${subject} é obrigatório.`) + .max( + CONTACT_RELATIONSHIP_MAX_LENGTH, + `O vínculo ${subject} deve ter no máximo ${CONTACT_RELATIONSHIP_MAX_LENGTH} caracteres.`, + ); + +const phoneField = z + .string() + .trim() + .refine( + (value) => value === '' || isValidPhone(value), + `Informe um telefone válido, com até ${PHONE_MAX_LENGTH} caracteres entre dígitos, espaços, +, parênteses e hífen.`, + ) + .transform((value) => value || null); + +const cpfField = z + .string() + .trim() + .refine((value) => value === '' || isValidCpf(value), 'Informe um CPF válido.') + .transform((value) => (value ? stripCpfMask(value) : null)); + +const emailField = z + .string() + .trim() + .toLowerCase() + .max(EMAIL_MAX_LENGTH, `O e-mail deve ter no máximo ${EMAIL_MAX_LENGTH} caracteres.`) + .refine((value) => value === '' || isValidEmail(value), 'Informe um e-mail válido.') + .transform((value) => value || null); + +const birthDateField = z.string().transform((value, ctx) => { + if (value.trim() === '') return null; + + const fail = (message: string) => { + ctx.addIssue({ code: 'custom', message }); + return z.NEVER; + }; + + const birthDate = parseBirthDate(value); + if (birthDate === null) return fail('Informe uma data válida no formato dd/mm/aaaa.'); + + const today = todayInSaoPaulo(); + if (!isInThePast(birthDate, today)) return fail('A data de nascimento deve estar no passado.'); + if (!isWithinMaxAge(birthDate, today)) { + return fail(`A data de nascimento não pode indicar idade superior a ${MAX_AGE_IN_YEARS} anos.`); + } + + return birthDate; +}); + +const guardianSchema = z.object({ + name: nameField('do responsável'), + relationship: relationshipField('do responsável'), + phone: phoneField, + cpf: cpfField, +}); + +const referenceContactSchema = z.object({ + name: nameField('da pessoa de referência'), + relationship: relationshipField('da pessoa de referência'), + phone: phoneField, + purposes: z + .array(z.string()) + .min(1, 'Informe ao menos uma finalidade para a pessoa de referência.'), +}); + +const clientFormObject = z.object({ + fullName: nameField('completo'), + birthDate: birthDateField, + phone: phoneField, + email: emailField, + cpf: cpfField, + administrativeNotes: z + .string() + .trim() + .max( + CLIENT_NOTES_MAX_LENGTH, + `As observações administrativas devem ter no máximo ${CLIENT_NOTES_MAX_LENGTH} caracteres.`, + ) + .transform((value) => value || null), + guardians: z + .array(guardianSchema) + .max(MAX_GUARDIANS, `Informe no máximo ${MAX_GUARDIANS} responsáveis.`), + referenceContacts: z + .array(referenceContactSchema) + .max( + MAX_REFERENCE_CONTACTS, + `Informe no máximo ${MAX_REFERENCE_CONTACTS} pessoas de referência.`, + ), +}); + +export const clientFormSchema = clientFormObject.superRefine((value, ctx) => { + if ( + value.birthDate && + isMinorOn(value.birthDate, todayInSaoPaulo()) && + value.guardians.length === 0 + ) { + ctx.addIssue({ + code: 'custom', + path: ['guardians'], + message: `Informe ao menos um responsável para pessoas menores de ${ADULT_AGE_IN_YEARS} anos.`, + }); + } +}); + +export type ClientFormFieldValues = z.input; +export type ClientFormValues = z.output; +export type GuardianFormFieldValues = ClientFormFieldValues['guardians'][number]; +export type ReferenceContactFormFieldValues = ClientFormFieldValues['referenceContacts'][number]; + +export const CLIENT_FORM_FIELDS = clientFormObject.keyof().options; +export const GUARDIAN_FORM_FIELDS = guardianSchema.keyof().options; +export const REFERENCE_CONTACT_FORM_FIELDS = referenceContactSchema.keyof().options; + +export function toClientFormFieldValues(): ClientFormFieldValues { + return { + fullName: '', + birthDate: '', + phone: '', + email: '', + cpf: '', + administrativeNotes: '', + guardians: [], + referenceContacts: [], + }; +} + +export function emptyGuardian(): GuardianFormFieldValues { + return { name: '', relationship: '', phone: '', cpf: '' }; +} + +export function emptyReferenceContact(): ReferenceContactFormFieldValues { + return { name: '', relationship: '', phone: '', purposes: [] }; +} + +export function ageFromBirthDate(masked: string): number | null { + const birthDate = parseBirthDate(masked); + const today = todayInSaoPaulo(); + if (birthDate === null || !isInThePast(birthDate, today) || !isWithinMaxAge(birthDate, today)) { + return null; + } + + return calculateAge(birthDate, today); +} diff --git a/apps/admin-frontend/src/features/clients/model/contactFormats.test.ts b/apps/admin-frontend/src/features/clients/model/contactFormats.test.ts new file mode 100644 index 00000000..54751921 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/contactFormats.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { isValidEmail, isValidPhone } from './contactFormats'; + +describe('isValidPhone', () => { + it.each([ + '11999990000', + '(11) 99999-0000', + '+55 (11) 99999-0000', + '11 9999-0000', + '1'.repeat(20), + ])('accepts %j', (phone) => { + expect(isValidPhone(phone)).toBe(true); + }); + + it.each([ + '', + '1'.repeat(21), + '(11) 99999-0000 ramal 12', + '11.99999.0000', + '11/99999', + 'telefone', + '+-() ', + '---', + ])('rejects %j', (phone) => { + expect(isValidPhone(phone)).toBe(false); + }); +}); + +describe('isValidEmail', () => { + it.each(['maria@example.com', 'maria.souza+agenda@mail.example.com.br', 'a@b.co'])( + 'accepts %j', + (email) => { + expect(isValidEmail(email)).toBe(true); + }, + ); + + it.each([ + '', + 'maria', + 'maria@', + '@example.com', + 'maria@example', + 'maria@@example.com', + 'maria@example..com', + 'maria@.example.com', + 'maria@example.com.', + 'maria souza@example.com', + `${'a'.repeat(250)}@example.com`, + ])('rejects %j', (email) => { + expect(isValidEmail(email)).toBe(false); + }); +}); diff --git a/apps/admin-frontend/src/features/clients/model/contactFormats.ts b/apps/admin-frontend/src/features/clients/model/contactFormats.ts new file mode 100644 index 00000000..1f36492c --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/contactFormats.ts @@ -0,0 +1,17 @@ +export const PHONE_MAX_LENGTH = 20; +export const EMAIL_MAX_LENGTH = 254; + +export function isValidPhone(value: string): boolean { + return value.length <= PHONE_MAX_LENGTH && /\d/.test(value) && /^[\d +()-]+$/.test(value); +} + +export function isValidEmail(value: string): boolean { + if (value.length > EMAIL_MAX_LENGTH || /\s/.test(value)) return false; + + const parts = value.split('@'); + if (parts.length !== 2) return false; + + const [local = '', domain = ''] = parts; + const labels = domain.split('.'); + return local.length > 0 && labels.length >= 2 && labels.every((label) => label.length > 0); +} diff --git a/apps/admin-frontend/src/features/clients/model/cpf.test.ts b/apps/admin-frontend/src/features/clients/model/cpf.test.ts new file mode 100644 index 00000000..c38d698c --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/cpf.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { formatCpf, isValidCpf, stripCpfMask } from './cpf'; + +describe('isValidCpf', () => { + it.each([ + '529.982.247-25', + '52998224725', + ' 529.982.247-25 ', + '123.456.789-09', + '111.444.777-35', + ])('accepts %s', (cpf) => { + expect(isValidCpf(cpf)).toBe(true); + }); + + it.each([ + '', + '529.982.247-24', + '529.982.247-52', + '123.456.789-00', + '5299822472', + '529982247255', + '000.000.000-00', + '111.111.111-11', + '999.999.999-99', + 'abc.def.ghi-jk', + '529.982.247-2a', + ])('rejects %j', (cpf) => { + expect(isValidCpf(cpf)).toBe(false); + }); +}); + +describe('stripCpfMask', () => { + it('removes dots, hyphens and spaces only', () => { + expect(stripCpfMask(' 529.982.247-25 ')).toBe('52998224725'); + expect(stripCpfMask('529 982 247 25')).toBe('52998224725'); + }); +}); + +describe('formatCpf', () => { + it.each([ + ['', ''], + ['5', '5'], + ['529', '529'], + ['5299', '529.9'], + ['529982', '529.982'], + ['5299822', '529.982.2'], + ['529982247', '529.982.247'], + ['5299822472', '529.982.247-2'], + ['52998224725', '529.982.247-25'], + ['529982247259999', '529.982.247-25'], + ['529.982.247-25', '529.982.247-25'], + ['abc529x', '529'], + ] as const)('formats %j as %j while typing', (typed, expected) => { + expect(formatCpf(typed)).toBe(expected); + }); +}); diff --git a/apps/admin-frontend/src/features/clients/model/cpf.ts b/apps/admin-frontend/src/features/clients/model/cpf.ts new file mode 100644 index 00000000..57abdb4c --- /dev/null +++ b/apps/admin-frontend/src/features/clients/model/cpf.ts @@ -0,0 +1,34 @@ +const CPF_LENGTH = 11; + +export function stripCpfMask(value: string): string { + return value.replace(/[.\-\s]/g, ''); +} + +export function isValidCpf(value: string): boolean { + const digits = stripCpfMask(value); + if (!/^\d{11}$/.test(digits) || /^(\d)\1{10}$/.test(digits)) return false; + + return ( + checkDigit(digits, 9) === Number(digits.charAt(9)) && + checkDigit(digits, 10) === Number(digits.charAt(10)) + ); +} + +export function formatCpf(value: string): string { + const digits = value.replace(/\D/g, '').slice(0, CPF_LENGTH); + + if (digits.length <= 3) return digits; + if (digits.length <= 6) return `${digits.slice(0, 3)}.${digits.slice(3)}`; + if (digits.length <= 9) return `${digits.slice(0, 3)}.${digits.slice(3, 6)}.${digits.slice(6)}`; + return `${digits.slice(0, 3)}.${digits.slice(3, 6)}.${digits.slice(6, 9)}-${digits.slice(9)}`; +} + +function checkDigit(digits: string, length: number): number { + let sum = 0; + for (let index = 0; index < length; index += 1) { + sum += Number(digits.charAt(index)) * (length + 1 - index); + } + + const remainder = (sum * 10) % 11; + return remainder === 10 ? 0 : remainder; +} diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx new file mode 100644 index 00000000..0a33a147 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx @@ -0,0 +1,671 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; +import { toast } from '@/shared/ui/toast'; +import type { ApiProblem } from '@/shared/api/servicesFacade'; +import type { Client } from '../../../model/client'; +import { ClientFormPage } from './ClientFormPage'; + +const { mockCreate } = vi.hoisted(() => ({ mockCreate: vi.fn() })); + +vi.mock('../../../api/clientsRepository', () => ({ + clientsRepository: { create: mockCreate }, +})); + +const EXISTING_ID = '0197f2a0-0000-7000-8000-000000000001'; + +const CREATED: Client = { + id: '0197f2a0-0000-7000-8000-0000000000aa', + fullName: 'Maria Souza', + birthDate: null, + phone: null, + email: null, + cpf: null, + administrativeNotes: null, + status: 'active', + guardians: [], + referenceContacts: [], +}; + +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}
; +} + +function renderPage() { + return render( + + + } /> + Lista de pessoas} /> + + + , + ); +} + +function problem(overrides: Partial): { ok: false; error: ApiProblem } { + return { + ok: false, + error: { status: 400, title: 'Ocorreram erros de validação.', ...overrides }, + }; +} + +function lastPayload() { + return mockCreate.mock.calls.at(-1)?.[0] as Record; +} + +describe('ClientFormPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ now: new Date('2026-10-02T15:00:00Z'), toFake: ['Date'] }); + mockCreate.mockResolvedValue({ ok: true, data: CREATED }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('layout', () => { + it('opens as a full page with the person fields and both contact sections', () => { + renderPage(); + + expect(screen.getByRole('heading', { name: 'Nova pessoa' })).toBeInTheDocument(); + for (const label of [ + 'Nome completo', + 'Data de nascimento', + 'Telefone', + 'E-mail', + 'CPF', + 'Observações administrativas', + ]) { + expect(screen.getByLabelText(label)).toBeInTheDocument(); + } + expect(screen.getByRole('heading', { name: 'Responsáveis' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Pessoas de referência' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Adicionar responsável' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Adicionar pessoa de referência' }), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Cancelar' })).toHaveAttribute('href', '/pessoas'); + expect(screen.getByRole('link', { name: 'Voltar para Pessoas' })).toHaveAttribute( + 'href', + '/pessoas', + ); + }); + + it('warns that notes are administrative only', () => { + renderPage(); + + expect(screen.getByText(/Não registre prontuário, diagnóstico/)).toBeInTheDocument(); + }); + + it('starts with no contacts and never asks whether the person is a minor', () => { + renderPage(); + + expect(screen.getByText('Nenhum responsável adicionado ainda.')).toBeInTheDocument(); + expect( + screen.getByText('Nenhuma pessoa de referência adicionada ainda.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + }); + + describe('registering an adult or a person without a birth date', () => { + it('saves a person described only by the name, then returns to the Pessoas area', async () => { + const user = userEvent.setup(); + const toastAdd = vi.spyOn(toast, 'add'); + renderPage(); + + await user.type(screen.getByLabelText('Nome completo'), ' Maria Souza '); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/pessoas')); + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(lastPayload()).toEqual({ + fullName: 'Maria Souza', + birthDate: null, + phone: null, + email: null, + cpf: null, + administrativeNotes: null, + guardians: [], + referenceContacts: [], + }); + expect(toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Pessoa cadastrada', type: 'success' }), + ); + expect(await screen.findByRole('heading', { name: 'Lista de pessoas' })).toBeInTheDocument(); + }); + + it('sends every field normalized: ISO birth date, CPF without mask, lowercase e-mail', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza'); + await user.type(screen.getByLabelText('Data de nascimento'), '20051990'); + await user.type(screen.getByLabelText('Telefone'), '(11) 99999-0000'); + await user.type(screen.getByLabelText('E-mail'), 'Maria.Souza@Example.COM'); + await user.type(screen.getByLabelText('CPF'), '52998224725'); + await user.type( + screen.getByLabelText('Observações administrativas'), + 'Prefere contato pela manhã.', + ); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(lastPayload()).toEqual({ + fullName: 'Maria Souza', + birthDate: '1990-05-20', + phone: '(11) 99999-0000', + email: 'maria.souza@example.com', + cpf: '52998224725', + administrativeNotes: 'Prefere contato pela manhã.', + guardians: [], + referenceContacts: [], + }); + }); + + it('masks the CPF and the birth date while they are typed and shows the calculated age', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('CPF'), '52998224725'); + await user.type(screen.getByLabelText('Data de nascimento'), '20051990'); + + expect(screen.getByLabelText('CPF')).toHaveValue('529.982.247-25'); + expect(screen.getByLabelText('Data de nascimento')).toHaveValue('20/05/1990'); + expect(screen.getByText('36 anos')).toBeInTheDocument(); + }); + + it('does not demand a guardian for an adult', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza'); + await user.type(screen.getByLabelText('Data de nascimento'), '02102008'); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + }); + + describe('local validation', () => { + it('blocks the submit and explains the missing name', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + expect(await screen.findByText('O nome completo é obrigatório.')).toBeInTheDocument(); + expect(screen.getByLabelText('Nome completo')).toHaveAttribute('aria-invalid', 'true'); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('keeps what was typed while reporting field errors in pt-BR', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza'); + await user.type(screen.getByLabelText('Telefone'), 'telefone'); + await user.type(screen.getByLabelText('E-mail'), 'maria@example'); + await user.type(screen.getByLabelText('CPF'), '52998224724'); + await user.type(screen.getByLabelText('Data de nascimento'), '31022020'); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + expect(await screen.findByText('Informe um e-mail válido.')).toBeInTheDocument(); + expect(screen.getByText('Informe um CPF válido.')).toBeInTheDocument(); + expect(screen.getByText(/Informe um telefone válido/)).toBeInTheDocument(); + expect( + screen.getByText('Informe uma data válida no formato dd/mm/aaaa.'), + ).toBeInTheDocument(); + expect(screen.getByLabelText('Nome completo')).toHaveValue('Maria Souza'); + expect(screen.getByLabelText('E-mail')).toHaveValue('maria@example'); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it.each([ + ['02102026', 'A data de nascimento deve estar no passado.'], + ['01011905', 'A data de nascimento não pode indicar idade superior a 120 anos.'], + ])('rejects the birth date %s', async (digits, message) => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza'); + await user.type(screen.getByLabelText('Data de nascimento'), digits); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + expect(await screen.findByText(message)).toBeInTheDocument(); + expect(mockCreate).not.toHaveBeenCalled(); + }); + }); + + describe('minors and their guardians', () => { + it('says a guardian is needed as soon as the birth date indicates a minor', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Data de nascimento'), '10032015'); + + expect(screen.getByRole('status')).toHaveTextContent( + 'Esta pessoa tem menos de 18 anos. Informe ao menos um responsável.', + ); + }); + + it('refuses to save a minor without a guardian and keeps the form', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Nome completo'), 'Joana Souza'); + await user.type(screen.getByLabelText('Data de nascimento'), '10032015'); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Informe ao menos um responsável para pessoas menores de 18 anos.', + ); + expect(mockCreate).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Nome completo')).toHaveValue('Joana Souza'); + }); + + it('moves focus to the way out of the problem when only the guardian is missing', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Nome completo'), 'Joana Souza'); + await user.type(screen.getByLabelText('Data de nascimento'), '10032015'); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + await screen.findByRole('alert'); + expect(screen.getByRole('button', { name: 'Adicionar responsável' })).toHaveFocus(); + }); + + it('keeps focus on the first invalid field when something else is wrong too', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Data de nascimento'), '10032015'); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + await screen.findByText('O nome completo é obrigatório.'); + expect(screen.getByLabelText('Nome completo')).toHaveFocus(); + }); + + it('stops offering more guardians at the limit', async () => { + const user = userEvent.setup(); + renderPage(); + const add = screen.getByRole('button', { name: 'Adicionar responsável' }); + + for (let count = 0; count < 10; count += 1) await user.click(add); + + expect(screen.getAllByRole('group', { name: /^Responsável \d+$/ })).toHaveLength(10); + expect(add).toBeDisabled(); + }); + + it('saves a minor once a valid guardian is informed', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Nome completo'), 'Joana Souza'); + await user.type(screen.getByLabelText('Data de nascimento'), '10032015'); + await user.click(screen.getByRole('button', { name: 'Adicionar responsável' })); + + const guardian = within(screen.getByRole('group', { name: 'Responsável 1' })); + await user.type(guardian.getByLabelText('Nome'), 'Ana Souza'); + await user.type(guardian.getByLabelText('Vínculo'), 'Mãe'); + await user.type(guardian.getByLabelText('Telefone'), '11 98888-0000'); + await user.type(guardian.getByLabelText('CPF'), '12345678909'); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(lastPayload()).toMatchObject({ + birthDate: '2015-03-10', + guardians: [ + { name: 'Ana Souza', relationship: 'Mãe', phone: '11 98888-0000', cpf: '12345678909' }, + ], + }); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('reports a guardian missing name and relationship on that guardian', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Nome completo'), 'Joana Souza'); + await user.click(screen.getByRole('button', { name: 'Adicionar responsável' })); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + const guardian = within(await screen.findByRole('group', { name: 'Responsável 1' })); + expect(await guardian.findByText('O nome do responsável é obrigatório.')).toBeInTheDocument(); + expect(guardian.getByText('O vínculo do responsável é obrigatório.')).toBeInTheDocument(); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('lets a guardian be removed from the form', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza'); + await user.click(screen.getByRole('button', { name: 'Adicionar responsável' })); + await user.click(screen.getByRole('button', { name: 'Adicionar responsável' })); + expect(screen.getAllByRole('group', { name: /^Responsável \d$/ })).toHaveLength(2); + + await user.click(screen.getByRole('button', { name: 'Remover responsável 1' })); + + expect(screen.getAllByRole('group', { name: /^Responsável \d$/ })).toHaveLength(1); + await user.click(screen.getByRole('button', { name: 'Remover responsável 1' })); + expect(screen.getByText('Nenhum responsável adicionado ainda.')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Salvar' })); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(lastPayload()).toMatchObject({ guardians: [] }); + }); + }); + + describe('reference contacts', () => { + async function addReferenceContact(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'Adicionar pessoa de referência' })); + return within(screen.getByRole('group', { name: 'Pessoa de referência 1' })); + } + + it('requires at least one purpose', async () => { + const user = userEvent.setup(); + renderPage(); + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza'); + const contact = await addReferenceContact(user); + await user.type(contact.getByLabelText('Nome'), 'Carlos Lima'); + await user.type(contact.getByLabelText('Vínculo'), 'Tio'); + + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + expect( + await contact.findByText('Informe ao menos uma finalidade para a pessoa de referência.'), + ).toBeInTheDocument(); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('offers the three purposes and sends the chosen ones', async () => { + const user = userEvent.setup(); + renderPage(); + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza'); + const contact = await addReferenceContact(user); + await user.type(contact.getByLabelText('Nome'), 'Carlos Lima'); + await user.type(contact.getByLabelText('Vínculo'), 'Tio'); + await user.type(contact.getByLabelText('Telefone'), '11 4000-1000'); + + expect( + contact.getAllByRole('checkbox').map((box) => box.getAttribute('aria-checked')), + ).toEqual(['false', 'false', 'false']); + await user.click(contact.getByRole('checkbox', { name: 'Emergência' })); + await user.click(contact.getByRole('checkbox', { name: 'Comunicação cotidiana' })); + expect(contact.getByRole('checkbox', { name: 'Apoio operacional' })).not.toBeChecked(); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(lastPayload()).toMatchObject({ + guardians: [], + referenceContacts: [ + { + name: 'Carlos Lima', + relationship: 'Tio', + phone: '11 4000-1000', + purposes: ['emergency', 'dailyCommunication'], + }, + ], + }); + }); + + it('keeps the contact separate from the person: it never becomes a guardian', async () => { + const user = userEvent.setup(); + renderPage(); + + await addReferenceContact(user); + + expect(screen.getByText('Nenhum responsável adicionado ainda.')).toBeInTheDocument(); + expect(screen.queryByRole('group', { name: 'Responsável 1' })).not.toBeInTheDocument(); + }); + + it('lets a reference contact be removed', async () => { + const user = userEvent.setup(); + renderPage(); + await addReferenceContact(user); + + await user.click(screen.getByRole('button', { name: 'Remover pessoa de referência 1' })); + + expect( + screen.getByText('Nenhuma pessoa de referência adicionada ainda.'), + ).toBeInTheDocument(); + }); + }); + + describe('backend responses', () => { + async function fillAndSubmit(user: ReturnType) { + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza'); + await user.type(screen.getByLabelText('CPF'), '52998224725'); + await user.type(screen.getByLabelText('E-mail'), 'maria@example.com'); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + } + + it('shows the duplicate CPF message on the CPF field, focuses it and offers the existing record', async () => { + const user = userEvent.setup(); + mockCreate.mockResolvedValue( + problem({ + status: 409, + code: 'Client.DuplicateCpf', + title: 'Já existe uma pessoa cadastrada com este CPF.', + errors: { + Cpf: [ + { + code: 'Client.DuplicateCpf', + message: 'Já existe uma pessoa cadastrada com este CPF.', + meta: { clientId: EXISTING_ID }, + }, + ], + }, + }), + ); + renderPage(); + + await fillAndSubmit(user); + + expect( + await screen.findByText('Já existe uma pessoa cadastrada com este CPF.'), + ).toBeInTheDocument(); + expect(screen.getByLabelText('CPF')).toHaveAttribute('aria-invalid', 'true'); + await waitFor(() => expect(screen.getByLabelText('CPF')).toHaveFocus()); + const link = screen.getByRole('link', { name: /Abrir cadastro existente/ }); + expect(link).toHaveAttribute('href', `/pessoas/${EXISTING_ID}`); + expect(link).toHaveAttribute('target', '_blank'); + expect(screen.getByLabelText('Nome completo')).toHaveValue('Maria Souza'); + expect(screen.getByLabelText('E-mail')).toHaveValue('maria@example.com'); + expect(screen.getByTestId('location')).toHaveTextContent('/pessoas/nova'); + }); + + it('stops offering the existing record once the CPF is changed', async () => { + const user = userEvent.setup(); + mockCreate.mockResolvedValue( + problem({ + status: 409, + code: 'Client.DuplicateCpf', + errors: { + Cpf: [ + { + code: 'Client.DuplicateCpf', + message: 'Já existe uma pessoa cadastrada com este CPF.', + meta: { clientId: EXISTING_ID }, + }, + ], + }, + }), + ); + renderPage(); + await fillAndSubmit(user); + await screen.findByRole('link', { name: /Abrir cadastro existente/ }); + + await user.type(screen.getByLabelText('CPF'), '{Backspace}'); + + expect( + screen.queryByRole('link', { name: /Abrir cadastro existente/ }), + ).not.toBeInTheDocument(); + }); + + it('explains a CPF that belongs to a deleted record, with nothing to open', async () => { + const user = userEvent.setup(); + mockCreate.mockResolvedValue( + problem({ + status: 409, + code: 'Client.DuplicateCpf', + errors: { + Cpf: [ + { + code: 'Client.DuplicateCpf', + message: + 'Este CPF pertence a um cadastro excluído e não pode ser usado em um novo cadastro.', + }, + ], + }, + }), + ); + renderPage(); + + await fillAndSubmit(user); + + expect(await screen.findByText(/pertence a um cadastro excluído/)).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: /Abrir cadastro existente/ }), + ).not.toBeInTheDocument(); + }); + + it('shows a duplicate e-mail on the e-mail field and keeps everything typed', async () => { + const user = userEvent.setup(); + mockCreate.mockResolvedValue( + problem({ + status: 409, + code: 'Client.DuplicateEmail', + errors: { + Email: [ + { + code: 'Client.DuplicateEmail', + message: 'Já existe uma pessoa ativa cadastrada com este e-mail.', + }, + ], + }, + }), + ); + renderPage(); + + await fillAndSubmit(user); + + expect( + await screen.findByText('Já existe uma pessoa ativa cadastrada com este e-mail.'), + ).toBeInTheDocument(); + expect(screen.getByLabelText('E-mail')).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByLabelText('CPF')).toHaveValue('529.982.247-25'); + }); + + it('maps indexed backend errors to the matching contact field', async () => { + const user = userEvent.setup(); + mockCreate.mockResolvedValue( + problem({ + code: 'Validation.Failed', + errors: { + 'Guardians[0].Name': [{ message: 'O nome do responsável é obrigatório.' }], + 'ReferenceContacts[0].Purposes': [{ message: 'Informe ao menos uma finalidade.' }], + }, + }), + ); + renderPage(); + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza'); + await user.click(screen.getByRole('button', { name: 'Adicionar responsável' })); + const guardian = within(screen.getByRole('group', { name: 'Responsável 1' })); + await user.type(guardian.getByLabelText('Nome'), 'Ana'); + await user.type(guardian.getByLabelText('Vínculo'), 'Mãe'); + await user.click(screen.getByRole('button', { name: 'Adicionar pessoa de referência' })); + const contact = within(screen.getByRole('group', { name: 'Pessoa de referência 1' })); + await user.type(contact.getByLabelText('Nome'), 'Carlos'); + await user.type(contact.getByLabelText('Vínculo'), 'Tio'); + await user.click(contact.getByRole('checkbox', { name: 'Emergência' })); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + expect(await guardian.findByText('O nome do responsável é obrigatório.')).toBeInTheDocument(); + expect(guardian.getByLabelText('Nome')).toHaveAttribute('aria-invalid', 'true'); + expect(contact.getByText('Informe ao menos uma finalidade.')).toBeInTheDocument(); + expect(guardian.getByLabelText('Nome')).toHaveValue('Ana'); + }); + + it('shows an error with no field in a banner and keeps the form', async () => { + const user = userEvent.setup(); + mockCreate.mockResolvedValue( + problem({ + status: 0, + code: 'Network.Unreachable', + title: 'Sem conexão com o servidor. Tente novamente.', + }), + ); + renderPage(); + + await fillAndSubmit(user); + + const banner = await screen.findByRole('alert'); + expect(banner).toHaveTextContent('Sem conexão com o servidor. Tente novamente.'); + expect( + banner.compareDocumentPosition(screen.getByRole('button', { name: 'Salvar' })) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + screen + .getByRole('heading', { name: 'Pessoas de referência' }) + .compareDocumentPosition(banner) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getByLabelText('Nome completo')).toHaveValue('Maria Souza'); + expect(screen.getByTestId('location')).toHaveTextContent('/pessoas/nova'); + }); + + it('can be submitted again after fixing the problem', async () => { + const user = userEvent.setup(); + mockCreate.mockResolvedValueOnce( + problem({ + status: 409, + code: 'Client.DuplicateEmail', + errors: { + Email: [{ message: 'Já existe uma pessoa ativa cadastrada com este e-mail.' }], + }, + }), + ); + renderPage(); + await fillAndSubmit(user); + await screen.findByText('Já existe uma pessoa ativa cadastrada com este e-mail.'); + + await user.clear(screen.getByLabelText('E-mail')); + await user.type(screen.getByLabelText('E-mail'), 'outro@example.com'); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/pessoas')); + expect(mockCreate).toHaveBeenCalledTimes(2); + expect(lastPayload()).toMatchObject({ email: 'outro@example.com' }); + }); + }); + + describe('while saving', () => { + it('shows progress, locks the exits and sends the request only once', async () => { + const user = userEvent.setup(); + let resolveCreate!: (value: unknown) => void; + mockCreate.mockReturnValue(new Promise((resolve) => (resolveCreate = resolve))); + renderPage(); + await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza'); + + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + const saving = await screen.findByRole('button', { name: 'Salvando…' }); + expect(saving).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Cancelar' })).toBeDisabled(); + expect(screen.queryByRole('link', { name: 'Cancelar' })).not.toBeInTheDocument(); + await user.click(saving); + expect(mockCreate).toHaveBeenCalledTimes(1); + + resolveCreate({ ok: true, data: CREATED }); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/pessoas')); + }); + }); +}); diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.tsx new file mode 100644 index 00000000..0065e377 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.tsx @@ -0,0 +1,49 @@ +import { FormProvider } from 'react-hook-form'; +import { FormErrorBanner } from '@/shared/form/fields'; +import { ClientFormFooter } from './components/ClientFormFooter'; +import { ClientFormHeader } from './components/ClientFormHeader'; +import { ClientGuardiansSection } from './components/ClientGuardiansSection'; +import { ClientPersonSection } from './components/ClientPersonSection'; +import { ClientReferenceContactsSection } from './components/ClientReferenceContactsSection'; +import { useClientFormPage } from './useClientFormPage'; + +export function ClientFormPage() { + const { + methods, + guardians, + referenceContacts, + age, + isMinor, + existingClientId, + isSaving, + addGuardianButtonRef, + onSubmit, + addGuardian, + addReferenceContact, + } = useClientFormPage(); + + return ( +
+ + +
+ + field.id)} + isMinor={isMinor} + addButtonRef={addGuardianButtonRef} + onAdd={addGuardian} + onRemove={guardians.remove} + /> + field.id)} + onAdd={addReferenceContact} + onRemove={referenceContacts.remove} + /> + + + +
+
+ ); +} diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormFooter.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormFooter.tsx new file mode 100644 index 00000000..617684ac --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormFooter.tsx @@ -0,0 +1,28 @@ +import { Link } from 'react-router'; +import { ActionButton } from '@/shared/ui/action-button'; +import { Button, buttonVariants } from '@/shared/ui/button'; + +interface ClientFormFooterProps { + isSaving: boolean; +} + +function ClientFormFooter({ isSaving }: ClientFormFooterProps) { + return ( +
+ {isSaving ? ( + + ) : ( + + Cancelar + + )} + + {isSaving ? 'Salvando…' : 'Salvar'} + +
+ ); +} + +export { ClientFormFooter }; diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormHeader.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormHeader.tsx new file mode 100644 index 00000000..5ec9bf47 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormHeader.tsx @@ -0,0 +1,22 @@ +import { ArrowLeftIcon } from 'lucide-react'; +import { Link } from 'react-router'; +import { buttonVariants } from '@/shared/ui/button'; + +function ClientFormHeader() { + return ( +
+ +
+ ); +} + +export { ClientFormHeader }; diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormSection.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormSection.tsx new file mode 100644 index 00000000..51b1d9ba --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormSection.tsx @@ -0,0 +1,32 @@ +import { useId, type ReactNode } from 'react'; + +interface ClientFormSectionProps { + title: string; + description: string; + action?: ReactNode; + children: ReactNode; +} + +function ClientFormSection({ title, description, action, children }: ClientFormSectionProps) { + const headingId = useId(); + + return ( +
+
+
+

+ {title} +

+

{description}

+
+ {action &&
{action}
} +
+ {children} +
+ ); +} + +export { ClientFormSection }; diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientGuardianItem.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientGuardianItem.tsx new file mode 100644 index 00000000..91c5d7ee --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientGuardianItem.tsx @@ -0,0 +1,82 @@ +import { useId } from 'react'; +import { XIcon } from 'lucide-react'; +import { MaskedField, TextField } from '@/shared/form/fields'; +import { Button } from '@/shared/ui/button'; +import { + CLIENT_NAME_MAX_LENGTH, + CONTACT_RELATIONSHIP_MAX_LENGTH, + type ClientFormFieldValues, +} from '../../../../model/clientForm'; +import { PHONE_MAX_LENGTH } from '../../../../model/contactFormats'; +import { formatCpf } from '../../../../model/cpf'; + +interface ClientGuardianItemProps { + index: number; + onRemove: () => void; +} + +function ClientGuardianItem({ index, onRemove }: ClientGuardianItemProps) { + const headingId = useId(); + const position = index + 1; + + return ( +
+
+

+ Responsável {position} +

+ +
+ +
+ + name={`guardians.${index}.name`} + label="Nome" + hint="obrigatório" + maxLength={CLIENT_NAME_MAX_LENGTH} + autoComplete="off" + /> + + name={`guardians.${index}.relationship`} + label="Vínculo" + hint="obrigatório" + maxLength={CONTACT_RELATIONSHIP_MAX_LENGTH} + placeholder="Ex.: mãe, pai, tutor legal" + autoComplete="off" + /> + + name={`guardians.${index}.phone`} + label="Telefone" + hint="opcional" + type="tel" + maxLength={PHONE_MAX_LENGTH} + autoComplete="off" + /> + + name={`guardians.${index}.cpf`} + label="CPF" + hint="opcional" + mask={formatCpf} + inputMode="numeric" + placeholder="000.000.000-00" + autoComplete="off" + /> +
+
+ ); +} + +export { ClientGuardianItem }; diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientGuardiansSection.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientGuardiansSection.tsx new file mode 100644 index 00000000..6623ac5a --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientGuardiansSection.tsx @@ -0,0 +1,73 @@ +import type { RefObject } from 'react'; +import { PlusIcon } from 'lucide-react'; +import { useFormContext } from 'react-hook-form'; +import { Button } from '@/shared/ui/button'; +import { ADULT_AGE_IN_YEARS } from '../../../../model/birthDate'; +import { MAX_GUARDIANS, type ClientFormFieldValues } from '../../../../model/clientForm'; +import { ClientFormSection } from './ClientFormSection'; +import { ClientGuardianItem } from './ClientGuardianItem'; + +interface ClientGuardiansSectionProps { + fieldIds: string[]; + isMinor: boolean; + addButtonRef: RefObject; + onAdd: () => void; + onRemove: (index: number) => void; +} + +function ClientGuardiansSection({ + fieldIds, + isMinor, + addButtonRef, + onAdd, + onRemove, +}: ClientGuardiansSectionProps) { + const { + formState: { errors }, + } = useFormContext(); + const listError = errors.guardians?.root?.message ?? errors.guardians?.message; + + return ( + = MAX_GUARDIANS} + onClick={onAdd} + > + + ); +} + +export { ClientGuardiansSection }; diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientPersonSection.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientPersonSection.tsx new file mode 100644 index 00000000..0a54998f --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientPersonSection.tsx @@ -0,0 +1,108 @@ +import { Link } from 'react-router'; +import { MaskedField, TextField, TextareaField } from '@/shared/form/fields'; +import { buttonVariants } from '@/shared/ui/button'; +import { formatAge, maskBirthDate } from '../../../../model/birthDate'; +import { + CLIENT_NAME_MAX_LENGTH, + CLIENT_NOTES_MAX_LENGTH, + type ClientFormFieldValues, +} from '../../../../model/clientForm'; +import { EMAIL_MAX_LENGTH, PHONE_MAX_LENGTH } from '../../../../model/contactFormats'; +import { formatCpf } from '../../../../model/cpf'; +import { ClientFormSection } from './ClientFormSection'; + +interface ClientPersonSectionProps { + age: number | null; + existingClientId: string | null; +} + +function ClientPersonSection({ age, existingClientId }: ClientPersonSectionProps) { + return ( + +
+
+ + name="fullName" + label="Nome completo" + hint="obrigatório" + maxLength={CLIENT_NAME_MAX_LENGTH} + autoComplete="off" + autoFocus + /> +
+ + + name="birthDate" + label="Data de nascimento" + hint={age === null ? 'opcional' : formatAge(age)} + mask={maskBirthDate} + inputMode="numeric" + placeholder="dd/mm/aaaa" + autoComplete="off" + /> + + + name="phone" + label="Telefone" + hint="opcional" + type="tel" + maxLength={PHONE_MAX_LENGTH} + placeholder="(11) 91234-5678" + autoComplete="off" + /> + + + name="email" + label="E-mail" + hint="opcional" + type="email" + maxLength={EMAIL_MAX_LENGTH} + placeholder="nome@exemplo.com" + autoComplete="off" + /> + +
+ + name="cpf" + label="CPF" + hint="opcional" + mask={formatCpf} + inputMode="numeric" + placeholder="000.000.000-00" + autoComplete="off" + /> + {existingClientId && ( + + Abrir cadastro existente + (abre em outra aba) + + )} +
+ +
+ + name="administrativeNotes" + label="Observações administrativas" + hint={`opcional · até ${CLIENT_NOTES_MAX_LENGTH} caracteres`} + maxLength={CLIENT_NOTES_MAX_LENGTH} + placeholder="Ex.: prefere contato por WhatsApp pela manhã." + /> +

+ Use só para informações administrativas. Não registre prontuário, diagnóstico ou outros + dados clínicos. +

+
+
+
+ ); +} + +export { ClientPersonSection }; diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientReferenceContactItem.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientReferenceContactItem.tsx new file mode 100644 index 00000000..fc2ec1ce --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientReferenceContactItem.tsx @@ -0,0 +1,93 @@ +import { useId } from 'react'; +import { XIcon } from 'lucide-react'; +import { ControlledField, TextField } from '@/shared/form/fields'; +import { Button } from '@/shared/ui/button'; +import { CheckboxGroup } from '@/shared/ui/checkbox-group'; +import { CONTACT_PURPOSE_OPTIONS } from '../../../../model/client'; +import { + CLIENT_NAME_MAX_LENGTH, + CONTACT_RELATIONSHIP_MAX_LENGTH, + type ClientFormFieldValues, +} from '../../../../model/clientForm'; +import { PHONE_MAX_LENGTH } from '../../../../model/contactFormats'; + +interface ClientReferenceContactItemProps { + index: number; + onRemove: () => void; +} + +function ClientReferenceContactItem({ index, onRemove }: ClientReferenceContactItemProps) { + const headingId = useId(); + const position = index + 1; + + return ( +
+
+

+ Pessoa de referência {position} +

+ +
+ +
+ + name={`referenceContacts.${index}.name`} + label="Nome" + hint="obrigatório" + maxLength={CLIENT_NAME_MAX_LENGTH} + autoComplete="off" + /> + + name={`referenceContacts.${index}.relationship`} + label="Vínculo" + hint="obrigatório" + maxLength={CONTACT_RELATIONSHIP_MAX_LENGTH} + placeholder="Ex.: tio, vizinha, amigo" + autoComplete="off" + /> + + name={`referenceContacts.${index}.phone`} + label="Telefone" + hint="opcional" + type="tel" + maxLength={PHONE_MAX_LENGTH} + autoComplete="off" + /> + + name={`referenceContacts.${index}.purposes`} + label="Finalidades" + hint="escolha ao menos uma" + labelHtmlFor={false} + > + {(field, controlProps) => ( + + )} + +
+
+ ); +} + +export { ClientReferenceContactItem }; diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientReferenceContactsSection.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientReferenceContactsSection.tsx new file mode 100644 index 00000000..76845db1 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientReferenceContactsSection.tsx @@ -0,0 +1,62 @@ +import { PlusIcon } from 'lucide-react'; +import { useFormContext } from 'react-hook-form'; +import { Button } from '@/shared/ui/button'; +import { MAX_REFERENCE_CONTACTS, type ClientFormFieldValues } from '../../../../model/clientForm'; +import { ClientFormSection } from './ClientFormSection'; +import { ClientReferenceContactItem } from './ClientReferenceContactItem'; + +interface ClientReferenceContactsSectionProps { + fieldIds: string[]; + onAdd: () => void; + onRemove: (index: number) => void; +} + +function ClientReferenceContactsSection({ + fieldIds, + onAdd, + onRemove, +}: ClientReferenceContactsSectionProps) { + const { + formState: { errors }, + } = useFormContext(); + const listError = errors.referenceContacts?.root?.message ?? errors.referenceContacts?.message; + + return ( + = MAX_REFERENCE_CONTACTS} + onClick={onAdd} + > + + ); +} + +export { ClientReferenceContactsSection }; diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.ts b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.ts new file mode 100644 index 00000000..9c20d4ee --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.ts @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState, type SubmitEvent } from 'react'; +import { useNavigate } from 'react-router'; +import { useFieldArray, useForm, useWatch, type FieldErrors, type Path } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { toFormErrors } from '@/shared/api/formErrors'; +import { applyApiProblem } from '@/shared/form/applyApiProblem'; +import { toast } from '@/shared/ui/toast'; +import { clientsRepository } from '../../../api/clientsRepository'; +import { existingClientIdFrom } from '../../../api/existingClientId'; +import { ADULT_AGE_IN_YEARS } from '../../../model/birthDate'; +import { + CLIENT_FORM_FIELDS, + GUARDIAN_FORM_FIELDS, + REFERENCE_CONTACT_FORM_FIELDS, + ageFromBirthDate, + clientFormSchema, + emptyGuardian, + emptyReferenceContact, + toClientFormFieldValues, + type ClientFormFieldValues, + type ClientFormValues, +} from '../../../model/clientForm'; +import type { UseClientFormPageResult } from './useClientFormPage.types'; + +const CLIENTS_PATH = '/pessoas'; + +function fieldPathsOf(values: ClientFormFieldValues): Path[] { + return [ + ...CLIENT_FORM_FIELDS, + ...values.guardians.flatMap((_, index) => + GUARDIAN_FORM_FIELDS.map((field) => `guardians.${index}.${field}` as const), + ), + ...values.referenceContacts.flatMap((_, index) => + REFERENCE_CONTACT_FORM_FIELDS.map((field) => `referenceContacts.${index}.${field}` as const), + ), + ]; +} + +export function useClientFormPage(): UseClientFormPageResult { + const navigate = useNavigate(); + const isMountedRef = useRef(true); + const addGuardianButtonRef = useRef(null); + const [conflict, setConflict] = useState<{ cpf: string; clientId: string } | null>(null); + + const methods = useForm({ + resolver: zodResolver(clientFormSchema), + defaultValues: toClientFormFieldValues(), + }); + const guardians = useFieldArray({ control: methods.control, name: 'guardians' }); + const referenceContacts = useFieldArray({ control: methods.control, name: 'referenceContacts' }); + const birthDate = useWatch({ control: methods.control, name: 'birthDate' }); + const cpf = useWatch({ control: methods.control, name: 'cpf' }); + + const isSaving = methods.formState.isSubmitting; + const age = ageFromBirthDate(birthDate); + const isMinor = age !== null && age < ADULT_AGE_IN_YEARS; + const existingClientId = conflict?.cpf === cpf ? conflict.clientId : null; + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + async function onValid(values: ClientFormValues) { + const result = await clientsRepository.create(values); + + if (result.ok) { + toast.add({ + title: 'Pessoa cadastrada', + description: `${result.data.fullName} foi cadastrada.`, + type: 'success', + }); + if (isMountedRef.current) void navigate(CLIENTS_PATH); + return; + } + + const paths = fieldPathsOf(methods.getValues()); + applyApiProblem(result.error, paths, methods.setError); + + const clientId = existingClientIdFrom(result.error); + setConflict(clientId ? { cpf: methods.getValues('cpf'), clientId } : null); + + const { fieldErrors } = toFormErrors(result.error, paths); + const firstInvalid = paths.find((path) => fieldErrors[path]); + if (firstInvalid) methods.setFocus(firstInvalid); + } + + function onInvalid(errors: FieldErrors) { + const onlyGuardianMissing = Object.keys(errors).join() === 'guardians'; + if (onlyGuardianMissing) addGuardianButtonRef.current?.focus(); + } + + function submit(event: SubmitEvent) { + event.preventDefault(); + if (!isSaving) void methods.handleSubmit(onValid, onInvalid)(event); + } + + return { + methods, + guardians, + referenceContacts, + age, + isMinor, + existingClientId, + isSaving, + addGuardianButtonRef, + onSubmit: submit, + addGuardian: () => guardians.append(emptyGuardian()), + addReferenceContact: () => referenceContacts.append(emptyReferenceContact()), + }; +} diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.types.ts b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.types.ts new file mode 100644 index 00000000..39d10767 --- /dev/null +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.types.ts @@ -0,0 +1,17 @@ +import type { RefObject, SubmitEvent } from 'react'; +import type { UseFieldArrayReturn, UseFormReturn } from 'react-hook-form'; +import type { ClientFormFieldValues, ClientFormValues } from '../../../model/clientForm'; + +export interface UseClientFormPageResult { + methods: UseFormReturn; + guardians: UseFieldArrayReturn; + referenceContacts: UseFieldArrayReturn; + age: number | null; + isMinor: boolean; + existingClientId: string | null; + isSaving: boolean; + addGuardianButtonRef: RefObject; + onSubmit: (event: SubmitEvent) => void; + addGuardian: () => void; + addReferenceContact: () => void; +} diff --git a/apps/admin-frontend/src/shared/api/formErrors.test.ts b/apps/admin-frontend/src/shared/api/formErrors.test.ts index e7ead42a..babc2f59 100644 --- a/apps/admin-frontend/src/shared/api/formErrors.test.ts +++ b/apps/admin-frontend/src/shared/api/formErrors.test.ts @@ -75,6 +75,30 @@ describe('toFormErrors', () => { ); }); + it('maps an indexed FluentValidation key to the matching nested field path', () => { + const problem: ApiProblem = { + status: 400, + title: 'Ocorreram erros de validação.', + errors: { + 'Guardians[1].Name': [{ message: 'O nome do responsável é obrigatório.' }], + 'ReferenceContacts[0].Purposes': [{ message: 'Informe ao menos uma finalidade.' }], + 'Guardians[7].Name': [{ message: 'Um índice que o formulário não tem.' }], + }, + }; + + const { fieldErrors, formError } = toFormErrors(problem, [ + 'guardians.0.name', + 'guardians.1.name', + 'referenceContacts.0.purposes', + ] as const); + + expect(fieldErrors).toEqual({ + 'guardians.1.name': 'O nome do responsável é obrigatório.', + 'referenceContacts.0.purposes': 'Informe ao menos uma finalidade.', + }); + expect(formError).toBe('Um índice que o formulário não tem.'); + }); + it('ignores an error entry with no message', () => { const problem: ApiProblem = { status: 400, diff --git a/apps/admin-frontend/src/shared/api/formErrors.ts b/apps/admin-frontend/src/shared/api/formErrors.ts index 273b0bc9..ffe75ecd 100644 --- a/apps/admin-frontend/src/shared/api/formErrors.ts +++ b/apps/admin-frontend/src/shared/api/formErrors.ts @@ -1,5 +1,7 @@ import type { ApiProblem } from './servicesFacade'; +const toFieldPath = (key: string) => key.replace(/\[(\d+)\]/g, '.$1').toLowerCase(); + export function toFormErrors( problem: ApiProblem, fields: readonly F[], @@ -11,7 +13,7 @@ export function toFormErrors( for (const [key, entries] of Object.entries(problem.errors ?? {})) { const message = entries[0]?.message; if (!message) continue; - const field = byField.get(key.toLowerCase()); + const field = byField.get(toFieldPath(key)); if (field) fieldErrors[field] = message; else formLevel.push(message); } 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 b1e998d6..87108d7f 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 @@ -452,6 +452,109 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v{version}/clients': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path: { + version: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CreateClientCommand']; + 'text/json': components['schemas']['CreateClientCommand']; + 'application/*+json': components['schemas']['CreateClientCommand']; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiResponseOfClientResponse']; + 'application/json': components['schemas']['ApiResponseOfClientResponse']; + 'text/json': components['schemas']['ApiResponseOfClientResponse']; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiProblemDetails']; + 'application/json': components['schemas']['ApiProblemDetails']; + 'text/json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiProblemDetails']; + 'application/json': components['schemas']['ApiProblemDetails']; + 'text/json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v{version}/services': { parameters: { query?: never; @@ -1298,6 +1401,14 @@ export interface components { traceId?: null | string; correlationId?: null | string; }; + ApiResponseOfClientResponse: { + data: null | components['schemas']['ClientResponse']; + success?: boolean; + /** Format: date-time */ + timestamp?: string; + traceId?: null | string; + correlationId?: null | string; + }; ApiResponseOfIReadOnlyListOfCategoryResponse: { data: null | components['schemas']['CategoryResponse'][]; success?: boolean; @@ -1343,9 +1454,34 @@ export interface components { id: string; name: string; }; + ClientResponse: { + /** Format: uuid */ + id: string; + fullName: string; + /** Format: date */ + birthDate: null | string; + phone: null | string; + email: null | string; + cpf: null | string; + administrativeNotes: null | string; + status: string; + guardians: components['schemas']['GuardianResponse'][]; + referenceContacts: components['schemas']['ReferenceContactResponse'][]; + }; CreateCategoryCommand: { name: string; }; + CreateClientCommand: { + fullName: string; + /** Format: date */ + birthDate: null | string; + phone: null | string; + email: null | string; + cpf: null | string; + administrativeNotes: null | string; + guardians: null | components['schemas']['GuardianInput'][]; + referenceContacts: null | components['schemas']['ReferenceContactInput'][]; + }; CreateServiceCommand: { name: string; description: null | string; @@ -1371,6 +1507,23 @@ export interface components { FieldError: { code?: string; message?: string; + meta?: null | { + [key: string]: string; + }; + }; + GuardianInput: { + name: string; + relationship: string; + phone: null | string; + cpf: null | string; + }; + GuardianResponse: { + /** Format: uuid */ + id: string; + name: string; + relationship: string; + phone: null | string; + cpf: null | string; }; PagedResultOfServiceResponse: { items: components['schemas']['ServiceResponse'][]; @@ -1381,6 +1534,20 @@ export interface components { /** Format: int32 */ pageSize: number | string; }; + ReferenceContactInput: { + name: string; + relationship: string; + phone: null | string; + purposes: null | string[]; + }; + ReferenceContactResponse: { + /** Format: uuid */ + id: string; + name: string; + relationship: string; + phone: null | string; + purposes: string[]; + }; ServiceResponse: { /** Format: uuid */ id: string; diff --git a/apps/admin-frontend/src/shared/form/fields/components/masked-field.test.tsx b/apps/admin-frontend/src/shared/form/fields/components/masked-field.test.tsx new file mode 100644 index 00000000..6571263e --- /dev/null +++ b/apps/admin-frontend/src/shared/form/fields/components/masked-field.test.tsx @@ -0,0 +1,55 @@ +import { useEffect } from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormProvider, useForm, useFormContext, useWatch } from 'react-hook-form'; +import { MaskedField } from './masked-field'; + +interface DummyValues { + code: string; +} + +const dashAfterTwo = (value: string) => { + const digits = value.replace(/\D/g, '').slice(0, 4); + return digits.length > 2 ? `${digits.slice(0, 2)}-${digits.slice(2)}` : digits; +}; + +function Harness({ withError = false }: { withError?: boolean }) { + const methods = useForm({ defaultValues: { code: '' } }); + + useEffect(() => { + if (withError) methods.setError('code', { type: 'test', message: 'Erro de teste' }); + }, [withError, methods]); + + return ( + + name="code" label="Código" hint="opcional" mask={dashAfterTwo} /> + + + ); +} + +function CurrentValue() { + const { control } = useFormContext(); + const code = useWatch({ control, name: 'code' }); + return {code}; +} + +describe('MaskedField', () => { + it('formats what is typed with the mask and stores the masked text in the form', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Código'), 'a1b2c3d4e5'); + + expect(screen.getByLabelText('Código')).toHaveValue('12-34'); + expect(screen.getByLabelText('valor do formulário')).toHaveTextContent('12-34'); + }); + + it('shows the hint and the field error tied to the input', () => { + render(); + + expect(screen.getByText('opcional')).toBeInTheDocument(); + expect(screen.getByLabelText('Código')).toHaveAccessibleDescription(/Erro de teste/); + }); +}); diff --git a/apps/admin-frontend/src/shared/form/fields/components/masked-field.tsx b/apps/admin-frontend/src/shared/form/fields/components/masked-field.tsx new file mode 100644 index 00000000..f7149f29 --- /dev/null +++ b/apps/admin-frontend/src/shared/form/fields/components/masked-field.tsx @@ -0,0 +1,28 @@ +import type { FieldValues } from 'react-hook-form'; +import { Input } from '@/shared/ui/input'; +import type { MaskedFieldProps } from '../fields.types'; +import { ControlledField } from './controlled-field'; + +export function MaskedField({ + name, + label, + hint, + mask, + ...inputProps +}: MaskedFieldProps) { + return ( + name={name} label={label} hint={hint}> + {(field, controlProps) => ( + field.onChange(mask(event.target.value))} + /> + )} + + ); +} diff --git a/apps/admin-frontend/src/shared/form/fields/fields.types.ts b/apps/admin-frontend/src/shared/form/fields/fields.types.ts index dcd49319..9ea6b78d 100644 --- a/apps/admin-frontend/src/shared/form/fields/fields.types.ts +++ b/apps/admin-frontend/src/shared/form/fields/fields.types.ts @@ -20,6 +20,11 @@ export type TextFieldProps = BaseFieldProps & export type TextareaFieldProps = BaseFieldProps & Omit, RegisterOwnedProps | FormFieldOwnedProps>; +export type MaskedFieldProps = BaseFieldProps & + Omit, RegisterOwnedProps | FormFieldOwnedProps | 'value'> & { + mask: (value: string) => string; + }; + export interface ControlledFieldProps { name: Path; label: string; diff --git a/apps/admin-frontend/src/shared/form/fields/index.tsx b/apps/admin-frontend/src/shared/form/fields/index.tsx index e8cfebe6..26923ac7 100644 --- a/apps/admin-frontend/src/shared/form/fields/index.tsx +++ b/apps/admin-frontend/src/shared/form/fields/index.tsx @@ -1,10 +1,12 @@ export { TextField, TextareaField } from './components/text-fields'; +export { MaskedField } from './components/masked-field'; export { ControlledField } from './components/controlled-field'; export { ColorField } from './components/color-field'; export { FormErrorBanner } from './components/form-error-banner'; export type { TextFieldProps, TextareaFieldProps, + MaskedFieldProps, ControlledFieldProps, ColorFieldProps, } from './fields.types'; diff --git a/apps/admin-frontend/src/shared/format/date.test.ts b/apps/admin-frontend/src/shared/format/date.test.ts new file mode 100644 index 00000000..e9bdb2bd --- /dev/null +++ b/apps/admin-frontend/src/shared/format/date.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { todayInSaoPaulo } from './date'; + +describe('todayInSaoPaulo', () => { + it.each([ + ['2026-10-02T15:00:00Z', '2026-10-02'], + ['2026-10-03T02:59:59Z', '2026-10-02'], + ['2026-10-03T03:00:00Z', '2026-10-03'], + ['2026-01-01T02:00:00Z', '2025-12-31'], + ] as const)('reads %s as the São Paulo calendar day %s', (instant, expected) => { + expect(todayInSaoPaulo(new Date(instant))).toBe(expected); + }); + + it('defaults to the current instant', () => { + expect(todayInSaoPaulo()).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); +}); diff --git a/apps/admin-frontend/src/shared/format/date.ts b/apps/admin-frontend/src/shared/format/date.ts new file mode 100644 index 00000000..3ee6d153 --- /dev/null +++ b/apps/admin-frontend/src/shared/format/date.ts @@ -0,0 +1,13 @@ +const dateParts = new Intl.DateTimeFormat('pt-BR', { + timeZone: 'America/Sao_Paulo', + year: 'numeric', + month: '2-digit', + day: '2-digit', +}); + +export function todayInSaoPaulo(now: Date = new Date()): string { + const parts = Object.fromEntries( + dateParts.formatToParts(now).map((part) => [part.type, part.value]), + ); + return `${parts['year']}-${parts['month']}-${parts['day']}`; +} diff --git a/apps/admin-frontend/src/shared/ui/checkbox-group/checkbox-group.test.tsx b/apps/admin-frontend/src/shared/ui/checkbox-group/checkbox-group.test.tsx new file mode 100644 index 00000000..02193714 --- /dev/null +++ b/apps/admin-frontend/src/shared/ui/checkbox-group/checkbox-group.test.tsx @@ -0,0 +1,131 @@ +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CheckboxGroup } from './index'; + +const OPTIONS = [ + { value: 'emergency', label: 'Emergência' }, + { value: 'operationalSupport', label: 'Apoio operacional' }, + { value: 'dailyCommunication', label: 'Comunicação cotidiana' }, +]; + +describe('CheckboxGroup', () => { + it('renders a named group with one checkbox per option', () => { + render( + , + ); + + expect(screen.getByRole('group', { name: 'Finalidades do contato' })).toBeInTheDocument(); + expect(screen.getAllByRole('checkbox')).toHaveLength(3); + expect(screen.getByRole('checkbox', { name: 'Apoio operacional' })).toBeInTheDocument(); + }); + + it('checks only the options present in value', () => { + render( + , + ); + + expect(screen.getByRole('checkbox', { name: 'Emergência' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Apoio operacional' })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Comunicação cotidiana' })).toBeChecked(); + }); + + it('reports the whole new selection when one option is toggled on', async () => { + const onValueChange = vi.fn(); + render( + , + ); + + await userEvent.setup().click(screen.getByRole('checkbox', { name: 'Apoio operacional' })); + + expect(onValueChange).toHaveBeenCalledWith( + ['emergency', 'operationalSupport'], + expect.anything(), + ); + }); + + it('reports the remaining selection when one option is toggled off', async () => { + const onValueChange = vi.fn(); + render( + , + ); + + await userEvent.setup().click(screen.getByRole('checkbox', { name: 'Emergência' })); + + expect(onValueChange).toHaveBeenCalledWith(['operationalSupport'], expect.anything()); + }); + + it('toggles from the visible label text too', async () => { + const onValueChange = vi.fn(); + render( + , + ); + + await userEvent.setup().click(screen.getByText('Comunicação cotidiana')); + + expect(onValueChange).toHaveBeenCalledWith(['dailyCommunication'], expect.anything()); + }); + + it('forwards the ref to the first checkbox so a form can focus it on error', () => { + const ref = createRef(); + render( + , + ); + + expect(ref.current).toBe(screen.getByRole('checkbox', { name: 'Emergência' })); + }); + + it('describes the group and flags each checkbox as invalid', () => { + render( + <> +

Escolha ao menos uma.

+ + , + ); + + const group = screen.getByRole('group', { name: 'Finalidades do contato' }); + expect(group).toHaveAccessibleDescription('Escolha ao menos uma.'); + for (const checkbox of screen.getAllByRole('checkbox')) { + expect(checkbox).toHaveAttribute('aria-invalid', 'true'); + } + }); +}); diff --git a/apps/admin-frontend/src/shared/ui/checkbox-group/index.tsx b/apps/admin-frontend/src/shared/ui/checkbox-group/index.tsx new file mode 100644 index 00000000..40ca080b --- /dev/null +++ b/apps/admin-frontend/src/shared/ui/checkbox-group/index.tsx @@ -0,0 +1,61 @@ +import type { FocusEventHandler, Ref } from 'react'; +import { Checkbox } from '@base-ui/react/checkbox'; +import { CheckboxGroup as CheckboxGroupPrimitive } from '@base-ui/react/checkbox-group'; +import { CheckIcon } from 'lucide-react'; + +interface CheckboxGroupOption { + value: string; + label: string; +} + +interface CheckboxGroupProps { + options: readonly CheckboxGroupOption[]; + value: readonly string[]; + onValueChange: (value: string[]) => void; + onBlur?: FocusEventHandler | undefined; + ref?: Ref | undefined; + 'aria-label': string; + 'aria-invalid'?: boolean | undefined; + 'aria-describedby'?: string | undefined; +} + +function CheckboxGroup({ + options, + value, + onValueChange, + onBlur, + ref, + 'aria-label': ariaLabel, + 'aria-invalid': ariaInvalid, + 'aria-describedby': ariaDescribedBy, +}: CheckboxGroupProps) { + return ( + + {options.map((option, index) => ( + + ))} + + ); +} + +export { CheckboxGroup }; +export type { CheckboxGroupOption, CheckboxGroupProps }; From 4dab19caff003cd9b909c66bccef3aac939b682e Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Mon, 5 Oct 2026 19:52:59 -0300 Subject: [PATCH 02/30] asd --- .../features/clients/model/clientForm.test.ts | 28 +++++ .../src/features/clients/model/clientForm.ts | 26 ++-- .../ClientFormPage/ClientFormPage.test.tsx | 20 +++- .../pages/ClientFormPage/useClientFormPage.ts | 2 +- ...regate-uniqueness-and-conflict-contract.md | 112 ++++++++++++++++++ 5 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 docs/adr/0044-clients-aggregate-uniqueness-and-conflict-contract.md diff --git a/apps/admin-frontend/src/features/clients/model/clientForm.test.ts b/apps/admin-frontend/src/features/clients/model/clientForm.test.ts index f356c2fa..f1766eb2 100644 --- a/apps/admin-frontend/src/features/clients/model/clientForm.test.ts +++ b/apps/admin-frontend/src/features/clients/model/clientForm.test.ts @@ -190,6 +190,34 @@ describe('clientFormSchema', () => { ).toBeUndefined(); }); + it('reports the missing guardian together with errors on other fields', () => { + const issues = issuesOf( + values({ + fullName: '', + email: 'invalido', + birthDate: '10/03/2015', + guardians: [], + referenceContacts: [reference({ purposes: [] })], + }), + ); + + expect(issues['fullName']).toBe('O nome completo é obrigatório.'); + expect(issues['email']).toBe('Informe um e-mail válido.'); + expect(issues['referenceContacts.0.purposes']).toBeDefined(); + expect(issues['guardians']).toBe( + 'Informe ao menos um responsável para pessoas menores de 18 anos.', + ); + }); + + it('still counts a guardian whose own fields are invalid', () => { + const issues = issuesOf( + values({ birthDate: '10/03/2015', guardians: [guardian({ name: '' })] }), + ); + + expect(issues['guardians.0.name']).toBe('O nome do responsável é obrigatório.'); + expect(issues['guardians']).toBeUndefined(); + }); + it('counts the São Paulo day, not the UTC day', () => { vi.setSystemTime(new Date('2026-10-03T01:00:00Z')); diff --git a/apps/admin-frontend/src/features/clients/model/clientForm.ts b/apps/admin-frontend/src/features/clients/model/clientForm.ts index 3e54fbcc..3b1985e1 100644 --- a/apps/admin-frontend/src/features/clients/model/clientForm.ts +++ b/apps/admin-frontend/src/features/clients/model/clientForm.ts @@ -129,19 +129,19 @@ const clientFormObject = z.object({ ), }); -export const clientFormSchema = clientFormObject.superRefine((value, ctx) => { - if ( - value.birthDate && - isMinorOn(value.birthDate, todayInSaoPaulo()) && - value.guardians.length === 0 - ) { - ctx.addIssue({ - code: 'custom', - path: ['guardians'], - message: `Informe ao menos um responsável para pessoas menores de ${ADULT_AGE_IN_YEARS} anos.`, - }); - } -}); +export const clientFormSchema = clientFormObject.refine( + (value) => + !value.birthDate || + !isMinorOn(value.birthDate, todayInSaoPaulo()) || + value.guardians.length > 0, + { + path: ['guardians'], + message: `Informe ao menos um responsável para pessoas menores de ${ADULT_AGE_IN_YEARS} anos.`, + // Zod skips object refinements once any field fails; the person would only learn about the + // missing guardian on a second submit. Only an invalid birth date makes the rule meaningless. + when: (payload) => payload.issues.every((issue) => issue.path?.[0] !== 'birthDate'), + }, +); export type ClientFormFieldValues = z.input; export type ClientFormValues = z.output; diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx index 0a33a147..88bfb0b2 100644 --- a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx @@ -134,7 +134,11 @@ describe('ClientFormPage', () => { referenceContacts: [], }); expect(toastAdd).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Pessoa cadastrada', type: 'success' }), + expect.objectContaining({ + title: 'Pessoa cadastrada', + description: 'O cadastro de Maria Souza foi criado.', + type: 'success', + }), ); expect(await screen.findByRole('heading', { name: 'Lista de pessoas' })).toBeInTheDocument(); }); @@ -292,6 +296,20 @@ describe('ClientFormPage', () => { expect(screen.getByLabelText('Nome completo')).toHaveFocus(); }); + it('reports the missing guardian on the same submit as the other errors', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type(screen.getByLabelText('Data de nascimento'), '10032015'); + await user.click(screen.getByRole('button', { name: 'Salvar' })); + + expect(await screen.findByText('O nome completo é obrigatório.')).toBeInTheDocument(); + expect( + screen.getByText('Informe ao menos um responsável para pessoas menores de 18 anos.'), + ).toBeInTheDocument(); + expect(mockCreate).not.toHaveBeenCalled(); + }); + it('stops offering more guardians at the limit', async () => { const user = userEvent.setup(); renderPage(); diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.ts b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.ts index 9c20d4ee..0b824905 100644 --- a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.ts +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/useClientFormPage.ts @@ -69,7 +69,7 @@ export function useClientFormPage(): UseClientFormPageResult { if (result.ok) { toast.add({ title: 'Pessoa cadastrada', - description: `${result.data.fullName} foi cadastrada.`, + description: `O cadastro de ${result.data.fullName} foi criado.`, type: 'success', }); if (isMountedRef.current) void navigate(CLIENTS_PATH); diff --git a/docs/adr/0044-clients-aggregate-uniqueness-and-conflict-contract.md b/docs/adr/0044-clients-aggregate-uniqueness-and-conflict-contract.md new file mode 100644 index 00000000..be1f3d75 --- /dev/null +++ b/docs/adr/0044-clients-aggregate-uniqueness-and-conflict-contract.md @@ -0,0 +1,112 @@ +# ADR 0044 — Clients: person aggregate, uniqueness rules and conflict contract + +Status: accepted (2026-10) + +## Context + +Issue #139 introduces the "pessoa atendida" (served person, `Client` in code, `/api/v1/clients`, the +**Pessoas** area in the UI). #154 is the first slice of that work: it creates the persisted model and the +contract that #155 (query), #156 (update), #157 (deactivate), #158 (reactivate) and #159 (delete) build on. +The rules that shape the model are stricter than anything Tags, Categories or Services needed: + +- CPF is unique per tenant across **every** situation — active, inactive and deleted — and must never be + reused; e-mail is unique only among **active** persons of the tenant. Both must hold under concurrent + writes ([ADR 0012](0012-revert-cross-aggregate-checks-to-handlers-and-domain.md) already says the database, + not a pre-check, is the final authority). +- A person owns two kinds of linked contacts (guardians and reference contacts) that are not persons + themselves, and the person and its contacts must be written atomically. +- A birth date that indicates a minor requires a guardian, on the backend and in the form. +- A CPF conflict must let the user open the existing record, so the error has to carry that record's id. + +## Decision + +**Aggregate.** `Client` is a `TenantOwnedEntity` root; `ClientGuardian` and `ClientReferenceContact` are tenant-owned +children (`ClientContact` is an unmapped abstract base for the shared name/relationship/phone). Two tables, not one +with a discriminator: the two contact kinds have different required data (guardian CPF vs. purposes) and the issue +insists they are separate records. Children reference the root through the composite key +`(TenantId, ClientId) → Clients(TenantId, Id)` ([ADR 0024](0024-database-enforced-data-ownership.md)). One +`SaveChanges` is one transaction, so a failure leaves no partial person. + +**Situation.** `ClientStatus { Active, Inactive, Deleted }`, stored as text with a `CHECK`. Creation sets `Active`; +the transitions belong to #157–#159. A person that becomes `Deleted` must **also** be soft-deleted through the +existing `BaseEntity` mechanism (`Remove` → `DeletedAt`), because the global query filter is what makes a deleted +record invisible — and answer 404 — in every read path by default. The e-mail index ignores a row that is either +not `Active` or soft-deleted, so it stays correct whichever of the two a future operation sets. + +**Normalized storage.** The domain stores CPF as 11 digits, e-mail trimmed and lowercase, and phone trimmed, so the +unique indexes compare plain stored values (no generated column for them). `FullNameNormalized` (`lower("FullName")`, +the convention of ADR 0012) exists for #155's stable ordering. + +**Uniqueness.** + +| Rule | Index (all `UNIQUE`, tenant-scoped) | +| --- | --- | +| CPF, any situation | `IX_Clients_TenantId_Cpf` on `(TenantId, Cpf) WHERE Cpf IS NOT NULL` | +| E-mail, active persons | `IX_Clients_TenantId_Email` on `(TenantId, Email) WHERE Email IS NOT NULL AND Status = 'Active' AND DeletedAt IS NULL` | + +A guardian's CPF is deliberately not constrained. The handler pre-checks both rules for a friendly answer; the +indexes decide races, and `UnitOfWork` already turns SQLSTATE `23505` into a `PersistenceError` carrying the +constraint name. + +**The one `IgnoreQueryFilters()`.** `ClientRepository.FindByCpfAsync` must see soft-deleted and deleted rows, which the +global filter hides, and `IgnoreQueryFilters()` drops the tenant scope with them. The method re-applies the tenant by +hand from `ServicesDataContext.CurrentTenantId` (`Guid.Empty` — matching nothing — with no tenant). This is the only +bypass in the repository layer and is pinned by persistence tests that put the same CPF in two tenants, one of them +soft-deleted. A new read path must not copy it. + +**Conflict contract.** A duplicate answers `409` with the errors keyed by field (`Cpf`, `Email`, PascalCase like +validation keys), so a form can show each under its input. `FieldError` gained an optional `Meta` string map +(omitted from the JSON when null, so no existing response changes). The CPF conflict puts `clientId` there — and +only when the existing person is not deleted, since a deleted record cannot be opened; the message already tells the +user why. A client reads it from the typed OpenAPI schema (`errors.Cpf[0].meta.clientId`); the id is the one +`GET /api/v1/clients/{id}` will serve once #155 lands. + +**Validation layers.** Per-field pt-BR messages come from FluentValidation (the only place that can name the field); +the domain re-checks the same invariants and its messages are not meant to reach the user. List sizes are capped +(10 guardians, 10 reference contacts) because the issue sets no bound and an unbounded array in a body is an abuse +vector. Reference-contact purposes are strings (`emergency`, `operationalSupport`, `dailyCommunication`) validated +like `TagColor`, not a JSON enum: a bad enum value fails in the framework's binder with an English message and no code. + +**"Today".** The minor rule needs a calendar day, so `TimeProvider.GetBusinessToday()` converts to +`America/Sao_Paulo` (Windows id, then a fixed UTC−3, because Brazil has had no DST since 2019). Age is completed years +(a leap-day birthday counts on February 28 in common years), "in the past" is strictly before today, and "more than +120 years" means an age of 121 or more. A client that pre-validates must use the same day and the same age rule. + +**Tenant.** Neither the command nor its nested inputs has a tenant member; the tenant is assigned on save from the +validated token ([ADR 0008](0008-automatic-tenant-assignment-on-save.md)), and a `tenantId` smuggled into the body +is ignored by the binder (tested at the DTO and verified against the running API). + +## Consequences + +What #155–#159 can rely on: + +- Read paths go through `ServicesDataContext`'s filter: deleted persons never appear and unknown ids answer 404 with no + extra predicate. Only the CPF lookup bypasses it. +- Wire shape: camelCase English fields; `status` is `active | inactive | deleted`; dates are `yyyy-MM-dd`; CPF is + returned as digits (masking lists is #155's job). +- Deleting must go through `Remove` (soft delete) **and** set `Status = Deleted`; reactivating must re-check the e-mail + rule with `ActiveEmailExistsAsync` (it will need an exclusion for the person itself, which Create does not). +- `Client.Create`/`ClientGuardian.Create` are the factories to extend with `Update` for #156; the contact-id rules + are theirs to add. +- This change is deliverable on its own: it commits the regenerated OpenAPI types + (`apps/admin-frontend/src/shared/api/generated/services-api.d.ts`) because `generate:api-types:check` compares them + with the live document, and it adds no consumer. The admin-frontend form is a separate change that depends on it. + +Accepted costs and limits: the Postgres-only guarantees (unique indexes, composite FK, `CHECK`s, the race) have no +automated proof, per [ADR 0026](0026-remove-dedicated-runtime-tests.md). They were exercised by hand against a +disposable PostgreSQL 18 with the real services and a real login — 8 and 10 parallel creates yielded exactly one +`201` and no orphan contacts, and the schema rules were driven with SQL in a rolled-back transaction. Persistence +tests cover the EF side (tenant assignment across the graph, isolation, the filters' bypass and the index definitions). + +## Considered and rejected + +- **One contacts table with a discriminator** — nullable columns that only apply to one kind, and a purposes column + that is meaningless for guardians. +- **Deletion as a status only (no `DeletedAt`)** — every read path would need its own `Status <> Deleted` predicate; + one forgotten predicate exposes deleted people. The soft-delete filter is already the default everywhere. +- **Named query filters / changing `ApplyAuditableConventions` to split tenant from soft delete** — would remove the + hand-written tenant predicate, but touches the highest-consequence mechanism ([ADR 0006](0006-tenant-header-base-entity-generic-repository.md)) + for one lookup. +- **The existing id as a top-level problem extension** — not visible in the generated OpenAPI schema, so untyped. +- **Sending the user to a CPF search instead of the record** — an extra step, and it depends on #155's search. +- **JSON enums for purposes and status** — see the validation note above. From d2b75c5bf21f431026c43cf9dc704c32e431f2de Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Fri, 9 Oct 2026 09:20:15 -0300 Subject: [PATCH 03/30] asdasd --- .../ui/pages/ClientFormPage/ClientFormPage.test.tsx | 13 ++++++------- .../ClientFormPage/components/ClientFormHeader.tsx | 11 +++++++++-- .../components/ClientGuardianItem.tsx | 6 +----- .../components/ClientGuardiansSection.tsx | 8 +++----- .../components/ClientReferenceContactItem.tsx | 6 +----- .../components/ClientReferenceContactsSection.tsx | 10 +++------- 6 files changed, 23 insertions(+), 31 deletions(-) diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx index 88bfb0b2..984af414 100644 --- a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/ClientFormPage.test.tsx @@ -104,10 +104,10 @@ describe('ClientFormPage', () => { it('starts with no contacts and never asks whether the person is a minor', () => { renderPage(); - expect(screen.getByText('Nenhum responsável adicionado ainda.')).toBeInTheDocument(); + expect(screen.queryByRole('group', { name: /^Responsável \d$/ })).not.toBeInTheDocument(); expect( - screen.getByText('Nenhuma pessoa de referência adicionada ainda.'), - ).toBeInTheDocument(); + screen.queryByRole('group', { name: /^Pessoa de referência \d$/ }), + ).not.toBeInTheDocument(); expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); }); @@ -373,7 +373,7 @@ describe('ClientFormPage', () => { expect(screen.getAllByRole('group', { name: /^Responsável \d$/ })).toHaveLength(1); await user.click(screen.getByRole('button', { name: 'Remover responsável 1' })); - expect(screen.getByText('Nenhum responsável adicionado ainda.')).toBeInTheDocument(); + expect(screen.queryByRole('group', { name: /^Responsável \d$/ })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Salvar' })); await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); @@ -440,7 +440,6 @@ describe('ClientFormPage', () => { await addReferenceContact(user); - expect(screen.getByText('Nenhum responsável adicionado ainda.')).toBeInTheDocument(); expect(screen.queryByRole('group', { name: 'Responsável 1' })).not.toBeInTheDocument(); }); @@ -452,8 +451,8 @@ describe('ClientFormPage', () => { await user.click(screen.getByRole('button', { name: 'Remover pessoa de referência 1' })); expect( - screen.getByText('Nenhuma pessoa de referência adicionada ainda.'), - ).toBeInTheDocument(); + screen.queryByRole('group', { name: 'Pessoa de referência 1' }), + ).not.toBeInTheDocument(); }); }); diff --git a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormHeader.tsx b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormHeader.tsx index 5ec9bf47..7aefc6ac 100644 --- a/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormHeader.tsx +++ b/apps/admin-frontend/src/features/clients/ui/pages/ClientFormPage/components/ClientFormHeader.tsx @@ -4,8 +4,15 @@ import { buttonVariants } from '@/shared/ui/button'; function ClientFormHeader() { return ( -
- +
+