Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
419ac64
feat(admin): register a served person under Pessoas (#154)
evertonschuster Oct 2, 2026
4dab19c
asd
evertonschuster Oct 5, 2026
49d7947
Merge branch 'main' into claude/clientes-frontend-154-a56a20
evertonschuster Oct 8, 2026
d2b75c5
asdasd
evertonschuster Oct 9, 2026
3a9e6aa
Add breadcrumb-based page header
evertonschuster Oct 9, 2026
bbdb28f
feat(admin-frontend): calendário no campo de data de nascimento (#154)
evertonschuster Oct 9, 2026
6be57e8
fix(admin-frontend): ajustes de revisão no seletor de data (#154)
evertonschuster Oct 9, 2026
c98a6ba
docs(admin-frontend): trecho verbatim de toFormErrors usa toFieldPath
evertonschuster Oct 9, 2026
ebc59b0
asdasd
evertonschuster Oct 9, 2026
36df7cb
asd
evertonschuster Oct 9, 2026
5a07994
Extract shared form/page widgets
evertonschuster Oct 11, 2026
a62d75d
Normalize birth dates to Date objects
evertonschuster Oct 11, 2026
b8be851
Normalize client birth-date handling
evertonschuster Oct 11, 2026
e5e966d
Align client date rules with UTC
evertonschuster Oct 11, 2026
3ffb521
asdads
evertonschuster Oct 11, 2026
24bc1ee
fix(admin-frontend): corrige o e2e e o teste de observações após a no…
evertonschuster Oct 11, 2026
592d9f3
refactor(clients): improve contact sections readability
evertonschuster Oct 12, 2026
340beaf
Improve form error handling and date UX
evertonschuster Oct 12, 2026
c518cd2
Move CPF and birth-date helpers to shared
evertonschuster Oct 12, 2026
cb66c6a
Move shared contact format helpers
evertonschuster Oct 12, 2026
4f02c2e
asd
evertonschuster Oct 12, 2026
2798276
Relocate form schemas to page modules
evertonschuster Oct 12, 2026
68876e4
Split client model types and add birthDate
evertonschuster Oct 12, 2026
f036c0a
docs(admin-frontend): skill agenza-form-field usa a assinatura atual …
evertonschuster Oct 12, 2026
af38ce2
Split shared form field components
evertonschuster Oct 12, 2026
4ed6ad8
Centralize form limits in model layer
evertonschuster Oct 12, 2026
e9c1cb1
dfg
evertonschuster Oct 12, 2026
bc615a2
Align date fields with API contract
evertonschuster Oct 12, 2026
fe48782
0897
evertonschuster Oct 12, 2026
1f459ab
432
evertonschuster Oct 12, 2026
9f89aea
test(admin-frontend): remove o teste de CPF de cadastro excluído (#154)
evertonschuster Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/skills/agenza-api-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ document. It also fires on a backend-only PR, by design — that is the drift it
resolved config, so regenerate rather than reformat by hand.
4. Never relax the check, never hand-patch the `.d.ts`.

## Calendar dates cross as `Date`, instants as `string`

Two rules, one per direction, that only hold together:

- **Types.** `generateApiTypes.mjs` turns `format: date` into `Date` only in schemas reached solely
from request bodies: it follows the `$ref`s from each operation's `requestBody`, so the DTO's name
(`*Command`, `*Input`, `*Request`) does not matter. A schema that a response or a parameter also
uses stays `string`. A date in a response is therefore always a `string`, and the repository that
reads it converts it (`new Date(isoDate)` is midnight UTC). `format: date-time` is never mapped.
`scripts/generateApiTypes.test.mjs` pins each case against a fixture document (`typesFromDocument`,
no backend needed); change the rule there first.
- **Wire.** `serializeBody` (`apiClient.ts`, the client's `bodySerializer`) writes **every** `Date`
in a request body as `yyyy-MM-dd`, its UTC calendar day; the backend's `DateOnly` binding answers
400 to an instant. A `Date` in a body always means a calendar day: never carry an instant in one,
send it as the ISO `string` its `date-time` type already asks for.

Build calendar days with `utcDate`/`todayUtc` (`shared/format/date.ts`), never `new Date(y, m, d)`:
that is local midnight, which east of UTC serializes as the previous day.

## Write a repository

None exists today ([ADR 0038](../../../docs/adr/0038-admin-frontend-remove-categories-harness.md)
Expand Down
17 changes: 13 additions & 4 deletions .claude/skills/agenza-api-contract/references/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,15 @@ 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<string, { code?, message? }[]>` and gets filled **three
This is the trap. `errors` is `Record<string, { code?, message?, meta? }[]>` and gets filled **three
different ways** (`ApiProblemDetailsFactory`):

1. **Validation (400).** Keys come from FluentValidation's `PropertyName`, grouped in
`Dispatcher.cs`, and `ApiProblemDetailsFactory` writes them as the **camelCase path of the JSON
body** (`name`, `durationMinutes`, `guardians[0].name`; ADR 0051). 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. `toFormErrors` turns the index of a list item into a
path segment: `guardians[1].name` becomes `guardians.1.name`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
2. **A body that did not bind (400).** Before any validator runs, MVC fills the keys from the JSON
member path (`cpf`, `guardians[0].cpf`), each item with `code: "Validation.Failed"`. The `message` is
pt-BR only when a shared value object rejected the value; otherwise it is the framework's English
Expand All @@ -69,7 +70,13 @@ different ways** (`ApiProblemDetailsFactory`):
3. **Any error without field errors** (Conflict, NotFound, Failure, and a Validation the domain
rejected after the validator let it through). `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 and e-mail conflicts put `clientId` and `clientName` there, and the record can always
be opened because neither conflict matches a deleted person. Branch on the entry's `code` before
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.
Expand All @@ -93,6 +100,8 @@ speculative. Revisit if a real case needs multiple messages per field; don't bui

```ts
// shared/api/formErrors.ts (verbatim)
const toFieldPath = (key: string) => key.replace(/\[(\d+)\]/g, '.$1').toLowerCase();

export function toFormErrors<F extends string>(
problem: ApiProblem,
fields: readonly F[],
Expand All @@ -104,7 +113,7 @@ export function toFormErrors<F extends string>(
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);
}
Expand Down
101 changes: 78 additions & 23 deletions .claude/skills/agenza-form-field/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,63 @@ 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`), a typed-or-picked date (`DateField`: mask plus calendar popover, bounded by `minDate`/`maxDate`) 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`, and `applyApiProblem` matches it against the **current** field paths: it derives
them from `methods.getValues()` when the response arrives (top-level plus one per item), so a page
keeps no path list of its own. 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

| Layer | File | Job |
| --- | --- | --- |
| Error mapping | `shared/api/formErrors.ts` | `toFormErrors<F>(problem, fields)` — `ApiProblem` → `{fieldErrors, formError}`. Knows `ApiProblem`, nothing about RHF. |
| RHF bridge | `shared/form/applyApiProblem.ts` | `applyApiProblem<T>(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`). |
| RHF bridge | `shared/form/applyApiProblem.ts` | `applyApiProblem(problem, methods)` — builds the current field paths from `methods.getValues()`, calls the above, then `setError` per field, `setError('root.serverError', …)` for the form-level message and `setFocus` on the first invalid field. Knows both shapes; the only file that does. |
| Field components | `shared/form/fields/` | `TextField`/`TextareaField`, `MaskedField`, `DateField`, `ControlledField`, `ColorField`, `FormErrorBanner` — RHF wiring, zero entity 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 (indexed
keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See `agenza-api-contract`'s
`references/errors.md` for that taxonomy in full.

## Wiring a new entity's form

1. **Schema** in that entity's `model/<entity>Form.ts`, mirroring `tagForm.ts`:
1. **Limits and invariants** in the slice's `model/<entity>.ts`, beside the entity's types, mirroring
the backend aggregate: plain constants (`TAG_NAME_MAX_LENGTH`, `MAX_GUARDIANS`) and pure functions
over the plain types (`needsGuardian(birthDate, guardianCount, today)`). No Zod, no pt-BR copy, no
class. Anything outside the form that needs the rule (a list badge, another page) imports it from
there, and so does the form's own UI when it shows the rule before submit.

**Schema** in the page that owns the form, `ui/pages/<X>FormPage/<x>Form.ts`, mirroring
`tagForm.ts`, built from those limits and composing those invariants:
```ts
export const xFormSchema = z.object({ /* z.string().trim().min(1, '…') etc */ });
export const xFormSchema = z.object({
name: z.string().trim().min(1, '…').max(X_NAME_MAX_LENGTH, `… ${X_NAME_MAX_LENGTH} …`),
});
export type XFormFieldValues = z.input<typeof xFormSchema>;
export type XFormValues = z.output<typeof xFormSchema>;
export const X_FORM_FIELDS = xFormSchema.keyof().options;

export function toXFormFieldValues(x?: X): XFormFieldValues {
return { name: x?.name ?? '', description: x?.description ?? '' };
}
```
**Use `z.input`/`z.output`, never a single `z.infer`, the moment the schema has a `.refine()` or
`.transform()`.** See Gotcha 1 below — this is not optional once either is present.
`keyof().options` is already typed as the key union, so the model needs no `as` cast and no
`react-hook-form` import — `model/` stays library-free. `toXFormFieldValues` is the inverse of the
**No field list for errors**: `applyApiProblem` derives the paths from `methods.getValues()`, so
`toXFormFieldValues()` must return every field (the `z.input` return type already forces it).
Export `X_FORM_FIELDS = xFormSchema.keyof().options` only when something renders from it, like
the loading skeleton below; an export kept alive only by its own test is dead code.
`toXFormFieldValues` is the inverse of the
schema's `.transform()`s (a `null` description back to `''`) and sits beside them: `useForm`'s
`defaultValues` is `toXFormFieldValues()`, the edit fetch's `reset` is `toXFormFieldValues(entity)`.

Expand All @@ -58,7 +79,7 @@ keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See
async function onValid(values: XFormValues) {
const result = isEdit ? await xRepository.update(id, values) : await xRepository.create(values);
if (result.ok) { /* publish, toast, navigate */ return; }
applyApiProblem<XFormFieldValues>(result.error, X_FORM_FIELDS, methods.setError);
applyApiProblem(result.error, methods);
}

function submit(event: SubmitEvent<HTMLFormElement>) {
Expand All @@ -74,20 +95,23 @@ keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See
trips `@typescript-eslint/no-misused-promises`; `tsc` alone won't catch it, only `npm run lint`.

`canSubmit` is the rule behind the **Salvar** button's `disabled` state. The form's
`onSubmit` calls `submit`. The footer uses:
`onSubmit` calls `submit`. The footer is a widget, chosen by where the form lives:
```tsx
<DialogClose disabled={isSaving} render={<Button variant="outline" />}>Cancelar</DialogClose>
<ActionButton type="submit" disabled={!canSubmit} pending={isSaving}>
{isSaving ? 'Salvando…' : 'Salvar'}
</ActionButton>
import { DialogFormFooter, PageFormFooter } from '@/widgets/form-footer';

<DialogFormFooter canSubmit={canSubmit} isSaving={isSaving} />
<PageFormFooter isSaving={isSaving} cancelTo="/pessoas" />
```
`ActionButton` turns `pending` into a spinner and disables the button.
`DialogFormFooter` goes inside a `Dialog` (its Cancelar is a `DialogClose`); `PageFormFooter` goes on
a full page and requires `cancelTo` (its Cancelar is a link that turns into a disabled button while
saving). Both render **Salvar**/**Salvando…** as an `ActionButton`, which turns `pending` into a
spinner and disables the button.

**While saving, the dialog offers no way out** — a Cancelar clicked mid-request would close the
dialog while the save still lands, a Cancel that doesn't cancel. Three lines, all with APIs the
dialog already had: the hook's `onOpenChange` ignores a close while `isSaving` (a controlled dialog
stays open when its handler doesn't close it, which covers Esc and outside press), Cancelar is
`<DialogClose disabled={isSaving}>`, and the shell passes `showCloseButton={!isSaving}` to
stays open when its handler doesn't close it, which covers Esc and outside press),
`DialogFormFooter` disables its Cancelar, and the shell passes `showCloseButton={!isSaving}` to
`DialogContent`. `shared/ui/dialog` itself stays a plain wrapper — a `busy` prop with a context was
tried there and reverted as more machinery than three lines per dialog.

Expand All @@ -101,17 +125,43 @@ keys, the collapsed `""` key for a 409/404) lives in `formErrors.ts` alone. See
field-values type as an explicit JSX type argument:
```tsx
import { ColorField, TextField } from '@/shared/form/fields';
import type { XFormFieldValues } from '../../model/xForm';
import { X_NAME_MAX_LENGTH } from '../../../../model/x';
import type { XFormFieldValues } from '../xForm';

<TextField<XFormFieldValues> name="name" label="Nome" hint="…" maxLength={40} placeholder="…" />
<TextField<XFormFieldValues> name="name" label="Nome" hint="…" maxLength={X_NAME_MAX_LENGTH} />
<ColorField<XFormFieldValues> name="color" label="Cor" aria-label="Cor…" options={PALETTE} />
```
The type argument is what makes `name` type-check against `Path<XFormFieldValues>` — a bare
`<TextField name="name" …>` with no argument still compiles (`T` falls back to the unconstrained
`FieldValues`), but silently accepts any string as `name`, losing the one thing worth keeping. A
native-input field (`register()`-bound) is `TextField`/`TextareaField`. A custom control needs
`ControlledField` (pass a render function) or a named specialization built on top of it, per the
rule below.
rule below. When the render function needs the value's own type, pass the path as the second type
argument and drop any cast: with
``<ControlledField<XFormFieldValues, `contacts.${number}.purposes`> …>``, `field.value` is that
array, not the union of every value in the form.

5. **A CPF, phone, e-mail or birth date is not configured by hand: reuse the pair.** The schema half
comes from that value's file in `@/shared/values/` (`cpfSchema` from `@/shared/values/cpf`, then
`phoneSchema`, `emailSchema`, `birthDateSchema`: an empty field becomes `null`, anything else is
parsed and fails with the pt-BR message), the
field half from `@/shared/form/fields` (`CpfField`, `PhoneField`, `EmailField`, `BirthDateField`:
mask, input type, limit, placeholder, default label and `opcional` hint already set, each one
overridable by prop):
```ts
const personSchema = z.object({ phone: phoneSchema, cpf: cpfSchema });
```
```tsx
<CpfField<XFormFieldValues> name="cpf" />
<PhoneField<XFormFieldValues> name="phone" label="Telefone do contato" />
```
The parsing underneath sits in the same file as the schema (`parseCpf`, `parsePhone`, `parseEmail`:
canonical value or `null`, mirroring the backend's `Create`), so never re-validate with a regex of
your own. A new value type (a CNPJ) is one file, `shared/values/cnpj.ts`, mirroring the backend's
value object: its `parseX`, its limits and a schema built with `optionalValue(parse)` (its
`fail(message)` becomes the Zod issue); plus, only if several forms will configure it the same way,
a preset field in `shared/form/fields/`. The schemas have no required variant yet; add it to `optionalValue` when the first
form needs one.

Comment thread
evertonschuster marked this conversation as resolved.
## Editing an existing entity: fetch fresh, don't trust what the list already had

Expand Down Expand Up @@ -156,6 +206,11 @@ already applies to `entities/` and to not rebuilding `color-swatch-picker` befor
(ARCHITECTURE §1, §5) — a one-off specialization for a control only `ControlledField` has ever wrapped
is guessing at an API shape with a sample size of one.

The value presets (`CpfField`, `PhoneField`, `EmailField`, `BirthDateField`) are a different thing:
they are not custom controls but fixed configurations of `MaskedField`/`TextField`/`DateField`, kept in
one place because the same value was being configured by hand at several call sites (the phone three
times). Add another only for a value type that will be configured the same way in more than one form.

## Two gotchas, both caught by `tsc`/review, not by a test

**1. `z.infer` is not enough once a schema has `.refine()` or `.transform()`.** A schema's *input*
Expand All @@ -167,7 +222,7 @@ TTransformedValues>` needs the input shape as its first generic, the output shap
Modeling both as one `z.infer<typeof schema>` type — the natural first guess — compiles right up
until `resolver: zodResolver(schema)` is attached to `useForm`, where `tsc` names a `Resolver<Input,
any, Output>` mismatch. Fix: `z.input<typeof schema>` for the field components and
`applyApiProblem`'s generic, `z.output<typeof schema>` for `onValid`'s parameter — see
`useForm`'s first generic (`applyApiProblem` infers its own from `methods`), `z.output<typeof schema>` for `onValid`'s parameter — see
`TagFormFieldValues`/`TagFormValues` in `tagForm.ts`.

**2. Not every control accepts `id`/`htmlFor`.** `FormField`'s `<Label htmlFor={id}>` assumes the
Expand Down
16 changes: 11 additions & 5 deletions .claude/skills/agenza-ptbr-copy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ palette.
| **Nenhum(a) … ainda** | Sem resultados, Lista vazia, Nada por aqui | The never-created empty state. |
| **Não foi possível …** | Erro ao …, Falha ao …, Ops! | Names the action that failed, then the next step. |

The domain nouns follow the same rule from the other side: the screen says **Pessoa**, **Responsável**,
**Pessoa de referência**, **Etiqueta**, while the code keeps the backend's English names (`Client`,
`ClientGuardian`, `ClientReferenceContact`, `Tag`). The full screen × code table is in
[`docs/ARCHITECTURE.md` §1](../../../apps/admin-frontend/docs/ARCHITECTURE.md), under "Vocabulary".

## Where strings and formatters live

**There is no message catalog and no `t()`.** Internationalisation beyond pt-BR is explicitly out of
Expand All @@ -79,11 +84,12 @@ i18n layer to "prepare" for a language nobody asked for.
The one exception: a **code → message map is data, not markup**. It belongs in the slice's `model/`,
where it is pure and testable without React, next to the branch that consumes it.

**Formatters live in `src/shared/format/`** — cross-cutting, no business rule, so `shared/` by the
dependency direction in [`docs/ARCHITECTURE.md` §1](../../../apps/admin-frontend/docs/ARCHITECTURE.md).
The folder does not exist yet; create it with the first screen that needs it, not before. Note that
the coverage exclusion covers `src/shared/ui/**` only — `shared/format/**` is measured, and these are
exactly the pure functions that make the gate easy.
**Formatters live in `src/shared/format/`** (`date.ts`, `age.ts`) — cross-cutting, no business rule,
so `shared/` by the dependency direction in
[`docs/ARCHITECTURE.md` §1](../../../apps/admin-frontend/docs/ARCHITECTURE.md). A value with its own
validation and limits (CPF, phone, e-mail, birth date) is not a formatter: its mask and parse live with
its schema in `src/shared/values/<value>.ts`. Note that the coverage exclusion covers `src/shared/ui/**`
only — both folders are measured, and these are exactly the pure functions that make the gate easy.

## What breaks

Expand Down
Loading
Loading