Merge pull request #337 from fsolla/cursor/pool-i313-f30f822f-8e1d
B144 — Sidebar — entrada para Organizações
This commit is contained in:
commit
e3a54be217
4 changed files with 115 additions and 0 deletions
61
docs/plans/sidebar-organizacoes-impl.md
Normal file
61
docs/plans/sidebar-organizacoes-impl.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Impl: Sidebar — entrada para Organizações
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-02
|
||||
Issue: #313
|
||||
Intenção: docs/plans/sidebar-organizacoes.md
|
||||
Appetite restante: ~0,25 dia eng (um item de nav; sem migration)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** Staff (coordenador, assessor, candidato) descobre `/campanha/organizacoes` pela sidebar desktop e Sheet mobile; liderança não vê o item; destino ativo em lista, detalhe e `/nova`.
|
||||
- **O que NÃO negociar:** lockdown de `leader`; não redesenhar sidebar; não duplicar lista; href canônico `/campanha/organizacoes`.
|
||||
- **O que reavaliar:** hipótese de importar `ORGANIZATIONS_LIST_PATH` — `nav.ts` usa literais (precedente Conceitos) para não puxar módulos ao client bundle; manter literal.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
staffNav["staffNav + Organizações"] --> getCampaignNav
|
||||
getCampaignNav --> CampaignSidebar
|
||||
getCampaignNav --> MobileSheet["Sheet mobile"]
|
||||
isCampaignNavActive --> Active["lista / slug / nova"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) item após Lideranças | B) após Dobradinhas | C) antes de Apoiadores
|
||||
**Recomendação:** **A** — afinidade domínio orgs ↔ lideranças; alinhado à recomendação da intenção.
|
||||
**Rejeitadas:** B/C — sem ganho de descoberta; A é o modelo mental acordado.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`staffNav`** (`src/components/campaign/shell/nav.ts`): inserir `{ title: 'Organizações', href: '/campanha/organizacoes', icon: LandmarkIcon }` logo após Lideranças; import `LandmarkIcon` de lucide-react (instituições — distinto de `HandshakeIcon` e `Building2` usado em município no dashboard).
|
||||
- **Migration:** sem migration
|
||||
- **Access / Consent:** nenhum — `getCampaignNav` já exclui `leader`; staff vê por default (mesmo que Lideranças/Demandas)
|
||||
- **UI:** Impeccable B — encaixe no shell existente; `CampaignSidebar` e Sheet já consomem `getCampaignNav`; `isCampaignNavActive` já cobre paths aninhados (`/nova`, `[slug]`)
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A — só navegação.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Nav** — uma linha em `staffNav` + ícone
|
||||
2. **Testes** — unit: href presente para staff, ausente para leader; `isCampaignNavActive` em lista/detalhe/nova
|
||||
3. **Gates** — `pnpm gate:fast`; entrega `pnpm push`
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Reordenar demais itens além do encaixe após Lideranças
|
||||
- Exportar constante de href (só `MUNICIPALITY_NAV_HREF` tem consumidor especial B18)
|
||||
- `staffSecondaryNav` — destino de trabalho, não referência
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Leader vê link:** mitigado — `getCampaignNav('leader')` retorna `leaderNav` separado
|
||||
- **Active state em `/nova`:** mitigado — `isCampaignNavActive` usa `pathname.startsWith(\`${href}/\`)`
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto
|
||||
- [x] Invariantes AGENTS/engineering-standards
|
||||
- [x] Testes de domínio previstos (unit) para nav + active state
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
HandshakeIcon,
|
||||
HomeIcon,
|
||||
InboxIcon,
|
||||
LandmarkIcon,
|
||||
LayoutDashboardIcon,
|
||||
MapIcon,
|
||||
MapPinIcon,
|
||||
|
|
@ -37,6 +38,7 @@ const staffNav: CampaignNavItem[] = [
|
|||
{ title: 'Municípios', href: MUNICIPALITY_NAV_HREF, icon: MapPinIcon },
|
||||
{ title: 'Territórios', href: '/campanha/territorios', icon: MapIcon },
|
||||
{ title: 'Lideranças', href: '/campanha/liderancas', icon: HandshakeIcon },
|
||||
{ title: 'Organizações', href: '/campanha/organizacoes', icon: LandmarkIcon },
|
||||
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },
|
||||
{ title: 'Atividades', href: '/campanha/atividades', icon: CalendarDaysIcon },
|
||||
{ title: 'Demandas', href: '/campanha/demandas', icon: InboxIcon },
|
||||
|
|
|
|||
34
tests/unit/campaignNav.unit.spec.ts
Normal file
34
tests/unit/campaignNav.unit.spec.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/shell/nav'
|
||||
import { ORGANIZATIONS_LIST_PATH } from '@/lib/campaignQuickActionPaths'
|
||||
|
||||
describe('organizations sidebar entry', () => {
|
||||
it('is offered to staff after Lideranças', () => {
|
||||
const hrefs = getCampaignNav('coordinator').map((item) => item.href)
|
||||
const leadershipIndex = hrefs.indexOf('/campanha/liderancas')
|
||||
const organizationsIndex = hrefs.indexOf(ORGANIZATIONS_LIST_PATH)
|
||||
|
||||
expect(organizationsIndex).toBeGreaterThan(-1)
|
||||
expect(organizationsIndex).toBe(leadershipIndex + 1)
|
||||
})
|
||||
|
||||
it('is visible to advisor and candidate', () => {
|
||||
expect(getCampaignNav('advisor').map((item) => item.href)).toContain(ORGANIZATIONS_LIST_PATH)
|
||||
expect(getCampaignNav('candidate').map((item) => item.href)).toContain(ORGANIZATIONS_LIST_PATH)
|
||||
})
|
||||
|
||||
it('is hidden from leaders', () => {
|
||||
expect(getCampaignNav('leader').map((item) => item.href)).not.toContain(ORGANIZATIONS_LIST_PATH)
|
||||
})
|
||||
|
||||
it('marks list, detail, and nova as active', () => {
|
||||
expect(isCampaignNavActive(ORGANIZATIONS_LIST_PATH, ORGANIZATIONS_LIST_PATH)).toBe(true)
|
||||
expect(isCampaignNavActive('/campanha/organizacoes/sindmed', ORGANIZATIONS_LIST_PATH)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(isCampaignNavActive('/campanha/organizacoes/nova', ORGANIZATIONS_LIST_PATH)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,9 @@ import { describe, expect, it } from 'vitest'
|
|||
import {
|
||||
applySearchOnlyOmniboxSuggestion,
|
||||
buildSearchOnlyOmniboxChips,
|
||||
buildSearchOnlyOmniboxSuggestions,
|
||||
clearSearchOnlyOmnibox,
|
||||
removeSearchOnlyOmniboxChip,
|
||||
} from '@/lib/searchOnlyListOmnibox'
|
||||
import {
|
||||
applyActivityOmniboxSuggestion,
|
||||
|
|
@ -264,6 +267,21 @@ describe('list omnibox adapters (B128)', () => {
|
|||
withPageReset: (next) => ({ ...next, page: 1 }),
|
||||
})
|
||||
expect(applied).toEqual({ kind: 'url', state: { page: 1, q: 'ana' } })
|
||||
|
||||
expect(buildSearchOnlyOmniboxSuggestions('x')[0]?.id).toBe('q:x')
|
||||
|
||||
const removed = removeSearchOnlyOmniboxChip({
|
||||
state: { q: 'sindicato', page: 2 },
|
||||
chipId: 'q',
|
||||
withPageReset: (next) => ({ ...next, page: 1 }),
|
||||
})
|
||||
expect(removed).toEqual({ kind: 'url', state: { page: 1, q: undefined } })
|
||||
|
||||
const cleared = clearSearchOnlyOmnibox({
|
||||
state: { q: 'sindicato' },
|
||||
cleared: {},
|
||||
})
|
||||
expect(cleared).toEqual({ kind: 'clear', state: {} })
|
||||
})
|
||||
|
||||
it('advisor toggles municipality portfolio filter and suggests by name', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue