From de6a283b6d1ac3d895260712f75261360ef4a7a0 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Sun, 2 Aug 2026 19:40:23 -0300 Subject: [PATCH] Land the agenza-frontend-feature skill updates missed by the PR split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These 3 files (agent-skills/agenza-frontend-feature/SKILL.md and its two synced copies under .claude/skills/ and .agents/skills/) live at the repo root, outside apps/admin-frontend/ — every diff I computed while splitting #69 into #70/#71/#75/#76 was scoped to apps/admin-frontend (and backend/ for #70), so these files' accumulated updates from this session (Catalog Result migration, Auth Result migration, and finally the Tags-removal doc pass replacing TagsPage/TagForm with Categories as the reference implementation) never made it into any of the split PRs, even though the actual code changes they describe are all correctly merged. Content taken directly from the original branch's final commit (4911abb), already reviewed and governance-checked at the time. Verified again here against the current merged main: sync_agent_skills.py --check, check_agent_governance.py, and architecture_guard.py all pass, and the file paths the skill references (CategoriesListPage.tsx, CategoryForm.tsx, categoryMapper.ts, AdminLayout.tsx) all exist in the current tree. Co-Authored-By: Claude Sonnet 5 --- .../skills/agenza-frontend-feature/SKILL.md | 238 +++++++++++------- .../skills/agenza-frontend-feature/SKILL.md | 238 +++++++++++------- agent-skills/agenza-frontend-feature/SKILL.md | 238 +++++++++++------- 3 files changed, 426 insertions(+), 288 deletions(-) diff --git a/.agents/skills/agenza-frontend-feature/SKILL.md b/.agents/skills/agenza-frontend-feature/SKILL.md index f29063d..8c7f3cb 100644 --- a/.agents/skills/agenza-frontend-feature/SKILL.md +++ b/.agents/skills/agenza-frontend-feature/SKILL.md @@ -40,18 +40,22 @@ src/ index.ts public API - everything outside this feature imports through here, never a deep path into the above - catalog/ Tags, Categories, Services - one feature, they - collaborate in the same business context - domain/ Tag, Category, Service entities + their errors + catalog/ Categories, Services - one feature, they + collaborate in the same business context. Tags + was removed from the frontend (docs/adr/016 in + this app's ADRs) - the backend Tag domain/API + is intentionally retained, unrelated to this + feature's current frontend shape + domain/ Category, Service entities + their errors application/ 3 repository ports, 12 use cases infrastructure/ Api*Repository, mappers, generated/ (OpenAPI types) - presentation/ every entity folder (tags/, categories/, services/) + presentation/ every entity folder (categories/, services/) shares the same internal shape - location alone tells you a file's role: / - Page.tsx composition shell (stays at entity root - see - "Componentization" below for why) - hooks/ data hook (useTags/useCategories/useServices) + Page.tsx composition shell (stays at entity root by + default; Categories uses pages/, ADR 012) + hooks/ data hook (useCategories/useServices) + controller hook (useXPage) + any sub-hooks (useServiceEditor, useServiceDeletion, ...) components/ presentational pieces: tables, dialogs, @@ -61,8 +65,8 @@ src/ across entities - see "Forms" below) models/ services/ only - pure, non-React view-model/ formatting logic (servicePresentationModels, - serviceFormatters); tags/categories have no - equivalent, so no models/ for them + serviceFormatters); categories has no + equivalent, so no models/ for it index.ts public API shared/ @@ -122,10 +126,10 @@ state, UI, and completion criteria. REST repository depends on it; it already exists for every current feature. 3. **Decide whether this is a new feature or belongs in an existing - one.** A resource that collaborates closely with Tags/Categories/ - Services (shares forms, cross-references, or the same backend service) - belongs in `features/catalog/`; a genuinely independent domain gets its - own `features//` following the same four-layer shape. + one.** A resource that collaborates closely with Categories/Services + (shares forms, cross-references, or the same backend service) belongs + in `features/catalog/`; a genuinely independent domain gets its own + `features//` following the same four-layer shape. 4. **Identify which use cases the current page actually needs.** Don't build every possible use case upfront. @@ -173,20 +177,38 @@ as `apps/admin-frontend/AGENTS.md` and `backend/AGENTS.md`. React, that feature's own `application/`, `infrastructure/`, or `presentation/`, and zero imports from another feature. Private constructor + static `create(input)` factory that validates invariants -and throws a named error subclassing `shared/domain/DomainError.ts` — see -`Category.ts`/`Service.ts` (`features/catalog/domain/entities/`) for the -existing pattern (`InvalidCategoryError`/`InvalidServiceError`). This is -the frontend's own, already-established exception-and-catch convention -for validation failures, distinct from the .NET backend's Result/ -DomainResult pattern (docs/adr/0014, `backend/AGENTS.md`) — that ADR -governs the backend only; every caller here already expects a throw -(`useAsync`'s `catch`, `mapApiErrorToForm`). No constructor parameter -property shorthand (`erasableSyntaxOnly`) — explicit field declarations + -assignment in the constructor body. Optional fields: `if (value !== -undefined) { this.field = value }`, never a direct assignment of a -possibly-`undefined` value (`exactOptionalPropertyTypes`). `strict: true` -— never `any`; if a value's shape is genuinely unknown at a boundary, -type it `unknown` and narrow it, never widen with `any`. +and returns `Result` +(`shared/application/Result.ts`) instead of throwing (docs/adr/014, +docs/adr/015 — both Catalog's `Category.ts` and Auth's +`Session.ts`/`User.ts`/`Tenant.ts` follow this). Every caller composes +with `flatMapResult`/`combineResults`, or plain early-return `Result` +branching for a short sequential chain with heterogeneous error types +(see `mapOidcUserToSession`, `features/auth/infrastructure/`) — never +`try/catch`. A mapper that turns a domain validation failure arising from +an untrusted API response into a curated `AppError` uses +`shared/infrastructure/http/malformedResponseError.ts`, not its own +message. `useAsync` (`shared/presentation/hooks/useAsync.ts`) takes +`() => Promise>`, not a throwing `() => Promise`. + +A test fixture that needs a known-valid entity (most test files touching +auth or catalog do) imports `Tenant`/`User`/`Session`/`Category` +from `src/test/fixtures/{authEntityFixtures,unwrapResult}.ts` instead of +the real `domain/entities/` path — those re-export the same `create()` +call shape already unwrapped, so call sites read exactly like before +without every test wrapping every call in `unwrapResult(...)`. Only each +entity's own `*.test.ts` imports the real class directly, since it +specifically asserts on both the success and failure `Result` shapes. + +Every feature vertical (Catalog now, Auth now, a future one like +Services) follows this same Result convention — there is no throwing +variant left to mirror. + +No constructor parameter property shorthand (`erasableSyntaxOnly`) — +explicit field declarations + assignment in the constructor body. Optional +fields: `if (value !== undefined) { this.field = value }`, never a direct +assignment of a possibly-`undefined` value (`exactOptionalPropertyTypes`). +`strict: true` — never `any`; if a value's shape is genuinely unknown at a +boundary, type it `unknown` and narrow it, never widen with `any`. ### 2. Repository interface (no test needed) @@ -203,10 +225,10 @@ one class per use case, explicit constructor body (no shorthand): ```typescript export class ListServices { - private readonly serviceRepository: ServiceRepository + private readonly serviceRepository: ServiceRepository; constructor(serviceRepository: ServiceRepository) { - this.serviceRepository = serviceRepository + this.serviceRepository = serviceRepository; } } ``` @@ -240,8 +262,8 @@ field pattern). Tests use MSW handlers in `src/test/mocks/handlers/index.ts`. `onUnhandledRequest: 'error'` is global — any call without a registered handler fails loudly. A test mock handler typing a fixture against a feature's internal DTO type -(`import type { TagDto } from '@/features/catalog/infrastructure/ -mappers/tagMapper'`) is the one place allowed to import a feature's +(`import type { CategoryDto } from '@/features/catalog/infrastructure/ +mappers/categoryMapper'`) is the one place allowed to import a feature's internals directly from outside it — `src/test/**` is exempt from the public-API-only rule (ESLint + `architecture_guard.py` both carve this out explicitly). @@ -250,7 +272,7 @@ out explicitly). `shared/presentation/hooks/useAsync.ts` is the one shared "call an async function, track loading/data/error" primitive — every feature hook -(`useCategories`, `useServices`, `useTags` — and `AuthProvider` for the +(`useCategories`, `useServices` — and `AuthProvider` for the shared session) builds on it instead of a bespoke `useState`/`useEffect` pair or a server-state library (see "Prohibited" below). It already handles the two things that are easy to get wrong by hand: @@ -263,7 +285,7 @@ handles the two things that are easy to get wrong by hand: - **Unmounted-component writes**: guarded internally; you don't need your own `isMounted` ref. -For a mutation (`createTag`, `updateTag`, `deleteTag` in `useTags.ts`), +For a mutation (create/update/delete on a feature's data hook), **a create's success must not depend on the follow-up refetch succeeding**: call `mutate(current => [...(current ?? []), created])` to insert the new item into the hook's state immediately after the write succeeds, then @@ -282,24 +304,37 @@ frame (multi-tenancy — see root `AGENTS.md`). ### 8. Page component -Replace the stub. **`TagsPage` is the reference for behavior and design** -(search → table → dialog create/edit → `AlertDialog` delete-confirm, -loading/error/empty states) — **not for anatomy**. Copy the *pattern*, not -the file count: a feature with more independent workflows legitimately -needs more files than Tags does. See "Componentization" below for when -and how to split a page's controller hook, form, and dialog. +Replace the stub. **`CategoriesListPage`/`CategoryEditorDialog` +(`features/catalog/presentation/categories/`) is the reference for +behavior and design** (search → table → dialog create/edit → +`AlertDialog` delete-confirm, loading/error/empty states) — **not for +anatomy**. Copy the _pattern_, not the file count: a feature with more +independent workflows legitimately needs more files than Categories does. +See "Componentization" below for when and how to split a page's +controller hook, form, and dialog. -#### List = `Table`, form = `Dialog` — always +#### List = `Table`; form = `Dialog` by default A page listing records renders a `Table` (`src/components/ui/table.tsx`): one row per record, actions (Edit/Delete) as buttons in the last column — -not stacked `Card`s. A create/edit form always opens in a `Dialog` -(`src/components/ui/dialog.tsx`) over the list — never inline in the page, -never its own route. One `Dialog` instance whose content switches between -create/edit based on which record (if any) triggered it (`TagsPage`'s -dialog-target state), not a dialog per row. The form component itself stays -dialog-agnostic — the page (or its dialog wrapper) wires it into the -`Dialog`. +not stacked `Card`s. A create/edit form opens in a `Dialog` +(`src/components/ui/dialog.tsx`) over the list by default. One `Dialog` +instance switches between create/edit based on which record triggered it, +not a dialog per row. The form component stays dialog-agnostic. + +Categories maps `/categories/new` and `/categories/:id/edit` to the same +nested editor `Dialog` over the still-mounted `/categories` list +(docs/adr/012). `CategoryEditorDialog` renders one `CategoryForm` and +`useCategoryEditor` selects create or update from the route. In edit mode +`useCategoryEditor` fetches its own category directly via +`GET /api/v1/categories/{id}` — it does **not** read the list's data +through outlet context (docs/adr/013 superseded that shape; a +`useOutletContext()` cast has no runtime guarantee an ancestor route +actually supplied a value). `useCategoriesListPage` refetches the list +unconditionally whenever navigation returns from the editor route back to +the bare `/categories` route, whether the editor closed via cancel or a +successful save. Its smartphone table uses labelled icon actions with +larger touch targets and reveals action text from `sm` upward. A destructive action (delete) is confirmed with the shared `DeleteConfirmationDialog` (`shared/presentation/components/`, built on @@ -318,7 +353,7 @@ machine behind it. - A controller hook (`useXPage`) follows the same single-responsibility bar as any other code: when it accumulates more than one real workflow (search/filter state, an editor with dirty-tracking, a deletion - confirmation are three *different* concerns), split it into focused + confirmation are three _different_ concerns), split it into focused hooks (`useXFilters`, `useXEditor`, `useXDeletion`) that the page's composer hook assembles — see `features/catalog/presentation/services/hooks/` for the reference (`useServicesPage` composing `useServiceFilters` + @@ -326,7 +361,7 @@ machine behind it. - Extract a component or hook on its **first** use if it's already a distinct concern (a field group, a delete dialog) — keep it feature-local (e.g. `features/catalog/presentation/services/components/ - ServiceCategoryField.tsx`). Only **promote** something to `shared/` +ServiceCategoryField.tsx`). Only **promote** something to `shared/` once a **second**, genuinely-identical use appears across features — the "second use" rule gates promotion, not the initial extraction. - Break a type cycle between a controller and the component(s) it feeds @@ -350,25 +385,35 @@ machine behind it. #### Forms: React Hook Form + Zod Any form beyond a single trivial field uses `react-hook-form` + -`@hookform/resolvers/zod` — see `TagForm.tsx` -(`features/catalog/presentation/tags/forms/`) for the exact shape: +`@hookform/resolvers/zod` — see `CategoryForm.tsx` +(`features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/`) +for the exact shape: ```typescript -const tagFormSchema = z.object({ - name: z.string().trim().min(1, NAME_MESSAGE).max(40, NAME_MESSAGE), - color: z.enum(TAG_COLOR_PALETTE, { message: COLOR_MESSAGE }), -}) -export type TagFormValues = z.infer - -const { register, control, handleSubmit, setError, setFocus, formState: { errors } } = - useForm({ - resolver: zodResolver(tagFormSchema), - defaultValues: initialValues, - mode: 'onTouched', - reValidateMode: 'onChange', - }) +const categoryFormSchema = z.object({ + name: z.string().trim().min(1, NAME_MESSAGE).max(60, NAME_MESSAGE), +}); +export type CategoryFormValues = z.infer; + +const { + register, + handleSubmit, + setError, + setFocus, + formState: { errors }, +} = useForm({ + resolver: zodResolver(categoryFormSchema), + defaultValues: initialValues, + mode: "onTouched", + reValidateMode: "onChange", +}); ``` +(A field wired through `Controller` instead of `register` — e.g. a +`Select`, a color swatch group, a multi-value picker — also destructures +`control` from `useForm`; `CategoryForm` doesn't need one since its only +field is a plain text input.) + - `
void handleSubmit(onSubmit)(e)} noValidate ...>` — `noValidate` because native browser constraint validation would intercept submit before react-hook-form/zod ever sees it. @@ -393,10 +438,10 @@ const { register, control, handleSubmit, setError, setFocus, formState: { errors result with `setError(field, { type: 'server', message })` in a `useEffect` keyed on the server-error object, and `setFocus(firstField)` so a screen-reader/keyboard user lands on the - first invalid field instead of losing their position — see `TagForm`'s - `serverError` effect. + first invalid field instead of losing their position — see + `CategoryForm`'s `serverError` effect. - Don't reach for Formik or Yup without an explicit ADR — React Hook Form - + Zod is the established, working pattern here (`docs/DECISIONS.md`). + - Zod is the established, working pattern here (`docs/DECISIONS.md`). #### Inline creation (a select that can create its own options) @@ -431,16 +476,16 @@ wants it." Do it at the call site instead (a conditional `` in Shared composites live in `shared/presentation/components/` — reuse before writing a new one: -| Component | Use for | -| --------------------------------- | ------------------------------------------------------------------ | -| `PageHeader` | Title + primary action row at the top of every page | -| `StatusMessage` | Loading / empty / error text (`tone="error"` for errors) | -| `CollectionFeedback` | Loading/error/empty/last-known-good states for a tenant-scoped list | -| `DeleteConfirmationDialog` | Destructive-action `AlertDialog`, wired to `useDeleteConfirmation` | -| `TextField` / `TextAreaField` | Labeled form inputs (wraps shadcn `Label` + `Input`/`Textarea`) | -| `CenteredScreen` | Full-page centered content (pre-auth screens only) | -| `FullScreenSpinner` | Full-page loading state | -| `ThemeToggle` | Already in `AdminLayout` — don't add another one | +| Component | Use for | +| ----------------------------- | ------------------------------------------------------------------- | +| `PageHeader` | Title + primary action row at the top of every page | +| `StatusMessage` | Loading / empty / error text (`tone="error"` for errors) | +| `CollectionFeedback` | Loading/error/empty/last-known-good states for a tenant-scoped list | +| `DeleteConfirmationDialog` | Destructive-action `AlertDialog`, wired to `useDeleteConfirmation` | +| `TextField` / `TextAreaField` | Labeled form inputs (wraps shadcn `Label` + `Input`/`Textarea`) | +| `CenteredScreen` | Full-page centered content (pre-auth screens only) | +| `FullScreenSpinner` | Full-page loading state | +| `ThemeToggle` | Already in `AdminLayout` — don't add another one | Only promote a one-off to `shared/` once a second, genuinely identical use appears (see "Componentization" above) — until then it stays @@ -455,13 +500,13 @@ it's a fixed light-mode color that breaks the moment a user switches to dark. | Instead of (stale, don't use) | Use | For | -| -------------------------------------- | -------------------------------- | -------------------------------- | -| `bg-slate-50` | `bg-background` | Page background | -| `bg-white` | `bg-card` | Card/surface background | -| `border-slate-200` | `border-border` | Card and divider borders | -| `text-slate-800` | `text-foreground` | Headings, primary text | +| ----------------------------------- | ----------------------------- | ----------------------------- | +| `bg-slate-50` | `bg-background` | Page background | +| `bg-white` | `bg-card` | Card/surface background | +| `border-slate-200` | `border-border` | Card and divider borders | +| `text-slate-800` | `text-foreground` | Headings, primary text | | `text-slate-600` / `text-slate-400` | `text-muted-foreground` | Secondary/muted text | -| `text-red-600` | `text-destructive` | Error text | +| `text-red-600` | `text-destructive` | Error text | | `bg-teal-600` / `text-teal-700` | `text-primary` / `bg-primary` | Brand accent, primary buttons | There is no brand color to special-case — the app uses the stock @@ -487,7 +532,7 @@ non-token color. - `Dialog` is responsive by default (`max-w-[calc(100%-2rem)]` below its `sm:` breakpoint). - Any `flex` row inside a form that could get tight still needs - `flex-wrap` — see `TagForm`'s color swatches and button row. + `flex-wrap` — see `CategoryForm`'s button row. - Never use a fixed pixel width wider than ~300px without a responsive override. Prefer `w-full` + `max-w-*`. - `AdminLayout` already handles the page shell (off-canvas sidebar below @@ -505,12 +550,13 @@ last-known-good-after-a-failed-refresh states). Every string a user reads or a screen reader announces — headings, button labels, `PageHeader`/`StatusMessage` text, form labels/hints, `aria-label`s, confirm prompts, error-message fallbacks — is pt-BR. See -`TagsPage`/`TagForm` for the pattern (e.g. "Nova etiqueta", `aria-label="Cor -${paletteColor}"`). Code stays in English: identifiers, comments, commit +`CategoriesListPage`/`CategoryEditorDialog` for the pattern (e.g. "Nova +categoria", `aria-label={\`Excluir categoria ${category.name}\`}`). Code +stays in English: identifiers, comments, commit messages, this skill's own prose. Nav labels (source of truth: `AdminLayout.tsx`'s `NAV_ITEMS`) are Painel, -Agendamentos, Serviços, Clientes, Caixa de entrada, Etiquetas, +Agendamentos, Serviços, Categorias, Clientes, Caixa de entrada, Configurações — reuse the exact same word for a stub page's `PlaceholderPage title` and for that vertical's `PageHeader title` once built. @@ -548,13 +594,13 @@ built. ```typescript // shared/application/HttpClient.ts -export type Decoder = (payload: unknown) => T +export type Decoder = (payload: unknown) => T; export interface HttpClient { - get(path: string, decode: Decoder): Promise - post(path: string, body: unknown, decode: Decoder): Promise - put(path: string, body: unknown, decode: Decoder): Promise - delete(path: string): Promise + get(path: string, decode: Decoder): Promise; + post(path: string, body: unknown, decode: Decoder): Promise; + put(path: string, body: unknown, decode: Decoder): Promise; + delete(path: string): Promise; } ``` @@ -562,8 +608,8 @@ Every `get`/`post`/`put` call takes a `decode` function alongside its `T` - a generic type parameter alone validates nothing at runtime, so the decoder is what actually stands between an untrusted response body and a value the rest of the app treats as `T` (docs/adr/011). A feature's mapper -owns its own decoder next to its DTO type (e.g. `tagMapper.ts`'s -`decodeTagDto`/`decodeTagDtoArray`) - hand-rolled `typeof`/`Array.isArray` +owns its own decoder next to its DTO type (e.g. `categoryMapper.ts`'s +`decodeCategoryDto`/`decodeCategoryDtoArray`) - hand-rolled `typeof`/`Array.isArray` guards matching `shared/infrastructure/http/ProblemDetails.ts`'s existing style, not a schema library. A decoder that throws is caught by the same place every other infrastructure failure already is (see below) - never @@ -601,8 +647,8 @@ read. than one - [ ] Page: handles loading/error/success, built from shadcn/ui primitives and shared composites (not hand-rolled markup) -- [ ] List uses `Table`, form opens in a `Dialog` — not stacked `Card`s - or an inline/routed form +- [ ] List uses `Table`; form uses the feature's documented interaction + (`Dialog` by default, routed editor only where an ADR establishes it) - [ ] Destructive actions confirmed with `DeleteConfirmationDialog` — not `window.confirm` or a hand-rolled `AlertDialog` - [ ] No prop/variant added to a `src/components/ui/*` file unless this diff --git a/.claude/skills/agenza-frontend-feature/SKILL.md b/.claude/skills/agenza-frontend-feature/SKILL.md index f29063d..8c7f3cb 100644 --- a/.claude/skills/agenza-frontend-feature/SKILL.md +++ b/.claude/skills/agenza-frontend-feature/SKILL.md @@ -40,18 +40,22 @@ src/ index.ts public API - everything outside this feature imports through here, never a deep path into the above - catalog/ Tags, Categories, Services - one feature, they - collaborate in the same business context - domain/ Tag, Category, Service entities + their errors + catalog/ Categories, Services - one feature, they + collaborate in the same business context. Tags + was removed from the frontend (docs/adr/016 in + this app's ADRs) - the backend Tag domain/API + is intentionally retained, unrelated to this + feature's current frontend shape + domain/ Category, Service entities + their errors application/ 3 repository ports, 12 use cases infrastructure/ Api*Repository, mappers, generated/ (OpenAPI types) - presentation/ every entity folder (tags/, categories/, services/) + presentation/ every entity folder (categories/, services/) shares the same internal shape - location alone tells you a file's role: / - Page.tsx composition shell (stays at entity root - see - "Componentization" below for why) - hooks/ data hook (useTags/useCategories/useServices) + Page.tsx composition shell (stays at entity root by + default; Categories uses pages/, ADR 012) + hooks/ data hook (useCategories/useServices) + controller hook (useXPage) + any sub-hooks (useServiceEditor, useServiceDeletion, ...) components/ presentational pieces: tables, dialogs, @@ -61,8 +65,8 @@ src/ across entities - see "Forms" below) models/ services/ only - pure, non-React view-model/ formatting logic (servicePresentationModels, - serviceFormatters); tags/categories have no - equivalent, so no models/ for them + serviceFormatters); categories has no + equivalent, so no models/ for it index.ts public API shared/ @@ -122,10 +126,10 @@ state, UI, and completion criteria. REST repository depends on it; it already exists for every current feature. 3. **Decide whether this is a new feature or belongs in an existing - one.** A resource that collaborates closely with Tags/Categories/ - Services (shares forms, cross-references, or the same backend service) - belongs in `features/catalog/`; a genuinely independent domain gets its - own `features//` following the same four-layer shape. + one.** A resource that collaborates closely with Categories/Services + (shares forms, cross-references, or the same backend service) belongs + in `features/catalog/`; a genuinely independent domain gets its own + `features//` following the same four-layer shape. 4. **Identify which use cases the current page actually needs.** Don't build every possible use case upfront. @@ -173,20 +177,38 @@ as `apps/admin-frontend/AGENTS.md` and `backend/AGENTS.md`. React, that feature's own `application/`, `infrastructure/`, or `presentation/`, and zero imports from another feature. Private constructor + static `create(input)` factory that validates invariants -and throws a named error subclassing `shared/domain/DomainError.ts` — see -`Category.ts`/`Service.ts` (`features/catalog/domain/entities/`) for the -existing pattern (`InvalidCategoryError`/`InvalidServiceError`). This is -the frontend's own, already-established exception-and-catch convention -for validation failures, distinct from the .NET backend's Result/ -DomainResult pattern (docs/adr/0014, `backend/AGENTS.md`) — that ADR -governs the backend only; every caller here already expects a throw -(`useAsync`'s `catch`, `mapApiErrorToForm`). No constructor parameter -property shorthand (`erasableSyntaxOnly`) — explicit field declarations + -assignment in the constructor body. Optional fields: `if (value !== -undefined) { this.field = value }`, never a direct assignment of a -possibly-`undefined` value (`exactOptionalPropertyTypes`). `strict: true` -— never `any`; if a value's shape is genuinely unknown at a boundary, -type it `unknown` and narrow it, never widen with `any`. +and returns `Result` +(`shared/application/Result.ts`) instead of throwing (docs/adr/014, +docs/adr/015 — both Catalog's `Category.ts` and Auth's +`Session.ts`/`User.ts`/`Tenant.ts` follow this). Every caller composes +with `flatMapResult`/`combineResults`, or plain early-return `Result` +branching for a short sequential chain with heterogeneous error types +(see `mapOidcUserToSession`, `features/auth/infrastructure/`) — never +`try/catch`. A mapper that turns a domain validation failure arising from +an untrusted API response into a curated `AppError` uses +`shared/infrastructure/http/malformedResponseError.ts`, not its own +message. `useAsync` (`shared/presentation/hooks/useAsync.ts`) takes +`() => Promise>`, not a throwing `() => Promise`. + +A test fixture that needs a known-valid entity (most test files touching +auth or catalog do) imports `Tenant`/`User`/`Session`/`Category` +from `src/test/fixtures/{authEntityFixtures,unwrapResult}.ts` instead of +the real `domain/entities/` path — those re-export the same `create()` +call shape already unwrapped, so call sites read exactly like before +without every test wrapping every call in `unwrapResult(...)`. Only each +entity's own `*.test.ts` imports the real class directly, since it +specifically asserts on both the success and failure `Result` shapes. + +Every feature vertical (Catalog now, Auth now, a future one like +Services) follows this same Result convention — there is no throwing +variant left to mirror. + +No constructor parameter property shorthand (`erasableSyntaxOnly`) — +explicit field declarations + assignment in the constructor body. Optional +fields: `if (value !== undefined) { this.field = value }`, never a direct +assignment of a possibly-`undefined` value (`exactOptionalPropertyTypes`). +`strict: true` — never `any`; if a value's shape is genuinely unknown at a +boundary, type it `unknown` and narrow it, never widen with `any`. ### 2. Repository interface (no test needed) @@ -203,10 +225,10 @@ one class per use case, explicit constructor body (no shorthand): ```typescript export class ListServices { - private readonly serviceRepository: ServiceRepository + private readonly serviceRepository: ServiceRepository; constructor(serviceRepository: ServiceRepository) { - this.serviceRepository = serviceRepository + this.serviceRepository = serviceRepository; } } ``` @@ -240,8 +262,8 @@ field pattern). Tests use MSW handlers in `src/test/mocks/handlers/index.ts`. `onUnhandledRequest: 'error'` is global — any call without a registered handler fails loudly. A test mock handler typing a fixture against a feature's internal DTO type -(`import type { TagDto } from '@/features/catalog/infrastructure/ -mappers/tagMapper'`) is the one place allowed to import a feature's +(`import type { CategoryDto } from '@/features/catalog/infrastructure/ +mappers/categoryMapper'`) is the one place allowed to import a feature's internals directly from outside it — `src/test/**` is exempt from the public-API-only rule (ESLint + `architecture_guard.py` both carve this out explicitly). @@ -250,7 +272,7 @@ out explicitly). `shared/presentation/hooks/useAsync.ts` is the one shared "call an async function, track loading/data/error" primitive — every feature hook -(`useCategories`, `useServices`, `useTags` — and `AuthProvider` for the +(`useCategories`, `useServices` — and `AuthProvider` for the shared session) builds on it instead of a bespoke `useState`/`useEffect` pair or a server-state library (see "Prohibited" below). It already handles the two things that are easy to get wrong by hand: @@ -263,7 +285,7 @@ handles the two things that are easy to get wrong by hand: - **Unmounted-component writes**: guarded internally; you don't need your own `isMounted` ref. -For a mutation (`createTag`, `updateTag`, `deleteTag` in `useTags.ts`), +For a mutation (create/update/delete on a feature's data hook), **a create's success must not depend on the follow-up refetch succeeding**: call `mutate(current => [...(current ?? []), created])` to insert the new item into the hook's state immediately after the write succeeds, then @@ -282,24 +304,37 @@ frame (multi-tenancy — see root `AGENTS.md`). ### 8. Page component -Replace the stub. **`TagsPage` is the reference for behavior and design** -(search → table → dialog create/edit → `AlertDialog` delete-confirm, -loading/error/empty states) — **not for anatomy**. Copy the *pattern*, not -the file count: a feature with more independent workflows legitimately -needs more files than Tags does. See "Componentization" below for when -and how to split a page's controller hook, form, and dialog. +Replace the stub. **`CategoriesListPage`/`CategoryEditorDialog` +(`features/catalog/presentation/categories/`) is the reference for +behavior and design** (search → table → dialog create/edit → +`AlertDialog` delete-confirm, loading/error/empty states) — **not for +anatomy**. Copy the _pattern_, not the file count: a feature with more +independent workflows legitimately needs more files than Categories does. +See "Componentization" below for when and how to split a page's +controller hook, form, and dialog. -#### List = `Table`, form = `Dialog` — always +#### List = `Table`; form = `Dialog` by default A page listing records renders a `Table` (`src/components/ui/table.tsx`): one row per record, actions (Edit/Delete) as buttons in the last column — -not stacked `Card`s. A create/edit form always opens in a `Dialog` -(`src/components/ui/dialog.tsx`) over the list — never inline in the page, -never its own route. One `Dialog` instance whose content switches between -create/edit based on which record (if any) triggered it (`TagsPage`'s -dialog-target state), not a dialog per row. The form component itself stays -dialog-agnostic — the page (or its dialog wrapper) wires it into the -`Dialog`. +not stacked `Card`s. A create/edit form opens in a `Dialog` +(`src/components/ui/dialog.tsx`) over the list by default. One `Dialog` +instance switches between create/edit based on which record triggered it, +not a dialog per row. The form component stays dialog-agnostic. + +Categories maps `/categories/new` and `/categories/:id/edit` to the same +nested editor `Dialog` over the still-mounted `/categories` list +(docs/adr/012). `CategoryEditorDialog` renders one `CategoryForm` and +`useCategoryEditor` selects create or update from the route. In edit mode +`useCategoryEditor` fetches its own category directly via +`GET /api/v1/categories/{id}` — it does **not** read the list's data +through outlet context (docs/adr/013 superseded that shape; a +`useOutletContext()` cast has no runtime guarantee an ancestor route +actually supplied a value). `useCategoriesListPage` refetches the list +unconditionally whenever navigation returns from the editor route back to +the bare `/categories` route, whether the editor closed via cancel or a +successful save. Its smartphone table uses labelled icon actions with +larger touch targets and reveals action text from `sm` upward. A destructive action (delete) is confirmed with the shared `DeleteConfirmationDialog` (`shared/presentation/components/`, built on @@ -318,7 +353,7 @@ machine behind it. - A controller hook (`useXPage`) follows the same single-responsibility bar as any other code: when it accumulates more than one real workflow (search/filter state, an editor with dirty-tracking, a deletion - confirmation are three *different* concerns), split it into focused + confirmation are three _different_ concerns), split it into focused hooks (`useXFilters`, `useXEditor`, `useXDeletion`) that the page's composer hook assembles — see `features/catalog/presentation/services/hooks/` for the reference (`useServicesPage` composing `useServiceFilters` + @@ -326,7 +361,7 @@ machine behind it. - Extract a component or hook on its **first** use if it's already a distinct concern (a field group, a delete dialog) — keep it feature-local (e.g. `features/catalog/presentation/services/components/ - ServiceCategoryField.tsx`). Only **promote** something to `shared/` +ServiceCategoryField.tsx`). Only **promote** something to `shared/` once a **second**, genuinely-identical use appears across features — the "second use" rule gates promotion, not the initial extraction. - Break a type cycle between a controller and the component(s) it feeds @@ -350,25 +385,35 @@ machine behind it. #### Forms: React Hook Form + Zod Any form beyond a single trivial field uses `react-hook-form` + -`@hookform/resolvers/zod` — see `TagForm.tsx` -(`features/catalog/presentation/tags/forms/`) for the exact shape: +`@hookform/resolvers/zod` — see `CategoryForm.tsx` +(`features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/`) +for the exact shape: ```typescript -const tagFormSchema = z.object({ - name: z.string().trim().min(1, NAME_MESSAGE).max(40, NAME_MESSAGE), - color: z.enum(TAG_COLOR_PALETTE, { message: COLOR_MESSAGE }), -}) -export type TagFormValues = z.infer - -const { register, control, handleSubmit, setError, setFocus, formState: { errors } } = - useForm({ - resolver: zodResolver(tagFormSchema), - defaultValues: initialValues, - mode: 'onTouched', - reValidateMode: 'onChange', - }) +const categoryFormSchema = z.object({ + name: z.string().trim().min(1, NAME_MESSAGE).max(60, NAME_MESSAGE), +}); +export type CategoryFormValues = z.infer; + +const { + register, + handleSubmit, + setError, + setFocus, + formState: { errors }, +} = useForm({ + resolver: zodResolver(categoryFormSchema), + defaultValues: initialValues, + mode: "onTouched", + reValidateMode: "onChange", +}); ``` +(A field wired through `Controller` instead of `register` — e.g. a +`Select`, a color swatch group, a multi-value picker — also destructures +`control` from `useForm`; `CategoryForm` doesn't need one since its only +field is a plain text input.) + - ` void handleSubmit(onSubmit)(e)} noValidate ...>` — `noValidate` because native browser constraint validation would intercept submit before react-hook-form/zod ever sees it. @@ -393,10 +438,10 @@ const { register, control, handleSubmit, setError, setFocus, formState: { errors result with `setError(field, { type: 'server', message })` in a `useEffect` keyed on the server-error object, and `setFocus(firstField)` so a screen-reader/keyboard user lands on the - first invalid field instead of losing their position — see `TagForm`'s - `serverError` effect. + first invalid field instead of losing their position — see + `CategoryForm`'s `serverError` effect. - Don't reach for Formik or Yup without an explicit ADR — React Hook Form - + Zod is the established, working pattern here (`docs/DECISIONS.md`). + - Zod is the established, working pattern here (`docs/DECISIONS.md`). #### Inline creation (a select that can create its own options) @@ -431,16 +476,16 @@ wants it." Do it at the call site instead (a conditional `` in Shared composites live in `shared/presentation/components/` — reuse before writing a new one: -| Component | Use for | -| --------------------------------- | ------------------------------------------------------------------ | -| `PageHeader` | Title + primary action row at the top of every page | -| `StatusMessage` | Loading / empty / error text (`tone="error"` for errors) | -| `CollectionFeedback` | Loading/error/empty/last-known-good states for a tenant-scoped list | -| `DeleteConfirmationDialog` | Destructive-action `AlertDialog`, wired to `useDeleteConfirmation` | -| `TextField` / `TextAreaField` | Labeled form inputs (wraps shadcn `Label` + `Input`/`Textarea`) | -| `CenteredScreen` | Full-page centered content (pre-auth screens only) | -| `FullScreenSpinner` | Full-page loading state | -| `ThemeToggle` | Already in `AdminLayout` — don't add another one | +| Component | Use for | +| ----------------------------- | ------------------------------------------------------------------- | +| `PageHeader` | Title + primary action row at the top of every page | +| `StatusMessage` | Loading / empty / error text (`tone="error"` for errors) | +| `CollectionFeedback` | Loading/error/empty/last-known-good states for a tenant-scoped list | +| `DeleteConfirmationDialog` | Destructive-action `AlertDialog`, wired to `useDeleteConfirmation` | +| `TextField` / `TextAreaField` | Labeled form inputs (wraps shadcn `Label` + `Input`/`Textarea`) | +| `CenteredScreen` | Full-page centered content (pre-auth screens only) | +| `FullScreenSpinner` | Full-page loading state | +| `ThemeToggle` | Already in `AdminLayout` — don't add another one | Only promote a one-off to `shared/` once a second, genuinely identical use appears (see "Componentization" above) — until then it stays @@ -455,13 +500,13 @@ it's a fixed light-mode color that breaks the moment a user switches to dark. | Instead of (stale, don't use) | Use | For | -| -------------------------------------- | -------------------------------- | -------------------------------- | -| `bg-slate-50` | `bg-background` | Page background | -| `bg-white` | `bg-card` | Card/surface background | -| `border-slate-200` | `border-border` | Card and divider borders | -| `text-slate-800` | `text-foreground` | Headings, primary text | +| ----------------------------------- | ----------------------------- | ----------------------------- | +| `bg-slate-50` | `bg-background` | Page background | +| `bg-white` | `bg-card` | Card/surface background | +| `border-slate-200` | `border-border` | Card and divider borders | +| `text-slate-800` | `text-foreground` | Headings, primary text | | `text-slate-600` / `text-slate-400` | `text-muted-foreground` | Secondary/muted text | -| `text-red-600` | `text-destructive` | Error text | +| `text-red-600` | `text-destructive` | Error text | | `bg-teal-600` / `text-teal-700` | `text-primary` / `bg-primary` | Brand accent, primary buttons | There is no brand color to special-case — the app uses the stock @@ -487,7 +532,7 @@ non-token color. - `Dialog` is responsive by default (`max-w-[calc(100%-2rem)]` below its `sm:` breakpoint). - Any `flex` row inside a form that could get tight still needs - `flex-wrap` — see `TagForm`'s color swatches and button row. + `flex-wrap` — see `CategoryForm`'s button row. - Never use a fixed pixel width wider than ~300px without a responsive override. Prefer `w-full` + `max-w-*`. - `AdminLayout` already handles the page shell (off-canvas sidebar below @@ -505,12 +550,13 @@ last-known-good-after-a-failed-refresh states). Every string a user reads or a screen reader announces — headings, button labels, `PageHeader`/`StatusMessage` text, form labels/hints, `aria-label`s, confirm prompts, error-message fallbacks — is pt-BR. See -`TagsPage`/`TagForm` for the pattern (e.g. "Nova etiqueta", `aria-label="Cor -${paletteColor}"`). Code stays in English: identifiers, comments, commit +`CategoriesListPage`/`CategoryEditorDialog` for the pattern (e.g. "Nova +categoria", `aria-label={\`Excluir categoria ${category.name}\`}`). Code +stays in English: identifiers, comments, commit messages, this skill's own prose. Nav labels (source of truth: `AdminLayout.tsx`'s `NAV_ITEMS`) are Painel, -Agendamentos, Serviços, Clientes, Caixa de entrada, Etiquetas, +Agendamentos, Serviços, Categorias, Clientes, Caixa de entrada, Configurações — reuse the exact same word for a stub page's `PlaceholderPage title` and for that vertical's `PageHeader title` once built. @@ -548,13 +594,13 @@ built. ```typescript // shared/application/HttpClient.ts -export type Decoder = (payload: unknown) => T +export type Decoder = (payload: unknown) => T; export interface HttpClient { - get(path: string, decode: Decoder): Promise - post(path: string, body: unknown, decode: Decoder): Promise - put(path: string, body: unknown, decode: Decoder): Promise - delete(path: string): Promise + get(path: string, decode: Decoder): Promise; + post(path: string, body: unknown, decode: Decoder): Promise; + put(path: string, body: unknown, decode: Decoder): Promise; + delete(path: string): Promise; } ``` @@ -562,8 +608,8 @@ Every `get`/`post`/`put` call takes a `decode` function alongside its `T` - a generic type parameter alone validates nothing at runtime, so the decoder is what actually stands between an untrusted response body and a value the rest of the app treats as `T` (docs/adr/011). A feature's mapper -owns its own decoder next to its DTO type (e.g. `tagMapper.ts`'s -`decodeTagDto`/`decodeTagDtoArray`) - hand-rolled `typeof`/`Array.isArray` +owns its own decoder next to its DTO type (e.g. `categoryMapper.ts`'s +`decodeCategoryDto`/`decodeCategoryDtoArray`) - hand-rolled `typeof`/`Array.isArray` guards matching `shared/infrastructure/http/ProblemDetails.ts`'s existing style, not a schema library. A decoder that throws is caught by the same place every other infrastructure failure already is (see below) - never @@ -601,8 +647,8 @@ read. than one - [ ] Page: handles loading/error/success, built from shadcn/ui primitives and shared composites (not hand-rolled markup) -- [ ] List uses `Table`, form opens in a `Dialog` — not stacked `Card`s - or an inline/routed form +- [ ] List uses `Table`; form uses the feature's documented interaction + (`Dialog` by default, routed editor only where an ADR establishes it) - [ ] Destructive actions confirmed with `DeleteConfirmationDialog` — not `window.confirm` or a hand-rolled `AlertDialog` - [ ] No prop/variant added to a `src/components/ui/*` file unless this diff --git a/agent-skills/agenza-frontend-feature/SKILL.md b/agent-skills/agenza-frontend-feature/SKILL.md index f29063d..8c7f3cb 100644 --- a/agent-skills/agenza-frontend-feature/SKILL.md +++ b/agent-skills/agenza-frontend-feature/SKILL.md @@ -40,18 +40,22 @@ src/ index.ts public API - everything outside this feature imports through here, never a deep path into the above - catalog/ Tags, Categories, Services - one feature, they - collaborate in the same business context - domain/ Tag, Category, Service entities + their errors + catalog/ Categories, Services - one feature, they + collaborate in the same business context. Tags + was removed from the frontend (docs/adr/016 in + this app's ADRs) - the backend Tag domain/API + is intentionally retained, unrelated to this + feature's current frontend shape + domain/ Category, Service entities + their errors application/ 3 repository ports, 12 use cases infrastructure/ Api*Repository, mappers, generated/ (OpenAPI types) - presentation/ every entity folder (tags/, categories/, services/) + presentation/ every entity folder (categories/, services/) shares the same internal shape - location alone tells you a file's role: / - Page.tsx composition shell (stays at entity root - see - "Componentization" below for why) - hooks/ data hook (useTags/useCategories/useServices) + Page.tsx composition shell (stays at entity root by + default; Categories uses pages/, ADR 012) + hooks/ data hook (useCategories/useServices) + controller hook (useXPage) + any sub-hooks (useServiceEditor, useServiceDeletion, ...) components/ presentational pieces: tables, dialogs, @@ -61,8 +65,8 @@ src/ across entities - see "Forms" below) models/ services/ only - pure, non-React view-model/ formatting logic (servicePresentationModels, - serviceFormatters); tags/categories have no - equivalent, so no models/ for them + serviceFormatters); categories has no + equivalent, so no models/ for it index.ts public API shared/ @@ -122,10 +126,10 @@ state, UI, and completion criteria. REST repository depends on it; it already exists for every current feature. 3. **Decide whether this is a new feature or belongs in an existing - one.** A resource that collaborates closely with Tags/Categories/ - Services (shares forms, cross-references, or the same backend service) - belongs in `features/catalog/`; a genuinely independent domain gets its - own `features//` following the same four-layer shape. + one.** A resource that collaborates closely with Categories/Services + (shares forms, cross-references, or the same backend service) belongs + in `features/catalog/`; a genuinely independent domain gets its own + `features//` following the same four-layer shape. 4. **Identify which use cases the current page actually needs.** Don't build every possible use case upfront. @@ -173,20 +177,38 @@ as `apps/admin-frontend/AGENTS.md` and `backend/AGENTS.md`. React, that feature's own `application/`, `infrastructure/`, or `presentation/`, and zero imports from another feature. Private constructor + static `create(input)` factory that validates invariants -and throws a named error subclassing `shared/domain/DomainError.ts` — see -`Category.ts`/`Service.ts` (`features/catalog/domain/entities/`) for the -existing pattern (`InvalidCategoryError`/`InvalidServiceError`). This is -the frontend's own, already-established exception-and-catch convention -for validation failures, distinct from the .NET backend's Result/ -DomainResult pattern (docs/adr/0014, `backend/AGENTS.md`) — that ADR -governs the backend only; every caller here already expects a throw -(`useAsync`'s `catch`, `mapApiErrorToForm`). No constructor parameter -property shorthand (`erasableSyntaxOnly`) — explicit field declarations + -assignment in the constructor body. Optional fields: `if (value !== -undefined) { this.field = value }`, never a direct assignment of a -possibly-`undefined` value (`exactOptionalPropertyTypes`). `strict: true` -— never `any`; if a value's shape is genuinely unknown at a boundary, -type it `unknown` and narrow it, never widen with `any`. +and returns `Result` +(`shared/application/Result.ts`) instead of throwing (docs/adr/014, +docs/adr/015 — both Catalog's `Category.ts` and Auth's +`Session.ts`/`User.ts`/`Tenant.ts` follow this). Every caller composes +with `flatMapResult`/`combineResults`, or plain early-return `Result` +branching for a short sequential chain with heterogeneous error types +(see `mapOidcUserToSession`, `features/auth/infrastructure/`) — never +`try/catch`. A mapper that turns a domain validation failure arising from +an untrusted API response into a curated `AppError` uses +`shared/infrastructure/http/malformedResponseError.ts`, not its own +message. `useAsync` (`shared/presentation/hooks/useAsync.ts`) takes +`() => Promise>`, not a throwing `() => Promise`. + +A test fixture that needs a known-valid entity (most test files touching +auth or catalog do) imports `Tenant`/`User`/`Session`/`Category` +from `src/test/fixtures/{authEntityFixtures,unwrapResult}.ts` instead of +the real `domain/entities/` path — those re-export the same `create()` +call shape already unwrapped, so call sites read exactly like before +without every test wrapping every call in `unwrapResult(...)`. Only each +entity's own `*.test.ts` imports the real class directly, since it +specifically asserts on both the success and failure `Result` shapes. + +Every feature vertical (Catalog now, Auth now, a future one like +Services) follows this same Result convention — there is no throwing +variant left to mirror. + +No constructor parameter property shorthand (`erasableSyntaxOnly`) — +explicit field declarations + assignment in the constructor body. Optional +fields: `if (value !== undefined) { this.field = value }`, never a direct +assignment of a possibly-`undefined` value (`exactOptionalPropertyTypes`). +`strict: true` — never `any`; if a value's shape is genuinely unknown at a +boundary, type it `unknown` and narrow it, never widen with `any`. ### 2. Repository interface (no test needed) @@ -203,10 +225,10 @@ one class per use case, explicit constructor body (no shorthand): ```typescript export class ListServices { - private readonly serviceRepository: ServiceRepository + private readonly serviceRepository: ServiceRepository; constructor(serviceRepository: ServiceRepository) { - this.serviceRepository = serviceRepository + this.serviceRepository = serviceRepository; } } ``` @@ -240,8 +262,8 @@ field pattern). Tests use MSW handlers in `src/test/mocks/handlers/index.ts`. `onUnhandledRequest: 'error'` is global — any call without a registered handler fails loudly. A test mock handler typing a fixture against a feature's internal DTO type -(`import type { TagDto } from '@/features/catalog/infrastructure/ -mappers/tagMapper'`) is the one place allowed to import a feature's +(`import type { CategoryDto } from '@/features/catalog/infrastructure/ +mappers/categoryMapper'`) is the one place allowed to import a feature's internals directly from outside it — `src/test/**` is exempt from the public-API-only rule (ESLint + `architecture_guard.py` both carve this out explicitly). @@ -250,7 +272,7 @@ out explicitly). `shared/presentation/hooks/useAsync.ts` is the one shared "call an async function, track loading/data/error" primitive — every feature hook -(`useCategories`, `useServices`, `useTags` — and `AuthProvider` for the +(`useCategories`, `useServices` — and `AuthProvider` for the shared session) builds on it instead of a bespoke `useState`/`useEffect` pair or a server-state library (see "Prohibited" below). It already handles the two things that are easy to get wrong by hand: @@ -263,7 +285,7 @@ handles the two things that are easy to get wrong by hand: - **Unmounted-component writes**: guarded internally; you don't need your own `isMounted` ref. -For a mutation (`createTag`, `updateTag`, `deleteTag` in `useTags.ts`), +For a mutation (create/update/delete on a feature's data hook), **a create's success must not depend on the follow-up refetch succeeding**: call `mutate(current => [...(current ?? []), created])` to insert the new item into the hook's state immediately after the write succeeds, then @@ -282,24 +304,37 @@ frame (multi-tenancy — see root `AGENTS.md`). ### 8. Page component -Replace the stub. **`TagsPage` is the reference for behavior and design** -(search → table → dialog create/edit → `AlertDialog` delete-confirm, -loading/error/empty states) — **not for anatomy**. Copy the *pattern*, not -the file count: a feature with more independent workflows legitimately -needs more files than Tags does. See "Componentization" below for when -and how to split a page's controller hook, form, and dialog. +Replace the stub. **`CategoriesListPage`/`CategoryEditorDialog` +(`features/catalog/presentation/categories/`) is the reference for +behavior and design** (search → table → dialog create/edit → +`AlertDialog` delete-confirm, loading/error/empty states) — **not for +anatomy**. Copy the _pattern_, not the file count: a feature with more +independent workflows legitimately needs more files than Categories does. +See "Componentization" below for when and how to split a page's +controller hook, form, and dialog. -#### List = `Table`, form = `Dialog` — always +#### List = `Table`; form = `Dialog` by default A page listing records renders a `Table` (`src/components/ui/table.tsx`): one row per record, actions (Edit/Delete) as buttons in the last column — -not stacked `Card`s. A create/edit form always opens in a `Dialog` -(`src/components/ui/dialog.tsx`) over the list — never inline in the page, -never its own route. One `Dialog` instance whose content switches between -create/edit based on which record (if any) triggered it (`TagsPage`'s -dialog-target state), not a dialog per row. The form component itself stays -dialog-agnostic — the page (or its dialog wrapper) wires it into the -`Dialog`. +not stacked `Card`s. A create/edit form opens in a `Dialog` +(`src/components/ui/dialog.tsx`) over the list by default. One `Dialog` +instance switches between create/edit based on which record triggered it, +not a dialog per row. The form component stays dialog-agnostic. + +Categories maps `/categories/new` and `/categories/:id/edit` to the same +nested editor `Dialog` over the still-mounted `/categories` list +(docs/adr/012). `CategoryEditorDialog` renders one `CategoryForm` and +`useCategoryEditor` selects create or update from the route. In edit mode +`useCategoryEditor` fetches its own category directly via +`GET /api/v1/categories/{id}` — it does **not** read the list's data +through outlet context (docs/adr/013 superseded that shape; a +`useOutletContext()` cast has no runtime guarantee an ancestor route +actually supplied a value). `useCategoriesListPage` refetches the list +unconditionally whenever navigation returns from the editor route back to +the bare `/categories` route, whether the editor closed via cancel or a +successful save. Its smartphone table uses labelled icon actions with +larger touch targets and reveals action text from `sm` upward. A destructive action (delete) is confirmed with the shared `DeleteConfirmationDialog` (`shared/presentation/components/`, built on @@ -318,7 +353,7 @@ machine behind it. - A controller hook (`useXPage`) follows the same single-responsibility bar as any other code: when it accumulates more than one real workflow (search/filter state, an editor with dirty-tracking, a deletion - confirmation are three *different* concerns), split it into focused + confirmation are three _different_ concerns), split it into focused hooks (`useXFilters`, `useXEditor`, `useXDeletion`) that the page's composer hook assembles — see `features/catalog/presentation/services/hooks/` for the reference (`useServicesPage` composing `useServiceFilters` + @@ -326,7 +361,7 @@ machine behind it. - Extract a component or hook on its **first** use if it's already a distinct concern (a field group, a delete dialog) — keep it feature-local (e.g. `features/catalog/presentation/services/components/ - ServiceCategoryField.tsx`). Only **promote** something to `shared/` +ServiceCategoryField.tsx`). Only **promote** something to `shared/` once a **second**, genuinely-identical use appears across features — the "second use" rule gates promotion, not the initial extraction. - Break a type cycle between a controller and the component(s) it feeds @@ -350,25 +385,35 @@ machine behind it. #### Forms: React Hook Form + Zod Any form beyond a single trivial field uses `react-hook-form` + -`@hookform/resolvers/zod` — see `TagForm.tsx` -(`features/catalog/presentation/tags/forms/`) for the exact shape: +`@hookform/resolvers/zod` — see `CategoryForm.tsx` +(`features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/`) +for the exact shape: ```typescript -const tagFormSchema = z.object({ - name: z.string().trim().min(1, NAME_MESSAGE).max(40, NAME_MESSAGE), - color: z.enum(TAG_COLOR_PALETTE, { message: COLOR_MESSAGE }), -}) -export type TagFormValues = z.infer - -const { register, control, handleSubmit, setError, setFocus, formState: { errors } } = - useForm({ - resolver: zodResolver(tagFormSchema), - defaultValues: initialValues, - mode: 'onTouched', - reValidateMode: 'onChange', - }) +const categoryFormSchema = z.object({ + name: z.string().trim().min(1, NAME_MESSAGE).max(60, NAME_MESSAGE), +}); +export type CategoryFormValues = z.infer; + +const { + register, + handleSubmit, + setError, + setFocus, + formState: { errors }, +} = useForm({ + resolver: zodResolver(categoryFormSchema), + defaultValues: initialValues, + mode: "onTouched", + reValidateMode: "onChange", +}); ``` +(A field wired through `Controller` instead of `register` — e.g. a +`Select`, a color swatch group, a multi-value picker — also destructures +`control` from `useForm`; `CategoryForm` doesn't need one since its only +field is a plain text input.) + - ` void handleSubmit(onSubmit)(e)} noValidate ...>` — `noValidate` because native browser constraint validation would intercept submit before react-hook-form/zod ever sees it. @@ -393,10 +438,10 @@ const { register, control, handleSubmit, setError, setFocus, formState: { errors result with `setError(field, { type: 'server', message })` in a `useEffect` keyed on the server-error object, and `setFocus(firstField)` so a screen-reader/keyboard user lands on the - first invalid field instead of losing their position — see `TagForm`'s - `serverError` effect. + first invalid field instead of losing their position — see + `CategoryForm`'s `serverError` effect. - Don't reach for Formik or Yup without an explicit ADR — React Hook Form - + Zod is the established, working pattern here (`docs/DECISIONS.md`). + - Zod is the established, working pattern here (`docs/DECISIONS.md`). #### Inline creation (a select that can create its own options) @@ -431,16 +476,16 @@ wants it." Do it at the call site instead (a conditional `` in Shared composites live in `shared/presentation/components/` — reuse before writing a new one: -| Component | Use for | -| --------------------------------- | ------------------------------------------------------------------ | -| `PageHeader` | Title + primary action row at the top of every page | -| `StatusMessage` | Loading / empty / error text (`tone="error"` for errors) | -| `CollectionFeedback` | Loading/error/empty/last-known-good states for a tenant-scoped list | -| `DeleteConfirmationDialog` | Destructive-action `AlertDialog`, wired to `useDeleteConfirmation` | -| `TextField` / `TextAreaField` | Labeled form inputs (wraps shadcn `Label` + `Input`/`Textarea`) | -| `CenteredScreen` | Full-page centered content (pre-auth screens only) | -| `FullScreenSpinner` | Full-page loading state | -| `ThemeToggle` | Already in `AdminLayout` — don't add another one | +| Component | Use for | +| ----------------------------- | ------------------------------------------------------------------- | +| `PageHeader` | Title + primary action row at the top of every page | +| `StatusMessage` | Loading / empty / error text (`tone="error"` for errors) | +| `CollectionFeedback` | Loading/error/empty/last-known-good states for a tenant-scoped list | +| `DeleteConfirmationDialog` | Destructive-action `AlertDialog`, wired to `useDeleteConfirmation` | +| `TextField` / `TextAreaField` | Labeled form inputs (wraps shadcn `Label` + `Input`/`Textarea`) | +| `CenteredScreen` | Full-page centered content (pre-auth screens only) | +| `FullScreenSpinner` | Full-page loading state | +| `ThemeToggle` | Already in `AdminLayout` — don't add another one | Only promote a one-off to `shared/` once a second, genuinely identical use appears (see "Componentization" above) — until then it stays @@ -455,13 +500,13 @@ it's a fixed light-mode color that breaks the moment a user switches to dark. | Instead of (stale, don't use) | Use | For | -| -------------------------------------- | -------------------------------- | -------------------------------- | -| `bg-slate-50` | `bg-background` | Page background | -| `bg-white` | `bg-card` | Card/surface background | -| `border-slate-200` | `border-border` | Card and divider borders | -| `text-slate-800` | `text-foreground` | Headings, primary text | +| ----------------------------------- | ----------------------------- | ----------------------------- | +| `bg-slate-50` | `bg-background` | Page background | +| `bg-white` | `bg-card` | Card/surface background | +| `border-slate-200` | `border-border` | Card and divider borders | +| `text-slate-800` | `text-foreground` | Headings, primary text | | `text-slate-600` / `text-slate-400` | `text-muted-foreground` | Secondary/muted text | -| `text-red-600` | `text-destructive` | Error text | +| `text-red-600` | `text-destructive` | Error text | | `bg-teal-600` / `text-teal-700` | `text-primary` / `bg-primary` | Brand accent, primary buttons | There is no brand color to special-case — the app uses the stock @@ -487,7 +532,7 @@ non-token color. - `Dialog` is responsive by default (`max-w-[calc(100%-2rem)]` below its `sm:` breakpoint). - Any `flex` row inside a form that could get tight still needs - `flex-wrap` — see `TagForm`'s color swatches and button row. + `flex-wrap` — see `CategoryForm`'s button row. - Never use a fixed pixel width wider than ~300px without a responsive override. Prefer `w-full` + `max-w-*`. - `AdminLayout` already handles the page shell (off-canvas sidebar below @@ -505,12 +550,13 @@ last-known-good-after-a-failed-refresh states). Every string a user reads or a screen reader announces — headings, button labels, `PageHeader`/`StatusMessage` text, form labels/hints, `aria-label`s, confirm prompts, error-message fallbacks — is pt-BR. See -`TagsPage`/`TagForm` for the pattern (e.g. "Nova etiqueta", `aria-label="Cor -${paletteColor}"`). Code stays in English: identifiers, comments, commit +`CategoriesListPage`/`CategoryEditorDialog` for the pattern (e.g. "Nova +categoria", `aria-label={\`Excluir categoria ${category.name}\`}`). Code +stays in English: identifiers, comments, commit messages, this skill's own prose. Nav labels (source of truth: `AdminLayout.tsx`'s `NAV_ITEMS`) are Painel, -Agendamentos, Serviços, Clientes, Caixa de entrada, Etiquetas, +Agendamentos, Serviços, Categorias, Clientes, Caixa de entrada, Configurações — reuse the exact same word for a stub page's `PlaceholderPage title` and for that vertical's `PageHeader title` once built. @@ -548,13 +594,13 @@ built. ```typescript // shared/application/HttpClient.ts -export type Decoder = (payload: unknown) => T +export type Decoder = (payload: unknown) => T; export interface HttpClient { - get(path: string, decode: Decoder): Promise - post(path: string, body: unknown, decode: Decoder): Promise - put(path: string, body: unknown, decode: Decoder): Promise - delete(path: string): Promise + get(path: string, decode: Decoder): Promise; + post(path: string, body: unknown, decode: Decoder): Promise; + put(path: string, body: unknown, decode: Decoder): Promise; + delete(path: string): Promise; } ``` @@ -562,8 +608,8 @@ Every `get`/`post`/`put` call takes a `decode` function alongside its `T` - a generic type parameter alone validates nothing at runtime, so the decoder is what actually stands between an untrusted response body and a value the rest of the app treats as `T` (docs/adr/011). A feature's mapper -owns its own decoder next to its DTO type (e.g. `tagMapper.ts`'s -`decodeTagDto`/`decodeTagDtoArray`) - hand-rolled `typeof`/`Array.isArray` +owns its own decoder next to its DTO type (e.g. `categoryMapper.ts`'s +`decodeCategoryDto`/`decodeCategoryDtoArray`) - hand-rolled `typeof`/`Array.isArray` guards matching `shared/infrastructure/http/ProblemDetails.ts`'s existing style, not a schema library. A decoder that throws is caught by the same place every other infrastructure failure already is (see below) - never @@ -601,8 +647,8 @@ read. than one - [ ] Page: handles loading/error/success, built from shadcn/ui primitives and shared composites (not hand-rolled markup) -- [ ] List uses `Table`, form opens in a `Dialog` — not stacked `Card`s - or an inline/routed form +- [ ] List uses `Table`; form uses the feature's documented interaction + (`Dialog` by default, routed editor only where an ADR establishes it) - [ ] Destructive actions confirmed with `DeleteConfirmationDialog` — not `window.confirm` or a hand-rolled `AlertDialog` - [ ] No prop/variant added to a `src/components/ui/*` file unless this