From 33817d55879fc2ec6c20d4e61b4e559cf1fb87f3 Mon Sep 17 00:00:00 2001 From: Rodrigo Castro Date: Wed, 4 Nov 2026 22:54:28 -0300 Subject: [PATCH] =?UTF-8?q?Adiciona=20layout=20base=20do=20painel:=20menu?= =?UTF-8?q?=20lateral,=20barra=20superior=20e=20seletor=20de=20edi=C3=A7?= =?UTF-8?q?=C3=A3o=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu lateral de 264 px com seções e item ativo, barra superior com seletor de edição, busca, ver site, notificações e menu do usuário, gaveta em telas estreitas, guarda de rota (login com retorno e 403 por papel), página de erro global (404, 403, 500) e docs/LAYOUT.md. O painel passa a rodar como SPA. --- front-admin/CLAUDE.md | 7 +- .../app/application/locales/pt-br/index.ts | 2 + .../shared/components/layout/AppTopbar.vue | 41 ++++++ .../components/layout/EditionSwitcher.vue | 48 ++++++ .../shared/components/layout/MenuPopover.vue | 59 ++++++++ .../components/layout/MobileMenu.test.ts | 20 +++ .../shared/components/layout/MobileMenu.vue | 32 ++++ .../shared/components/layout/SideNav.test.ts | 62 ++++++++ .../shared/components/layout/SideNav.vue | 74 ++++++++++ .../shared/components/layout/Topbar.test.ts | 94 ++++++++++++ .../shared/components/layout/UserMenu.vue | 32 ++++ .../shared/composables/useEdition.test.ts | 60 +++++++- .../shared/composables/useEdition.ts | 76 +++++++++- .../shared/composables/useLogout.ts | 2 + .../shared/composables/usePermissions.test.ts | 39 +++++ .../shared/composables/usePermissions.ts | 22 +++ .../application/shared/layout/access.test.ts | 39 +++++ .../app/application/shared/layout/access.ts | 33 +++++ .../shared/layout/navigation.test.ts | 41 ++++++ .../application/shared/layout/navigation.ts | 62 ++++++++ front-admin/app/assets/css/main.css | 12 ++ front-admin/app/error.test.ts | 26 ++++ front-admin/app/error.vue | 33 +++++ front-admin/app/layouts/blank.vue | 6 + front-admin/app/layouts/default.vue | 25 +++- front-admin/app/middleware/auth.global.ts | 37 +++++ .../app/modules/shell/locales/pt-br/index.ts | 62 ++++++++ .../modules/shell/services/editionService.ts | 21 +++ front-admin/app/modules/shell/types/index.ts | 2 + front-admin/app/pages/_dev/componentes.vue | 2 + front-admin/app/pages/index.vue | 27 +--- front-admin/app/pages/login.vue | 2 + front-admin/app/stores/editionStore.ts | 29 +++- front-admin/docs/LAYOUT.md | 37 +++++ front-admin/e2e/login.spec.ts | 2 +- front-admin/e2e/shell.spec.ts | 137 ++++++++++++++++++ front-admin/nuxt.config.ts | 4 + 37 files changed, 1269 insertions(+), 40 deletions(-) create mode 100644 front-admin/app/application/shared/components/layout/AppTopbar.vue create mode 100644 front-admin/app/application/shared/components/layout/EditionSwitcher.vue create mode 100644 front-admin/app/application/shared/components/layout/MenuPopover.vue create mode 100644 front-admin/app/application/shared/components/layout/MobileMenu.test.ts create mode 100644 front-admin/app/application/shared/components/layout/MobileMenu.vue create mode 100644 front-admin/app/application/shared/components/layout/SideNav.test.ts create mode 100644 front-admin/app/application/shared/components/layout/SideNav.vue create mode 100644 front-admin/app/application/shared/components/layout/Topbar.test.ts create mode 100644 front-admin/app/application/shared/components/layout/UserMenu.vue create mode 100644 front-admin/app/application/shared/composables/usePermissions.test.ts create mode 100644 front-admin/app/application/shared/composables/usePermissions.ts create mode 100644 front-admin/app/application/shared/layout/access.test.ts create mode 100644 front-admin/app/application/shared/layout/access.ts create mode 100644 front-admin/app/application/shared/layout/navigation.test.ts create mode 100644 front-admin/app/application/shared/layout/navigation.ts create mode 100644 front-admin/app/error.test.ts create mode 100644 front-admin/app/error.vue create mode 100644 front-admin/app/layouts/blank.vue create mode 100644 front-admin/app/middleware/auth.global.ts create mode 100644 front-admin/app/modules/shell/locales/pt-br/index.ts create mode 100644 front-admin/app/modules/shell/services/editionService.ts create mode 100644 front-admin/app/modules/shell/types/index.ts create mode 100644 front-admin/docs/LAYOUT.md create mode 100644 front-admin/e2e/shell.spec.ts 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