diff --git a/front-admin/CLAUDE.md b/front-admin/CLAUDE.md index 952abc3..46e7436 100644 --- a/front-admin/CLAUDE.md +++ b/front-admin/CLAUDE.md @@ -8,6 +8,8 @@ Design/protótipo das telas: [claude.ai/artifact/3B7ZWqjBQPDFZ5vhGgNs4N](https:/ Kit de UI do tema Sertão (componentes `Ph*`, tokens, acessibilidade): [`docs/UI-KIT.md`](docs/UI-KIT.md). Use-o antes de criar markup próprio. +Layout do painel (menu, barra superior, papéis, guarda de rota, erro global): [`docs/LAYOUT.md`](docs/LAYOUT.md). + Full layer rules live in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — read that first, it's the actual convention document, not a summary. `.claude/rules/` covers what's specific to this being a template (which module is the reference, why `HttpClient` is shaped the way it is, the two different reasons something is "shared"). `app/modules/login/` is the reference module — a complete, working example of every layer, with tests for each. Imitate its shape when creating a new module. @@ -85,12 +87,11 @@ Tokens oficiais em `../docs/design/tema-sertao-tokens.png`, aplicados em `app/as - **Tipos gerados do contrato**: `npm run api:gen` lê `http://localhost:8000/docs/api.json` (backend rodando) e escreve `app/infrastructure/http/api-types.d.ts`, commitado — mudar o contrato da API sem rodar `api:gen` de novo quebra o typecheck do front. Ver `app/application/shared/services/pingService.ts` como referência de serviço tipado a partir de `operations['']`. - **401 em requisição autenticada** chama o handler registrado por `httpClient.setUnauthorizedHandler()` — hoje só `app/plugins/auth.client.ts` registra (limpa `authStore`, navega pro `/login`). Um 401 sem token (ex.: login errado) não dispara isso — `HttpClient` só decide que a sessão expirou quando *tinha* token. - **Token Bearer, não cookie httpOnly** — decisão registrada em `docs/adr/0005-token-bearer-no-client.md` (o app mobile também consome a mesma API com o mesmo token). -- `useEdition()` (`app/application/shared/composables/useEdition.ts`) guarda a edição ativa — hoje só em memória, sem API por trás; liga no módulo Community quando ele existir. +- `useEdition()` (`app/application/shared/composables/useEdition.ts`) guarda a edição ativa (store `edition`), a lista de edições da pessoa e o papel dela (`load`, `select`, `role`). Consome `GET /editions` (ainda não existe no backend; sai do OpenAPI/`api:gen` quando o módulo Community expuser). Ver `docs/LAYOUT.md`. ## Roadmap (not built yet) - No generator/skill scaffolding a new module — creating one is by hand, following `login`'s shape and `docs/ARCHITECTURE.md`. - `register` (the natural second consumer of `useAuthSubmit`, alongside `login`) isn't built. -- No navigation chrome (sidebar/navbar) yet. - `backend`'s Sanctum-shaped `/auth/login`/`/auth/logout` endpoints don't exist yet — needed for this app to talk to a real backend instead of just having the client-side shape ready. -- `useEdition()` não tem fonte de dados real (ver acima). +- `GET /editions` não existe no backend: sem ele o menu mostra só a Visão geral (ver `docs/LAYOUT.md`). diff --git a/front-admin/app/application/locales/pt-br/index.ts b/front-admin/app/application/locales/pt-br/index.ts index f09b385..1c2d763 100644 --- a/front-admin/app/application/locales/pt-br/index.ts +++ b/front-admin/app/application/locales/pt-br/index.ts @@ -1,9 +1,11 @@ import login from '../../../modules/login/locales/pt-br' import ui from './ui' +import shell from '../../../modules/shell/locales/pt-br' // Every module's locale is registered here — see docs/ARCHITECTURE.md, // "Registro obrigatório". Add one import + one key per new module. export default { login, + shell, ui, } diff --git a/front-admin/app/application/shared/components/layout/AppTopbar.vue b/front-admin/app/application/shared/components/layout/AppTopbar.vue new file mode 100644 index 0000000..491c0a8 --- /dev/null +++ b/front-admin/app/application/shared/components/layout/AppTopbar.vue @@ -0,0 +1,41 @@ + + + diff --git a/front-admin/app/application/shared/components/layout/EditionSwitcher.vue b/front-admin/app/application/shared/components/layout/EditionSwitcher.vue new file mode 100644 index 0000000..7f7f81e --- /dev/null +++ b/front-admin/app/application/shared/components/layout/EditionSwitcher.vue @@ -0,0 +1,48 @@ + + + diff --git a/front-admin/app/application/shared/components/layout/MenuPopover.vue b/front-admin/app/application/shared/components/layout/MenuPopover.vue new file mode 100644 index 0000000..00353a3 --- /dev/null +++ b/front-admin/app/application/shared/components/layout/MenuPopover.vue @@ -0,0 +1,59 @@ + + + diff --git a/front-admin/app/application/shared/components/layout/MobileMenu.test.ts b/front-admin/app/application/shared/components/layout/MobileMenu.test.ts new file mode 100644 index 0000000..4823631 --- /dev/null +++ b/front-admin/app/application/shared/components/layout/MobileMenu.test.ts @@ -0,0 +1,20 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { describe, expect, it } from 'vitest' +import MobileMenu from './MobileMenu.vue' + +describe('MobileMenu', () => { + it('abre e fecha com v-model e tem botão de fechar nomeado', async () => { + const wrapper = await mountSuspended(MobileMenu, { props: { modelValue: false } }) + const dialog = wrapper.find('dialog') + expect((dialog.element as HTMLDialogElement).open).toBe(false) + await wrapper.setProps({ modelValue: true }) + expect((dialog.element as HTMLDialogElement).open).toBe(true) + await wrapper.find('button[aria-label="Fechar menu"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]) + }) + + it('só aparece em telas estreitas', async () => { + const wrapper = await mountSuspended(MobileMenu, { props: { modelValue: false } }) + expect(wrapper.find('dialog').classes()).toContain('lg:hidden') + }) +}) diff --git a/front-admin/app/application/shared/components/layout/MobileMenu.vue b/front-admin/app/application/shared/components/layout/MobileMenu.vue new file mode 100644 index 0000000..3cf13b7 --- /dev/null +++ b/front-admin/app/application/shared/components/layout/MobileMenu.vue @@ -0,0 +1,32 @@ + + + diff --git a/front-admin/app/application/shared/components/layout/SideNav.test.ts b/front-admin/app/application/shared/components/layout/SideNav.test.ts new file mode 100644 index 0000000..1a1818f --- /dev/null +++ b/front-admin/app/application/shared/components/layout/SideNav.test.ts @@ -0,0 +1,62 @@ +import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import editionService from '@/modules/shell/services/editionService' +import { useEdition } from '@/application/shared/composables/useEdition' +import SideNav from './SideNav.vue' + +const { path } = vi.hoisted(() => ({ path: { value: '/' } })) +mockNuxtImport('useRoute', () => () => ({ path: path.value })) + +async function asRole(role: string) { + vi.spyOn(editionService, 'list').mockResolvedValue({ + data: [{ id: 1, slug: 'ed-1', name: 'PHPeste 2027', city: 'João Pessoa', state: 'PB', year: 2027, site_url: null, role }], + }) + await useEdition().load() +} + +beforeEach(() => { + vi.restoreAllMocks() + path.value = '/' + useEdition().reset() +}) + +describe('SideNav', () => { + it('organizador vê as 5 seções e os 17 itens, num