Merge branch 'cursor/11d518a6'
This commit is contained in:
commit
88376ef9a8
34 changed files with 12318 additions and 439 deletions
|
|
@ -10,6 +10,7 @@ alwaysApply: false
|
|||
- **B71 entregue em código (2026-07-30):** polimento visual dos resultados de busca no Início (UX-1) — `src/lib/homeSearchUi.ts` (`HOME_SEARCH_HIT_ROW_WRAPPER_CLASS` = bleed + hover + `focus-within`; heading/list); `HomeSearchHitRow` faixa full-bleed `-mx-4 px-4 md:-mx-6 md:px-6` (mantém `onMouseDown` preventDefault de B66); todos os `HomeSearch*Group` com `ul list-none` e título `text-xs` sentence-case (sem `uppercase`/`tracking-wide`). Unit `homeSearchUi.unit.spec.tsx`. Sem migration/action/JSON. Plano: [`polimento-resultados-busca-inicio.md`](../../../docs/plans/polimento-resultados-busca-inicio.md).
|
||||
- **B66 entregue em código (2026-07-30):** modo focado da busca no **focus** (emenda B47) — `homeSearchUiFocused` (`inputFocused || query.isActive`) já no contrato/hook; overlap B68 (sugestões no focus vazio). **Nesta entrega:** `CampaignHomeLayout` retrai summary+spacer (`data-slot="home-chrome"`) e actions com grid `0fr/1fr` + opacity 220ms (`motion-reduce:transition-none`) em vez de `hidden`; `md:flex-none` no chrome; `HomeSearchHitRow` `onMouseDown` preventDefault (blur→click); e2e focus sem digitar + regressão com digitação. Sem migration. Plano: [`modo-focado-busca-no-focus.md`](../../../docs/plans/modo-focado-busca-no-focus.md).
|
||||
- **B61 entregue em código (2026-07-29):** etapa de ajuste de votos no wizard A1 — `WizardExpectedVotesStep` substitui o stub B60 em `atualizar-votos` com `?municipio=`; três passos explícitos média→pessimista→otimista via `?cenario=`; atalhos `2×`/±50/±100; validação client com jump+warning (`getWizardVoteViolation`); **um POST batch** ao concluir otimista (`POST /campanha/municipios/expected-votes`). Helpers em `wizardVoteEstimate.ts` + `wizardActionHref` estendido. Sem migration/Consent. Unit `wizardVoteEstimate`; e2e avanço de cenário + violação. Plano: [`ajuste-votos-wizard.md`](../../../docs/plans/ajuste-votos-wizard.md). Desbloqueia wiring A1 / **B64** (prefill votos).
|
||||
- **B69 entregue em código (2026-07-30):** modelo de liderança simplificado — `leadership.exclusive` boolean NOT NULL DEFAULT true ("Apoio exclusivo"; migration `20260730_043306_simplify_leadership_fields`, gerada em duas passadas não-interativas e fundida à mão porque o prompt de rename do gerador trava sem TTY; `NOT NULL` emendado) + DROP `sector`/`sectorNotes`/`consentNote` (collection, zod, forms, lista com filtros/sort B29, invite autofill/redemption, dossiê). **Decisão de produto 2026-07-30 que reverteu o rascunho: `organization` FICA** — mapeia ramificações políticas (várias lideranças na mesma org, ex. SindMed); vertical `/campanha/organizacoes`, join, badges, contadores e `activity.organizations` intactos (a remoção da vertical inteira foi avaliada e descartada na conversa). Lista ganha coluna "Apoio exclusivo" com badge outline "Não exclusivo" só quando false; forms com checkbox default-on + input hidden "false" na criação (último valor vence; ausência total da chave cai no default do zod — pin de int cobre os três caminhos). Coluna Organizações continua texto simples (melhoria = fill-in futuro). `?sector=` legado canonicaliza fora (pin unit). LGPD do convite (`consent`/`consentContentHash`/`consentedAt`) intacto. Gate: tsc/lint/format/cycles verde; knip idêntico ao clean tree (P3 + drift B66/B71 pré-existentes); unit 890+16 falhas pré-existentes idênticas ao clean tree; int 518/518; build; Aikido 0 findings. Plano: [`simplificar-modelo-lideranca.md`](../../../docs/plans/simplificar-modelo-lideranca.md). Desbloqueia **B70** (wizard atualizar liderança — grid + form curto com exclusivo).
|
||||
- **B62 entregue em código (2026-07-29):** modelo de sinal simplificado — `municipalityUpdate` kind `sinal` agora é só `signalType` + `body`; migration `20260730_010601_simplify_municipality_signal_fields` dropa `signal_source`/`triangulated` (o gerador reemitiu `ADD VALUE 'adiada'` por drift de snapshot — removido à mão, e o snapshot novo sara a cadeia); labels/descriptions dos 5 tipos reescritas para a mesa (`Rede esfriou`, `Adversário apareceu`, `Alguém pediu algo` — sem "broker" no modelo de sinal; `activity.origin` `pedido_broker` e a copy do suggestionCatalog ficaram fora por decisão da sessão). Forms: `MunicipalitySignalFields` (lista B26) e o bloco inline de `MunicipalityUpdateForm` perderam Fonte/Triangulado; feed/dossiê (`MunicipalityUpdateFeed` + `municipalityUpdatePageData`) só exibem o badge do tipo. **E11: presença de sinal de adversário lê como confirmada — triage 1** (decisão do usuário: "eles não vão adicionar sinais improváveis"), `adversarySignal: { present: true } | null`, fator único "Sinal de adversário registrado", glossário E18 alinhado. Fixa B63. Bônus: teste e2e `campaignMunicipalities` "advisor scopes…" estava obsoleto desde B43 (home da liderança) — atualizado para `/campanha/contatos` + redirect pós-B43, e o pin de bypass do `municipalityElectoralBaseline` baixado 9→8 (drift pré-existente). Gate: tsc/lint/format/cycles/knip(P3 pré-existente)/876 unit/489 int/e2e alvo verde/build (`/campanha` 233 kB); Aikido 0 findings. Plano: [`simplificar-modelo-sinal.md`](../../../docs/plans/simplificar-modelo-sinal.md). Desbloqueia **B63** (wizard registrar sinal).
|
||||
- **E10+ entregue em código (2026-07-29):** Escala e DRY pós-E10 — F1: `loadMunicipalityScopeFromDocs` no `municipalityPageData.ts` evita a segunda query de `municipality` quando `isPagedByPayload === false`; F2: `computeAllMunicipalityPotentials` memoizado por processo (retorna cache `Map<string, MunicipalityPotential>`); F3: `TooltipProvider` movido para o layout de `(app)`, restaurando o agrupamento de delay do Radix para os tooltips das listas; F4: DRY nos filtros multi do mobile em `MunicipalityFilters` via `.map` sobre `municipalityFilterDefinitions` (que ganhou `emptyLabel`). Sem migration/UI nova. Plano: [`escala-dry-pos-e10.md`](../../../docs/plans/escala-dry-pos-e10.md).
|
||||
- **B60 entregue em código (2026-07-29):** etapa de busca de município nos wizards UX-1 — `WizardMunicipalitySearchStep` (debounce B47, `POST /campanha/home-search` modo `wizard-municipality`, só municípios scoped); select = `wizardActionHref` com `?municipio=<slug>`; loader compartilhado `searchStaffMunicipalityHits` (extraído de B48); `HomeSearchMunicipalityVoteTrailing` + `HomeSearchHitRow` link; stub B61 `WizardMunicipalitySelectedStub` com caption sticky; `WizardMunicipalityPlaceholderStep` removido. Sem migration/Consent. Unit `campaignActionRoutes`; int `searchStaffMunicipalityHits`; e2e wizard → `?municipio=cairu`. Plano: [`busca-municipio-wizard.md`](../../../docs/plans/busca-municipio-wizard.md). Desbloqueia **B61** / **B63** / **B64** / **B70**.
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
|
||||
|
||||
**Recently resolved (2026-07-30):** **B71** Polimento visual dos resultados de busca no Início — `src/lib/homeSearchUi.ts` centraliza bleed (`-mx-4 px-4 md:-mx-6 md:px-6`), heading discreto (`text-xs` sentence-case) e `ul list-none`; `HomeSearchHitRow` pinta hover/focus na faixa full-bleed; todos os `HomeSearch*Group` alinhados. Sem migration/action/contrato JSON. Plano: [`docs/plans/polimento-resultados-busca-inicio.md`](docs/plans/polimento-resultados-busca-inicio.md).
|
||||
|
||||
**Recently resolved (2026-07-30):** **B69** Simplificar modelo de liderança — `leadership.exclusive` boolean NOT NULL DEFAULT true ("Apoio exclusivo") via migration `20260730_043306_simplify_leadership_fields` + DROP `sector`/`sectorNotes`/`consentNote` (collection, zod, forms, filtros/sort B29 da lista, invite autofill/redemption, dossiê; `?sector=` legado canonicaliza fora). **Decisão de produto 2026-07-30 reverteu o rascunho: `organization` FICA** — mapeia ramificações políticas (SindMed com várias lideranças); vertical `/campanha/organizacoes` e todos os vínculos intactos. Lista ganha coluna "Apoio exclusivo" (badge outline "Não exclusivo" só quando false); forms com checkbox default-on + hidden "false" na criação (último valor vence; ausência cai no default do zod). Nota operacional: o prompt interativo de rename do `migrate:create` trava sem TTY — gerar drops e adds em passadas separadas (`CI=1`) e fundir à mão foi o caminho; o `NOT NULL` foi emendado sobre o SQL gerado. Gate: tsc/lint/format/cycles/build; int 518/518; unit com as mesmas 16 falhas pré-existentes do clean tree (drift B66/B71 na base); knip idêntico à base (P3 + drift); Aikido 0 findings. Plano: [`docs/plans/simplificar-modelo-lideranca.md`](docs/plans/simplificar-modelo-lideranca.md). Desbloqueia **B70**.
|
||||
**Recently resolved (2026-07-29):** **B62** Simplificar modelo de sinal — `municipalityUpdate` kind `sinal` is now `signalType` + `body` only: migration `20260730_010601_simplify_municipality_signal_fields` drops `signal_source` and `triangulated` (the generator re-emitted `ADD VALUE 'adiada'` from snapshot drift — removed by hand; this migration's committed `.json` heals the chain), and the five signal-type labels/descriptions were rewritten for the field team (`Rede esfriou`, `Adversário apareceu`, `Alguém pediu algo` — no "broker" in the signal model; `activity.origin`'s `Pedido de broker` and suggestionCatalog copy were explicitly left out by session decision). `MunicipalitySignalFields` (B26 list control) and the inline block in `MunicipalityUpdateForm` lost Fonte/Triangulado; `municipalityUpdatePageData` + `MunicipalityUpdateFeed` (detail + dossier) render only the type badge. **E11 changed semantics: any adversary signal in the window reads as confirmed — P1 triage 1** (session decision, replacing the triangulated/single-source split), `MunicipalityTriggerInput.adversarySignal: { present: true } | null`, single factor copy, E18 glossary aligned. Bonus gate fixes: the `campaignMunicipalities` "advisor scopes…" e2e was stale since B43 (leader home → `/campanha/contatos`) and was updated, and the `municipalityElectoralBaseline` bypass pin dropped 9→8 (pre-existing main drift). Gate: 876 unit / 489 int / targeted e2e green / build; Aikido 0 findings. Plano: [`docs/plans/simplificar-modelo-sinal.md`](docs/plans/simplificar-modelo-sinal.md).
|
||||
**Recently resolved (2026-07-29):** **B59** Chassis do wizard de campanha — rotas staff em `/campanha/acoes/<slug>` (`campaignActionRoutes.ts`, `campaignActionEntryHref`), layout `acoes/layout.tsx` (gate staff, coluna `max-w-lg`), `CampaignWizardShell` (voltar mobile, caption de município sticky, `trailingAction`, foco no `h1` ao trocar passo), cinco páginas-ponte até B60 e strip do Início com `href` nos fluxos wizard. Sem migration. Plano: [`docs/plans/chassis-wizard-campanha.md`](docs/plans/chassis-wizard-campanha.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
# Simplificar modelo de liderança (exclusivo + limpeza)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-29
|
||||
Status: entregue 2026-07-30 (org permanece — ver revisão)
|
||||
Atualizado em: 2026-07-30
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B69 — UX-1 / A4)
|
||||
Impeccable: B — schema + forms/lista/convite existentes; UI de grid do wizard = **B70**
|
||||
Appetite: ~1–1,25 dia eng; uma migration (drop + `exclusive`); zod/collection/UI lista/forms/invite; access `OwnLeadership`
|
||||
Appetite: ~1–1,25 dia eng; uma migration (drop + `exclusive`); zod/collection/UI lista/forms/invite
|
||||
Responsável: —
|
||||
|
||||
**Revisão 2026-07-30 (decisão de produto, pré-implementação):** `organization` **permanece** — o conceito mapeia como as relações políticas se ramificam na região (várias lideranças na mesma org, ex. SindMed). O escopo final dropou apenas `sector`/`sectorNotes`/`consentNote`; a vertical `/campanha/organizacoes`, o join `leadership.organizations`, badges/colunas/contadores e `activity.organizations` ficam intactos. Confirmado na mesma sessão: `exclusive` aparece na lista (badge "Não exclusivo") + toggle nos forms; coluna Organizações continua texto simples (melhoria vira fill-in).
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` / `DESIGN.md` · tema `campaign`. Superfícies tocadas = ficha/lista/convite já no Field Desk — craft compacto, não shape de rota nova.
|
||||
|
|
@ -29,38 +31,38 @@ Dados: N/A como métrica nova — boolean categórico + remoção de campos. Dec
|
|||
|
||||
`leadership` (`src/collections/Leadership.ts`) carrega hoje: `organizations`, `sector`/`sectorNotes`, `consentNote` (“Registro de consentimento **externo**”), além de `supportStatus`, `notes`, `stateDeputies`, municípios e o trio de Consent do **convite** (`consent` / `consentContentHash` / `consentedAt`).
|
||||
|
||||
Pedido de produto (2026-07-29), no pacote A4 / Cairu ([fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md)):
|
||||
Pedido de produto (2026-07-29), no pacote A4 / Cairu ([fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md)), **ajustado em 2026-07-30**:
|
||||
|
||||
1. Conceito de parceria **exclusiva** (default = exclusivo): liderança que era a articuladora principal e passa a dividir votos com outro candidato deixa de ser exclusiva — e pode voltar. Não é “principal vs secundária” na v1 (ranking implícito); é **exclusividade do apoio**.
|
||||
2. Remover da mesa os conceitos pouco usados: **organização**, **setor** (e notas de setor), **registro de consentimento externo** (`consentNote`).
|
||||
2. Remover da mesa os conceitos pouco usados: **setor** (e notas de setor), **registro de consentimento externo** (`consentNote`). **Organização fica** (revisão 2026-07-30): ramificação das relações políticas — várias lideranças na mesma org (SindMed).
|
||||
3. “A própria liderança é a dobradinha” — `stateDeputy` hoje é entidade por **nome** (sem `Contact`); `leadership` tem `Contact` único. Não unificar collections neste item.
|
||||
|
||||
Lista `/campanha/liderancas` já filtra/ordena por `sector` (B29 ✓); coluna Organizações é texto morto. Invite autofill ainda pede `sector`/`sectorNotes`.
|
||||
Lista `/campanha/liderancas` já filtra/ordena por `sector` (B29 ✓). Invite autofill ainda pede `sector`/`sectorNotes`.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Migration `pnpm migrate:create simplify_leadership_fields` (nome sugerido):
|
||||
- **DROP** `organizations` (tabela de join), `sector`, `sector_notes`, `consent_note`.
|
||||
- **ADD** `exclusive` boolean **NOT NULL DEFAULT true** (backfill implícito = todos nascem exclusivos — honesto com o default de produto).
|
||||
- Collection + zod (`src/lib/schemas/leadership.ts`): espelhar; `exclusive` staff-managed como `supportStatus`/`notes`.
|
||||
- Remover de create/internal forms, list columns/filters/sort de setor, badges de org no `MunicipalityLeadershipsPanel`, `organizationNames` / `organizationIDs` do view model.
|
||||
- Invite: dropar `sector`/`sectorNotes` do schema + `CampaignInviteForm` + redemption profile write (não tocar `consent*` do LGPD do convite).
|
||||
- Access: `getOwnEngagedLeadership` deixa de selecionar/expor `organizationIDs` (`municipalities.ts`); qualquer leitor de org via liderança engajada ajusta ou morre.
|
||||
- `organizationData` counts “lideranças da org” → **zero path** ou remover a métrica se só existia via esse join (verificar call sites; não inventar proxy).
|
||||
- Labels: `exclusive` → “Apoio exclusivo”; false → leitura “Não exclusivo” / badge discreto na lista (opcional neste item; B70 mostra no tile/form).
|
||||
- Migration `20260730_043306_simplify_leadership_fields`:
|
||||
- **DROP** `sector`, `sector_notes`, `consent_note` (+ `enum_leadership_sector`).
|
||||
- **ADD** `exclusive` boolean **NOT NULL DEFAULT true** (backfill implícito = todos nascem exclusivos — honesto com o default de produto). Gerada em duas passadas não-interativas (drop, depois add) e fundida à mão — o prompt interativo de rename trava sem TTY; o `NOT NULL` foi emendado sobre o SQL gerado.
|
||||
- Collection + zod (`src/lib/schemas/leadership.ts`): espelhado; `exclusive` staff-managed como `supportStatus`/`notes`.
|
||||
- Removido de create/internal forms, list columns/filters/sort de setor, badges de setor no dossiê; `sector`/`sectorNotes` fora dos view models.
|
||||
- Invite: `sector`/`sectorNotes` fora do schema + `CampaignInviteForm` + redemption profile write (o trio `consent*` do LGPD do convite intacto).
|
||||
- Lista: coluna setor → coluna **"Apoio exclusivo"** com badge outline "Não exclusivo" só quando `false` (Restrained; badge não compete com os vermelhos da linha).
|
||||
- **Checkbox padrão-marcado + input hidden "false"** no form de criação (último valor vence), para que desmarcar grave `false` e ausência total da chave caia no default do zod — pin de int cobre os três caminhos.
|
||||
- Sem Consent novo. Sem UI de grid do wizard (**B70**).
|
||||
- Unit/int: create default `exclusive: true`; update para `false`; filtros de setor removidos; invite sem setor.
|
||||
- Unit/int: create default `exclusive: true`; update para `false`; `?sector=` legado canonicaliza fora; invite sem setor.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Campo `exclusive: boolean`, default `true`, no documento `leadership`.** **Rejeitado:** `principal`/`prioritaria` (sugerem ranking 1º/2º, não o relato “dividiu apoio”); exclusividade por par liderança×município nesta fatia (caro — join/metadata; o relato do CG é sobre a parceria com a campanha, não nuance A vs B). Gatilho abaixo se a mesma pessoa precisar ser exclusiva em um município e não em outro.
|
||||
- **Dropar `organizations`, `sector`, `sectorNotes`, `consentNote`.** **Rejeitado:** deixar opcionais “por se acaso” (pedido: simplificar; campos mortos travam atualização); dropar o trio `consent*` do **convite** (é LGPD `lideranca-autopreenchimento`, Onda 0 — distinto do `consentNote` externo).
|
||||
- **Dropar `sector`, `sectorNotes`, `consentNote`.** **Rejeitado:** deixar opcionais “por se acaso” (campos mortos travam atualização); dropar o trio `consent*` do **convite** (é LGPD `lideranca-autopreenchimento`, Onda 0 — distinto do `consentNote` externo).
|
||||
- **`organization` PERMANECE (revisão 2026-07-30, produto).** O rascunho dropava o join; revertido — a org mapeia ramificações políticas (SindMed com várias lideranças). **Rejeitado na revisão:** remover a vertical `/campanha/organizacoes` inteira (avaliada e descartada pelo produto na conversa); dropar `activity.organizations` junto.
|
||||
- **“Liderança é a dobradinha” = reusar `stateDeputy` + `leadership.stateDeputies`.** Criar/vincular a ficha de dobradinha com o nome da pessoa; chips B31 ✓ já expressam a rede. **Rejeitado:** collection unificada; `Contact` obrigatório em `stateDeputy` agora; flag `actsAsStateDeputy` sem entity (segunda verdade). Atalho “criar dobradinha a partir desta liderança” → Adiado.
|
||||
- **i18n:** identifier `exclusive`; copy “Apoio exclusivo” / “Não exclusivo”.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Badge “Não exclusivo” na lista `/liderancas` neste item ou só no B70?** **Opções:** A lista + forms | B só forms/wizard. **Recomendação:** A — coluna ou chip discreto na lista (1 célula), senão o campo some da varredura. _(assumido)_
|
||||
- ~~**Badge “Não exclusivo” na lista `/liderancas` neste item ou só no B70?**~~ **Fechado 2026-07-30 (produto):** na lista — coluna própria com badge outline só quando `false`.
|
||||
- **Exclusividade por município?** **Opções:** A documento (v1) | B join. **Recomendação:** A; revisitação no Adiado. _(assumido — validar com produto se Cairu tiver o mesmo nome em dois municípios com papéis diferentes)_
|
||||
|
||||
## Abordagem proposta
|
||||
|
|
@ -69,19 +71,16 @@ Lista `/campanha/liderancas` já filtra/ordena por `sector` (B29 ✓); coluna Or
|
|||
flowchart LR
|
||||
Mig["migration DROP + exclusive"] --> Zod["zod + Leadership.ts"]
|
||||
Zod --> UI["forms + lista + invite"]
|
||||
Zod --> Access["OwnLeadership sem orgs"]
|
||||
Zod --> Orgs["organizationData counts"]
|
||||
```
|
||||
|
||||
Componentes:
|
||||
Componentes (como entregue):
|
||||
|
||||
- Migration + `Leadership.ts` + `src/lib/schemas/leadership.ts` (+ invite schema se setor sair).
|
||||
- `LeadershipForm` / `LeadershipInternalForm` — remover org/setor/consentNote; toggle `exclusive` (default on).
|
||||
- `liderancas/page.tsx` + `leadershipListUrl` / filters — dropar `sector`; opcional coluna/filtro `exclusive`.
|
||||
- `leadershipData.ts` — view models sem org/setor; incluir `exclusive`.
|
||||
- `CampaignInviteForm` + redemption — sem setor.
|
||||
- `municipalities.ts` `getOwnEngagedLeadership` — só municípios.
|
||||
- **Migration:** sim (drop + add boolean default true).
|
||||
- Migration + `Leadership.ts` + `src/lib/schemas/leadership.ts` + invite schema.
|
||||
- `LeadershipForm` / `LeadershipInternalForm` — remover setor/consentNote; checkbox `exclusive` (default on; hidden "false" na criação).
|
||||
- `liderancas/page.tsx` + `leadershipListUrl` / filters — dropar `sector`; coluna "Apoio exclusivo".
|
||||
- `leadershipData.ts` — view models sem setor/`sectorNotes`/`consentNote`; incluir `exclusive` (orgs mantidas).
|
||||
- `CampaignInviteForm` + redemption + page data — sem setor.
|
||||
- **Migration:** sim (`20260730_043306_simplify_leadership_fields`, drop + add boolean default true NOT NULL).
|
||||
|
||||
## Dependências
|
||||
|
||||
|
|
@ -92,12 +91,11 @@ Componentes:
|
|||
- Grid de tiles + form do wizard A4 → **B70**.
|
||||
- Wiring “quer também liderança?” no A1 → fatia A1.
|
||||
- Unificar `stateDeputy` com `Contact` / atalho criar-dobradinha → Adiado.
|
||||
- Collection `organization` em si (vertical `/campanha/organizacoes` permanece; só some o vínculo na liderança).
|
||||
- Collection `organization` e a vertical `/campanha/organizacoes` — **intactas por decisão 2026-07-30** (inclui `leadership.organizations`, badges no painel/dossiê e contadores do `organizationData`).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- **Exclusividade por município + histórico de mudanças.** **Mitigação:** boolean no doc; nota em `notes` se precisar matizar.
|
||||
- **Reescrever access de leader via organizações.** **Mitigação:** remover o ramo; leader continua lockdown em contatos — confirmar que `organizationIDs` não alimenta rota staff crítica antes de dropar.
|
||||
- **Backfill `exclusive: false` a partir de texto em `notes`.** **Mitigação:** não — default true; mesa corrige no wizard.
|
||||
|
||||
## Adiado com gatilho
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -12,7 +12,6 @@ import {
|
|||
} from '@/lib/formData'
|
||||
import {
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
leadershipSectors,
|
||||
leadershipSupportStatuses,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import {
|
||||
|
|
@ -31,7 +30,6 @@ export const updateLeadershipInternalFormAction = async (
|
|||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const sector = optionalFormText(formData, 'sector')
|
||||
const supportStatus = optionalFormText(formData, 'supportStatus')
|
||||
|
||||
await updateLeadershipInternal({
|
||||
|
|
@ -39,16 +37,13 @@ export const updateLeadershipInternalFormAction = async (
|
|||
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
|
||||
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
|
||||
stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'),
|
||||
sector: leadershipSectors.includes(sector as (typeof leadershipSectors)[number])
|
||||
? (sector as (typeof leadershipSectors)[number])
|
||||
: null,
|
||||
exclusive: formData.get('exclusive') === 'true',
|
||||
supportStatus: leadershipSupportStatuses.includes(
|
||||
supportStatus as (typeof leadershipSupportStatuses)[number],
|
||||
)
|
||||
? (supportStatus as (typeof leadershipSupportStatuses)[number])
|
||||
: undefined,
|
||||
notes: nullableFormText(formData, 'notes'),
|
||||
consentNote: nullableFormText(formData, 'consentNote'),
|
||||
})
|
||||
revalidatePath('/campanha/liderancas/[id]', 'page')
|
||||
return { message: 'Ficha da liderança atualizada.' }
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } fr
|
|||
import {
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
leadershipSectors,
|
||||
leadershipSupportStatuses,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import {
|
||||
|
|
@ -28,8 +27,11 @@ export const createLeadershipFormAction = async (
|
|||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignRedirectFormAction({
|
||||
execute: () => {
|
||||
const sector = optionalFormText(formData, 'sector')
|
||||
const supportStatus = optionalFormText(formData, 'supportStatus')
|
||||
// Checkbox ambiguity: unchecked submits nothing, so the form pairs the
|
||||
// checkbox with a hidden "false"; last value wins. A genuinely absent
|
||||
// key (tests, non-form callers) falls back to the schema default.
|
||||
const exclusiveValues = formData.getAll('exclusive')
|
||||
|
||||
return createLeadership({
|
||||
name: requiredFormText(formData, 'name'),
|
||||
|
|
@ -38,16 +40,13 @@ export const createLeadershipFormAction = async (
|
|||
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
|
||||
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
|
||||
stateDeputies: repeatedRelationshipFormValues(formData, 'stateDeputies'),
|
||||
sector: leadershipSectors.includes(sector as (typeof leadershipSectors)[number])
|
||||
? (sector as (typeof leadershipSectors)[number])
|
||||
: undefined,
|
||||
exclusive: exclusiveValues.length === 0 ? undefined : exclusiveValues.at(-1) === 'true',
|
||||
supportStatus: leadershipSupportStatuses.includes(
|
||||
supportStatus as (typeof leadershipSupportStatuses)[number],
|
||||
)
|
||||
? (supportStatus as (typeof leadershipSupportStatuses)[number])
|
||||
: 'a_abordar',
|
||||
notes: optionalFormText(formData, 'notes'),
|
||||
consentNote: optionalFormText(formData, 'consentNote'),
|
||||
})
|
||||
},
|
||||
redirectTo: (leadership) => `/campanha/liderancas/${leadership.id}`,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import {
|
|||
type MunicipalityPortfolioIndexEntry,
|
||||
} from '@/lib/municipalityPortfolio'
|
||||
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
|
||||
import { MAX_LEADERSHIP_MUNICIPALITIES, isLeadershipSector } from '@/lib/schemas/leadership'
|
||||
import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
|
|
@ -71,7 +71,6 @@ import {
|
|||
resolveLeadershipListUrl,
|
||||
type LeadershipListState,
|
||||
} from '@/utilities/leadership/leadershipListUrl'
|
||||
import { leadershipSectorLabels } from '@/utilities/leadership/leadershipUi'
|
||||
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
|
||||
|
||||
import {
|
||||
|
|
@ -175,13 +174,9 @@ const leadershipColumns = ({
|
|||
),
|
||||
},
|
||||
{
|
||||
id: 'sector',
|
||||
label: leadershipListSortLabels.sector,
|
||||
head: <LeadershipSortableHead state={state} sortKey="sector" filterParam="sector" />,
|
||||
cell: (row) => {
|
||||
if (!row.sector || !isLeadershipSector(row.sector)) return row.sector || '—'
|
||||
return leadershipSectorLabels[row.sector]
|
||||
},
|
||||
id: 'exclusive',
|
||||
label: 'Apoio exclusivo',
|
||||
cell: (row) => (row.exclusive ? null : <Badge variant="outline">Não exclusivo</Badge>),
|
||||
},
|
||||
{
|
||||
id: 'municipalities',
|
||||
|
|
|
|||
|
|
@ -138,11 +138,9 @@ const createValidatedLeadershipRecord = async (
|
|||
municipalities: data.municipalities,
|
||||
organizations: data.organizations ?? [],
|
||||
stateDeputies: data.stateDeputies ?? [],
|
||||
sector: data.sector,
|
||||
sectorNotes: data.sectorNotes,
|
||||
exclusive: data.exclusive,
|
||||
supportStatus: data.supportStatus,
|
||||
notes: data.notes,
|
||||
consentNote: data.consentNote,
|
||||
createdBy: currentActor.id,
|
||||
},
|
||||
depth: 0,
|
||||
|
|
|
|||
|
|
@ -34,8 +34,6 @@ const profileFromForm = (formData: FormData) => ({
|
|||
phone: requiredFormText(formData, 'phone'),
|
||||
email: nullableFormText(formData, 'email'),
|
||||
gender: nullableFormText(formData, 'gender'),
|
||||
sector: nullableFormText(formData, 'sector'),
|
||||
sectorNotes: nullableFormText(formData, 'sectorNotes'),
|
||||
})
|
||||
|
||||
const inviteFormError = (error: unknown): CampaignInviteFormState =>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export const Leadership: CollectionConfig = {
|
|||
admin: {
|
||||
group: 'Campanha',
|
||||
useAsTitle: 'contact',
|
||||
defaultColumns: ['contact', 'municipalities', 'sector', 'supportStatus', 'updatedAt'],
|
||||
defaultColumns: ['contact', 'municipalities', 'exclusive', 'supportStatus', 'updatedAt'],
|
||||
},
|
||||
access: {
|
||||
create: canCreateLeadership,
|
||||
|
|
@ -102,28 +102,16 @@ export const Leadership: CollectionConfig = {
|
|||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'sector',
|
||||
type: 'select',
|
||||
label: 'Setor',
|
||||
name: 'exclusive',
|
||||
type: 'checkbox',
|
||||
label: 'Apoio exclusivo',
|
||||
defaultValue: true,
|
||||
index: true,
|
||||
options: [
|
||||
{ label: 'Religioso', value: 'religioso' },
|
||||
{ label: 'Sindical', value: 'sindical' },
|
||||
{ label: 'Comunitário', value: 'comunitario' },
|
||||
{ label: 'Rural', value: 'rural' },
|
||||
{ label: 'Empresarial', value: 'empresarial' },
|
||||
{ label: 'Juventude', value: 'juventude' },
|
||||
{ label: 'Saúde', value: 'saude' },
|
||||
{ label: 'Educação', value: 'educacao' },
|
||||
{ label: 'Cultura', value: 'cultura' },
|
||||
{ label: 'Outro', value: 'outro' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sectorNotes',
|
||||
type: 'textarea',
|
||||
label: 'Observações do setor',
|
||||
maxLength: 1000,
|
||||
access: {
|
||||
create: canManageCampaignStaffField,
|
||||
read: canReadCampaignStaffField,
|
||||
update: canManageCampaignStaffField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'supportStatus',
|
||||
|
|
@ -200,17 +188,6 @@ export const Leadership: CollectionConfig = {
|
|||
update: canManageCampaignStaffField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'consentNote',
|
||||
type: 'textarea',
|
||||
label: 'Registro de consentimento externo',
|
||||
maxLength: 2000,
|
||||
access: {
|
||||
create: canManageCampaignStaffField,
|
||||
read: canReadCampaignStaffField,
|
||||
update: canManageCampaignStaffField,
|
||||
},
|
||||
},
|
||||
systemStampedActorField({
|
||||
readAccess: canReadCampaignStaffField,
|
||||
setAccess: canSetAdministrativeLeadershipField,
|
||||
|
|
|
|||
|
|
@ -23,25 +23,19 @@ import {
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { errorProps, fieldError } from '@/utilities/campaignFormFields'
|
||||
import { leadershipGenderLabels, leadershipSectorLabels } from '@/utilities/leadership/leadershipUi'
|
||||
import { leadershipGenderLabels } from '@/utilities/leadership/leadershipUi'
|
||||
|
||||
type CampaignInviteProfile = {
|
||||
name: string
|
||||
phone: string
|
||||
email: string | null
|
||||
gender: keyof typeof leadershipGenderLabels | null
|
||||
sector: keyof typeof leadershipSectorLabels | null | undefined
|
||||
sectorNotes: string | null
|
||||
}
|
||||
|
||||
const genderOptions = Object.entries(leadershipGenderLabels) as Array<
|
||||
[NonNullable<CampaignInviteProfile['gender']>, string]
|
||||
>
|
||||
const sectorOptions = Object.entries(leadershipSectorLabels) as Array<
|
||||
[NonNullable<CampaignInviteProfile['sector']>, string]
|
||||
>
|
||||
|
||||
const CampaignInviteFeedback = ({ state }: { state: CampaignInviteFormState }) => {
|
||||
if (state.status === 'success') return null
|
||||
|
|
@ -102,8 +96,6 @@ const ProfileFields = ({
|
|||
const phone = inviteField('phone')
|
||||
const email = inviteField('email')
|
||||
const gender = inviteField('gender')
|
||||
const sector = inviteField('sector')
|
||||
const sectorNotes = inviteField('sectorNotes')
|
||||
|
||||
return (
|
||||
<FieldGroup>
|
||||
|
|
@ -172,44 +164,7 @@ const ProfileFields = ({
|
|||
</NativeSelect>
|
||||
{gender.error ? <FieldError id={gender.describedBy}>{gender.error}</FieldError> : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={sector.invalid}>
|
||||
<FieldLabel htmlFor="campaign-invite-sector">Setor</FieldLabel>
|
||||
<NativeSelect
|
||||
id="campaign-invite-sector"
|
||||
name="sector"
|
||||
className="w-full"
|
||||
defaultValue={profile.sector ?? ''}
|
||||
aria-invalid={sector.invalid}
|
||||
aria-describedby={sector.describedBy}
|
||||
>
|
||||
<NativeSelectOption value="">Não informar</NativeSelectOption>
|
||||
{sectorOptions.map(([value, label]) => (
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{sector.error ? <FieldError id={sector.describedBy}>{sector.error}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={sectorNotes.invalid}>
|
||||
<FieldLabel htmlFor="campaign-invite-sector-notes">
|
||||
Como você atua nesse setor? (opcional)
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id="campaign-invite-sector-notes"
|
||||
name="sectorNotes"
|
||||
defaultValue={profile.sectorNotes ?? ''}
|
||||
maxLength={1000}
|
||||
aria-invalid={sectorNotes.invalid}
|
||||
aria-describedby={sectorNotes.describedBy}
|
||||
/>
|
||||
{sectorNotes.error ? (
|
||||
<FieldError id={sectorNotes.describedBy}>{sectorNotes.error}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,29 +8,17 @@ import {
|
|||
type RelationOption,
|
||||
} from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership'
|
||||
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
|
||||
|
||||
const sectorLabels: Record<(typeof leadershipSectors)[number], string> = {
|
||||
religioso: 'Religioso',
|
||||
sindical: 'Sindical',
|
||||
comunitario: 'Comunitário',
|
||||
rural: 'Rural',
|
||||
empresarial: 'Empresarial',
|
||||
juventude: 'Juventude',
|
||||
saude: 'Saúde',
|
||||
educacao: 'Educação',
|
||||
cultura: 'Cultura',
|
||||
outro: 'Outro',
|
||||
}
|
||||
|
||||
type LeadershipFormProps = {
|
||||
municipalityOptions: RelationOption[]
|
||||
organizationOptions: RelationOption[]
|
||||
|
|
@ -117,22 +105,6 @@ export const LeadershipForm = ({
|
|||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leadership-sector">Setor</FieldLabel>
|
||||
<NativeSelect
|
||||
id="leadership-sector"
|
||||
name="sector"
|
||||
defaultValue=""
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">Não informado</NativeSelectOption>
|
||||
{leadershipSectors.map((sector) => (
|
||||
<NativeSelectOption key={sector} value={sector}>
|
||||
{sectorLabels[sector]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leadership-status">Status de apoio</FieldLabel>
|
||||
<NativeSelect
|
||||
|
|
@ -148,16 +120,19 @@ export const LeadershipForm = ({
|
|||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field orientation="horizontal" className="items-center gap-2 self-end pb-2">
|
||||
<input type="hidden" name="exclusive" value="false" />
|
||||
<Checkbox id="leadership-exclusive" name="exclusive" value="true" defaultChecked />
|
||||
<FieldLabel htmlFor="leadership-exclusive" className="font-normal">
|
||||
Apoio exclusivo
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leadership-notes">Observações internas</FieldLabel>
|
||||
<Textarea id="leadership-notes" name="notes" rows={3} maxLength={3000} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leadership-consent-note">Registro de consentimento externo</FieldLabel>
|
||||
<Textarea id="leadership-consent-note" name="consentNote" rows={2} maxLength={2000} />
|
||||
</Field>
|
||||
|
||||
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
|
||||
<Button type="submit" disabled={isPending} className="min-h-11 self-start">
|
||||
|
|
|
|||
|
|
@ -10,15 +10,12 @@ import {
|
|||
buildLeadershipFilterHref,
|
||||
clearLeadershipAccessFilter,
|
||||
clearLeadershipMunicipalityFilter,
|
||||
clearLeadershipSectorFilter,
|
||||
clearLeadershipStatusFilter,
|
||||
isLeadershipColumnFilterActive,
|
||||
leadershipAccessFilterOptions,
|
||||
leadershipSectorFilterOptions,
|
||||
leadershipStatusFilterOptions,
|
||||
toggleLeadershipAccessFilter,
|
||||
toggleLeadershipMunicipalityFilter,
|
||||
toggleLeadershipSectorFilter,
|
||||
toggleLeadershipStatusFilter,
|
||||
type LeadershipFilterOption,
|
||||
type LeadershipFilterParam,
|
||||
|
|
@ -27,7 +24,6 @@ import { type LeadershipListState } from '@/utilities/leadership/leadershipListU
|
|||
|
||||
const FILTER_LABELS: Record<LeadershipFilterParam, string> = {
|
||||
supportStatus: 'Status',
|
||||
sector: 'Setor',
|
||||
municipality: 'Municípios',
|
||||
access: 'Acesso ao app',
|
||||
}
|
||||
|
|
@ -54,14 +50,6 @@ const resolveFilterBranch = (
|
|||
selected: (state, value) => Boolean(state.statuses?.some((status) => status === value)),
|
||||
checkbox: true,
|
||||
}
|
||||
case 'sector':
|
||||
return {
|
||||
options: leadershipSectorFilterOptions,
|
||||
toggle: toggleLeadershipSectorFilter,
|
||||
clear: clearLeadershipSectorFilter,
|
||||
selected: (state, value) => Boolean(state.sectors?.some((sector) => sector === value)),
|
||||
checkbox: true,
|
||||
}
|
||||
case 'municipality':
|
||||
return {
|
||||
options: options ?? [],
|
||||
|
|
|
|||
|
|
@ -8,29 +8,17 @@ import {
|
|||
type RelationOption,
|
||||
} from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership'
|
||||
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
import type { LeadershipDetailViewModel } from '@/utilities/leadership/leadershipData'
|
||||
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
|
||||
|
||||
const sectorLabels: Record<(typeof leadershipSectors)[number], string> = {
|
||||
religioso: 'Religioso',
|
||||
sindical: 'Sindical',
|
||||
comunitario: 'Comunitário',
|
||||
rural: 'Rural',
|
||||
empresarial: 'Empresarial',
|
||||
juventude: 'Juventude',
|
||||
saude: 'Saúde',
|
||||
educacao: 'Educação',
|
||||
cultura: 'Cultura',
|
||||
outro: 'Outro',
|
||||
}
|
||||
|
||||
type LeadershipInternalFormProps = {
|
||||
leadership: LeadershipDetailViewModel
|
||||
municipalityOptions: RelationOption[]
|
||||
|
|
@ -84,22 +72,6 @@ export const LeadershipInternalForm = ({
|
|||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leadership-internal-sector">Setor</FieldLabel>
|
||||
<NativeSelect
|
||||
id="leadership-internal-sector"
|
||||
name="sector"
|
||||
defaultValue={leadership.sector ?? ''}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">Não informado</NativeSelectOption>
|
||||
{leadershipSectors.map((sector) => (
|
||||
<NativeSelectOption key={sector} value={sector}>
|
||||
{sectorLabels[sector]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leadership-internal-status">Status de apoio</FieldLabel>
|
||||
<NativeSelect
|
||||
|
|
@ -115,6 +87,17 @@ export const LeadershipInternalForm = ({
|
|||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field orientation="horizontal" className="items-center gap-2 self-end pb-2">
|
||||
<Checkbox
|
||||
id="leadership-internal-exclusive"
|
||||
name="exclusive"
|
||||
value="true"
|
||||
defaultChecked={leadership.exclusive}
|
||||
/>
|
||||
<FieldLabel htmlFor="leadership-internal-exclusive" className="font-normal">
|
||||
Apoio exclusivo
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
|
|
@ -127,18 +110,6 @@ export const LeadershipInternalForm = ({
|
|||
defaultValue={leadership.notes ?? undefined}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leadership-internal-consent-note">
|
||||
Registro de consentimento externo
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id="leadership-internal-consent-note"
|
||||
name="consentNote"
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
defaultValue={leadership.consentNote ?? undefined}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<CampaignFormActionMessage state={state} />
|
||||
<Button type="submit" disabled={isPending} className="min-h-11 self-start">
|
||||
|
|
|
|||
|
|
@ -250,9 +250,6 @@ export const MunicipalityDossier = ({
|
|||
{leadership.supportStatus ? (
|
||||
<SupportStatusBadge status={leadership.supportStatus} />
|
||||
) : null}
|
||||
{leadership.sector ? (
|
||||
<span className="text-xs text-muted-foreground">{leadership.sector}</span>
|
||||
) : null}
|
||||
{leadership.phone ? (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{leadership.phone}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { campaignPasswordSchema } from '@/lib/schemas/campaignPassword'
|
||||
import { leadershipGenders, leadershipSectors } from '@/lib/schemas/leadership'
|
||||
import { leadershipGenders } from '@/lib/schemas/leadership'
|
||||
import {
|
||||
brazilianMobile,
|
||||
nullablePersistedEmail,
|
||||
positiveRelationshipId,
|
||||
trimmedNullableText,
|
||||
} from '@/lib/schemas/primitives'
|
||||
|
||||
const tokenSchema = z.string().min(20).max(256)
|
||||
|
|
@ -23,8 +22,6 @@ const campaignInviteProfileSchema = z.object({
|
|||
phone: brazilianMobile,
|
||||
email: nullablePersistedEmail,
|
||||
gender: optionalEnum(leadershipGenders),
|
||||
sector: optionalEnum(leadershipSectors),
|
||||
sectorNotes: trimmedNullableText(1000),
|
||||
})
|
||||
|
||||
export const campaignInviteCreateSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -10,19 +10,6 @@ import {
|
|||
|
||||
export const leadershipGenders = ['feminino', 'masculino', 'outro', 'nao_informado'] as const
|
||||
|
||||
export const leadershipSectors = [
|
||||
'religioso',
|
||||
'sindical',
|
||||
'comunitario',
|
||||
'rural',
|
||||
'empresarial',
|
||||
'juventude',
|
||||
'saude',
|
||||
'educacao',
|
||||
'cultura',
|
||||
'outro',
|
||||
] as const
|
||||
|
||||
export const leadershipSupportStatuses = [
|
||||
'engajado',
|
||||
'a_abordar',
|
||||
|
|
@ -31,14 +18,10 @@ export const leadershipSupportStatuses = [
|
|||
] as const
|
||||
|
||||
export type SupportStatus = (typeof leadershipSupportStatuses)[number]
|
||||
export type LeadershipSector = (typeof leadershipSectors)[number]
|
||||
|
||||
export const isSupportStatus = (value: unknown): value is SupportStatus =>
|
||||
typeof value === 'string' && leadershipSupportStatuses.some((status) => status === value)
|
||||
|
||||
export const isLeadershipSector = (value: unknown): value is LeadershipSector =>
|
||||
typeof value === 'string' && leadershipSectors.some((sector) => sector === value)
|
||||
|
||||
export const MAX_LEADERSHIP_MUNICIPALITIES = 30
|
||||
const MAX_LEADERSHIP_ORGANIZATIONS = 20
|
||||
export const MAX_LEADERSHIP_STATE_DEPUTIES = 20
|
||||
|
|
@ -87,11 +70,9 @@ export const leadershipCreateSchema = z.object({
|
|||
phone: brazilianMobile,
|
||||
email: optionalPersistedEmail,
|
||||
gender: z.enum(leadershipGenders).optional(),
|
||||
sector: z.enum(leadershipSectors).optional(),
|
||||
sectorNotes: trimmedOptionalText(1000),
|
||||
exclusive: z.boolean().default(true),
|
||||
supportStatus: z.enum(leadershipSupportStatuses).default('a_abordar'),
|
||||
notes: trimmedOptionalText(3000),
|
||||
consentNote: trimmedOptionalText(2000),
|
||||
})
|
||||
|
||||
export const leadershipInternalUpdateSchema = z.object({
|
||||
|
|
@ -99,11 +80,9 @@ export const leadershipInternalUpdateSchema = z.object({
|
|||
municipalities: municipalitiesArraySchema.optional(),
|
||||
organizations: organizationsArraySchema.nullable().optional(),
|
||||
stateDeputies: stateDeputiesArraySchema.nullable().optional(),
|
||||
sector: z.enum(leadershipSectors).nullable().optional(),
|
||||
sectorNotes: trimmedNullableText(1000),
|
||||
exclusive: z.boolean().optional(),
|
||||
supportStatus: z.enum(leadershipSupportStatuses).optional(),
|
||||
notes: trimmedNullableText(3000),
|
||||
consentNote: trimmedNullableText(2000),
|
||||
})
|
||||
|
||||
export type LeadershipInternalUpdateInput = z.input<typeof leadershipInternalUpdateSchema>
|
||||
|
|
|
|||
12146
src/migrations/20260730_043306_simplify_leadership_fields.json
Normal file
12146
src/migrations/20260730_043306_simplify_leadership_fields.json
Normal file
File diff suppressed because it is too large
Load diff
23
src/migrations/20260730_043306_simplify_leadership_fields.ts
Normal file
23
src/migrations/20260730_043306_simplify_leadership_fields.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP INDEX "leadership_sector_idx";
|
||||
ALTER TABLE "leadership" DROP COLUMN "sector";
|
||||
ALTER TABLE "leadership" DROP COLUMN "sector_notes";
|
||||
ALTER TABLE "leadership" DROP COLUMN "consent_note";
|
||||
DROP TYPE "public"."enum_leadership_sector";
|
||||
ALTER TABLE "leadership" ADD COLUMN "exclusive" boolean DEFAULT true NOT NULL;
|
||||
CREATE INDEX "leadership_exclusive_idx" ON "leadership" USING btree ("exclusive");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP INDEX "leadership_exclusive_idx";
|
||||
ALTER TABLE "leadership" DROP COLUMN "exclusive";
|
||||
CREATE TYPE "public"."enum_leadership_sector" AS ENUM('religioso', 'sindical', 'comunitario', 'rural', 'empresarial', 'juventude', 'saude', 'educacao', 'cultura', 'outro');
|
||||
ALTER TABLE "leadership" ADD COLUMN "sector" "enum_leadership_sector";
|
||||
ALTER TABLE "leadership" ADD COLUMN "sector_notes" varchar;
|
||||
ALTER TABLE "leadership" ADD COLUMN "consent_note" varchar;
|
||||
CREATE INDEX "leadership_sector_idx" ON "leadership" USING btree ("sector");`)
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ import * as migration_20260727_161752_add_municipality_engagement_level from './
|
|||
import * as migration_20260728_041547_add_allocation_decision_adiada_outcome from './20260728_041547_add_allocation_decision_adiada_outcome';
|
||||
import * as migration_20260728_041958_add_campaign_webauthn_credentials from './20260728_041958_add_campaign_webauthn_credentials';
|
||||
import * as migration_20260730_010601_simplify_municipality_signal_fields from './20260730_010601_simplify_municipality_signal_fields';
|
||||
import * as migration_20260730_043306_simplify_leadership_fields from './20260730_043306_simplify_leadership_fields';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -220,6 +221,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260730_010601_simplify_municipality_signal_fields.up,
|
||||
down: migration_20260730_010601_simplify_municipality_signal_fields.down,
|
||||
name: '20260730_010601_simplify_municipality_signal_fields'
|
||||
name: '20260730_010601_simplify_municipality_signal_fields',
|
||||
},
|
||||
{
|
||||
up: migration_20260730_043306_simplify_leadership_fields.up,
|
||||
down: migration_20260730_043306_simplify_leadership_fields.down,
|
||||
name: '20260730_043306_simplify_leadership_fields',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -355,28 +355,13 @@ export interface Leadership {
|
|||
municipalities: (number | Municipality)[];
|
||||
organizations?: (number | Organization)[] | null;
|
||||
stateDeputies?: (number | StateDeputy)[] | null;
|
||||
sector?:
|
||||
| (
|
||||
| 'religioso'
|
||||
| 'sindical'
|
||||
| 'comunitario'
|
||||
| 'rural'
|
||||
| 'empresarial'
|
||||
| 'juventude'
|
||||
| 'saude'
|
||||
| 'educacao'
|
||||
| 'cultura'
|
||||
| 'outro'
|
||||
)
|
||||
| null;
|
||||
sectorNotes?: string | null;
|
||||
exclusive?: boolean | null;
|
||||
supportStatus: 'engajado' | 'a_abordar' | 'em_disputa' | 'negativo';
|
||||
user?: (number | null) | CampaignUser;
|
||||
consent?: (number | null) | Consent;
|
||||
consentContentHash?: string | null;
|
||||
consentedAt?: string | null;
|
||||
notes?: string | null;
|
||||
consentNote?: string | null;
|
||||
createdBy?: (number | null) | CampaignUser;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
|
@ -1490,15 +1475,13 @@ export interface LeadershipSelect<T extends boolean = true> {
|
|||
municipalities?: T;
|
||||
organizations?: T;
|
||||
stateDeputies?: T;
|
||||
sector?: T;
|
||||
sectorNotes?: T;
|
||||
exclusive?: T;
|
||||
supportStatus?: T;
|
||||
user?: T;
|
||||
consent?: T;
|
||||
consentContentHash?: T;
|
||||
consentedAt?: T;
|
||||
notes?: T;
|
||||
consentNote?: T;
|
||||
createdBy?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'server-only'
|
|||
import type { Payload } from 'payload'
|
||||
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import type { Consent, Leadership } from '@/payload-types'
|
||||
import type { Consent } from '@/payload-types'
|
||||
import { getLeadershipConsent } from '@/utilities/campaignConsent'
|
||||
import type { CampaignInviteKind } from '@/utilities/campaignInvite'
|
||||
import { findActiveCampaignInvite } from '@/utilities/campaignInviteRepository'
|
||||
|
|
@ -13,8 +13,6 @@ type CampaignInviteProfile = {
|
|||
phone: string
|
||||
email: string | null
|
||||
gender: 'feminino' | 'masculino' | 'outro' | 'nao_informado' | null
|
||||
sector: Leadership['sector'] | null
|
||||
sectorNotes: string | null
|
||||
}
|
||||
|
||||
export type CampaignInvitePageData =
|
||||
|
|
@ -56,8 +54,6 @@ export const getCampaignInvitePageData = async (
|
|||
depth: 0,
|
||||
select: {
|
||||
contact: true,
|
||||
sector: true,
|
||||
sectorNotes: true,
|
||||
supportStatus: true,
|
||||
consent: true,
|
||||
consentContentHash: true,
|
||||
|
|
@ -96,8 +92,6 @@ export const getCampaignInvitePageData = async (
|
|||
phone: contact.phone ?? '',
|
||||
email: contact.email ?? null,
|
||||
gender: contact.gender ?? null,
|
||||
sector: leadership.sector ?? null,
|
||||
sectorNotes: leadership.sectorNotes ?? null,
|
||||
},
|
||||
requiresConsent:
|
||||
relationshipId(leadership.consent) !== consent.id ||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
type CampaignInviteAutofillInput,
|
||||
type CampaignInviteLoginInput,
|
||||
} from '@/lib/schemas/invite'
|
||||
import type { CampaignUser, Leadership } from '@/payload-types'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { requireLeadershipConsent, resolveInviteConsent } from '@/utilities/campaignConsent'
|
||||
import {
|
||||
acquireCampaignInviteAccountLocks,
|
||||
|
|
@ -33,8 +33,6 @@ type ProfileInput = {
|
|||
phone: string
|
||||
email?: string | null
|
||||
gender?: 'feminino' | 'masculino' | 'outro' | 'nao_informado' | null
|
||||
sector?: Leadership['sector']
|
||||
sectorNotes?: string | null
|
||||
}
|
||||
|
||||
const profileContactData = (data: ProfileInput) => ({
|
||||
|
|
@ -44,11 +42,6 @@ const profileContactData = (data: ProfileInput) => ({
|
|||
...(data.gender !== undefined ? { gender: data.gender } : {}),
|
||||
})
|
||||
|
||||
const profileLeadershipData = (data: ProfileInput) => ({
|
||||
...(data.sector !== undefined ? { sector: data.sector } : {}),
|
||||
...(data.sectorNotes !== undefined ? { sectorNotes: data.sectorNotes } : {}),
|
||||
})
|
||||
|
||||
const consentUpdateData = (
|
||||
resolution: ReturnType<typeof resolveInviteConsent>,
|
||||
): {
|
||||
|
|
@ -154,10 +147,7 @@ export const redeemCampaignInviteAutofillRecord = async (
|
|||
await payload.update({
|
||||
collection: 'leadership',
|
||||
id: leadership.id,
|
||||
data: {
|
||||
...profileLeadershipData(data),
|
||||
...consentUpdateData(consentResolution),
|
||||
},
|
||||
data: consentUpdateData(consentResolution),
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
|
|
@ -276,7 +266,6 @@ export const redeemCampaignInviteLoginRecord = async (
|
|||
collection: 'leadership',
|
||||
id: leadership.id,
|
||||
data: {
|
||||
...profileLeadershipData(data),
|
||||
...consentUpdateData(consentResolution),
|
||||
user: account.id,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export type LeadershipRowViewModel = {
|
|||
phone: string | null
|
||||
email: string | null
|
||||
supportStatus: SupportStatus | null
|
||||
sector: string | null
|
||||
exclusive: boolean
|
||||
/**
|
||||
* Ids only: the "Municípios" cell renders chips through
|
||||
* `buildMunicipalityPortfolioChips` over the shared portfolio index, which
|
||||
|
|
@ -88,7 +88,7 @@ const toLeadershipRows = async (
|
|||
phone: contact.phone,
|
||||
email: contact.email,
|
||||
supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null,
|
||||
sector: doc.sector ?? null,
|
||||
exclusive: doc.exclusive ?? true,
|
||||
municipalityIDs: (doc.municipalities ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
|
|
@ -157,7 +157,7 @@ export type LeadershipListFilterFacets = {
|
|||
}
|
||||
|
||||
/**
|
||||
* Respects every OTHER filter (search, status, sector, access) but drops the
|
||||
* Respects every OTHER filter (search, status, access) but drops the
|
||||
* municipality filter itself — same contract as `loadStateDeputyPartyFacet`:
|
||||
* a selected id is unioned in so it stays visible to undo.
|
||||
*/
|
||||
|
|
@ -231,9 +231,7 @@ export type LeadershipDetailViewModel = LeadershipRowViewModel & {
|
|||
municipalityIDs: number[]
|
||||
organizationIDs: number[]
|
||||
stateDeputyIDs: number[]
|
||||
sectorNotes: string | null
|
||||
notes: string | null
|
||||
consentNote: string | null
|
||||
}
|
||||
|
||||
export const loadLeadershipDetail = async (
|
||||
|
|
@ -265,8 +263,6 @@ export const loadLeadershipDetail = async (
|
|||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
stateDeputyIDs: row.stateDeputies.map((summary) => summary.id),
|
||||
sectorNotes: doc.sectorNotes ?? null,
|
||||
notes: doc.notes ?? null,
|
||||
consentNote: doc.consentNote ?? null,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Leadership list filter affordances: Status / Setor / Município / Acesso
|
||||
* Leadership list filter affordances: Status / Município / Acesso
|
||||
* toggles, active-filter summary and clear-filters.
|
||||
*/
|
||||
import { leadershipSectors, leadershipSupportStatuses } from '@/lib/schemas/leadership'
|
||||
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
|
||||
import { strictDecimalInteger, truncatedNamesLabel } from '@/utilities/campaignListUrl'
|
||||
import {
|
||||
leadershipAccessFilterLabels,
|
||||
|
|
@ -14,24 +14,19 @@ import {
|
|||
parseLeadershipListParams,
|
||||
type LeadershipListState,
|
||||
} from '@/utilities/leadership/leadershipListUrl'
|
||||
import { leadershipSectorLabels } from '@/utilities/leadership/leadershipUi'
|
||||
|
||||
export type LeadershipFilterParam = 'supportStatus' | 'sector' | 'municipality' | 'access'
|
||||
export type LeadershipFilterParam = 'supportStatus' | 'municipality' | 'access'
|
||||
|
||||
export type LeadershipFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type LeadershipMultiFilterParam = 'status' | 'sector' | 'municipality'
|
||||
type LeadershipMultiFilterParam = 'status' | 'municipality'
|
||||
|
||||
export const leadershipStatusFilterOptions: LeadershipFilterOption[] =
|
||||
leadershipSupportStatuses.map((value) => ({ value, label: supportStatusLabels[value] }))
|
||||
|
||||
export const leadershipSectorFilterOptions: LeadershipFilterOption[] = leadershipSectors.map(
|
||||
(value) => ({ value, label: leadershipSectorLabels[value] }),
|
||||
)
|
||||
|
||||
export const leadershipAccessFilterOptions: LeadershipFilterOption[] = (
|
||||
Object.keys(leadershipAccessFilterLabels) as Array<keyof typeof leadershipAccessFilterLabels>
|
||||
).map((value) => ({ value, label: leadershipAccessFilterLabels[value] }))
|
||||
|
|
@ -58,7 +53,6 @@ const getLeadershipMultiFilterValues = (
|
|||
param: LeadershipMultiFilterParam,
|
||||
): string[] => {
|
||||
if (param === 'status') return state.statuses ?? []
|
||||
if (param === 'sector') return state.sectors ?? []
|
||||
return (state.municipalities ?? []).map(String)
|
||||
}
|
||||
|
||||
|
|
@ -83,14 +77,6 @@ export const toggleLeadershipStatusFilter = (
|
|||
export const clearLeadershipStatusFilter = (state: LeadershipListState): LeadershipListState =>
|
||||
setLeadershipMultiFilterValues(state, 'status', [])
|
||||
|
||||
export const toggleLeadershipSectorFilter = (
|
||||
state: LeadershipListState,
|
||||
value: string,
|
||||
): LeadershipListState => toggleLeadershipMultiFilterValue(state, 'sector', value)
|
||||
|
||||
export const clearLeadershipSectorFilter = (state: LeadershipListState): LeadershipListState =>
|
||||
setLeadershipMultiFilterValues(state, 'sector', [])
|
||||
|
||||
export const toggleLeadershipMunicipalityFilter = (
|
||||
state: LeadershipListState,
|
||||
value: string,
|
||||
|
|
@ -135,8 +121,6 @@ export const isLeadershipColumnFilterActive = (
|
|||
switch (filterParam) {
|
||||
case 'supportStatus':
|
||||
return Boolean(state.statuses?.length)
|
||||
case 'sector':
|
||||
return Boolean(state.sectors?.length)
|
||||
case 'municipality':
|
||||
return Boolean(state.municipalities?.length)
|
||||
case 'access':
|
||||
|
|
@ -162,9 +146,6 @@ export const formatLeadershipActiveFiltersSummary = (
|
|||
if (state.statuses?.length) {
|
||||
parts.push(truncatedNamesLabel(state.statuses.map((status) => supportStatusLabels[status])))
|
||||
}
|
||||
if (state.sectors?.length) {
|
||||
parts.push(truncatedNamesLabel(state.sectors.map((sector) => leadershipSectorLabels[sector])))
|
||||
}
|
||||
if (state.municipalities?.length) {
|
||||
parts.push(
|
||||
truncatedNamesLabel(
|
||||
|
|
|
|||
|
|
@ -5,11 +5,7 @@
|
|||
*/
|
||||
import type { Where } from 'payload'
|
||||
|
||||
import {
|
||||
leadershipSectors,
|
||||
leadershipSupportStatuses,
|
||||
type SupportStatus,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import { leadershipSupportStatuses, type SupportStatus } from '@/lib/schemas/leadership'
|
||||
import {
|
||||
allParamValues,
|
||||
buildListHref,
|
||||
|
|
@ -24,19 +20,16 @@ import {
|
|||
|
||||
export const leadershipPageSize = 25
|
||||
|
||||
export type LeadershipListSortKey = 'name' | 'supportStatus' | 'sector' | 'updatedAt'
|
||||
export type LeadershipListSortKey = 'name' | 'supportStatus' | 'updatedAt'
|
||||
export type LeadershipListSortDirection = 'asc' | 'desc'
|
||||
|
||||
/** Acesso ao app — exclusive filter (com | sem), same shape as municipality coverage. */
|
||||
type LeadershipListAccessFilter = 'com' | 'sem'
|
||||
|
||||
type LeadershipSector = (typeof leadershipSectors)[number]
|
||||
|
||||
export type LeadershipListState = {
|
||||
page: number
|
||||
q?: string
|
||||
statuses?: SupportStatus[]
|
||||
sectors?: LeadershipSector[]
|
||||
municipalities?: number[]
|
||||
access?: LeadershipListAccessFilter
|
||||
sort?: LeadershipListSortKey
|
||||
|
|
@ -48,7 +41,6 @@ export type LeadershipListSearchParams = RawSearchParams
|
|||
const leadershipListParamNames = [
|
||||
'q',
|
||||
'status',
|
||||
'sector',
|
||||
'municipality',
|
||||
'access',
|
||||
'sort',
|
||||
|
|
@ -63,14 +55,12 @@ const DEFAULT_LEADERSHIP_LIST_SORT_DIR: LeadershipListSortDirection = 'desc'
|
|||
export const leadershipListSortLabels: Record<LeadershipListSortKey, string> = {
|
||||
name: 'Nome',
|
||||
supportStatus: 'Status',
|
||||
sector: 'Setor',
|
||||
updatedAt: 'Última atualização',
|
||||
}
|
||||
|
||||
const leadershipListSortKeySet = new Set<string>(Object.keys(leadershipListSortLabels))
|
||||
const leadershipListSortDirSet = new Set<LeadershipListSortDirection>(['asc', 'desc'])
|
||||
const supportStatusSet = new Set<string>(leadershipSupportStatuses)
|
||||
const sectorSet = new Set<string>(leadershipSectors)
|
||||
const accessFilterSet = new Set<LeadershipListAccessFilter>(['com', 'sem'])
|
||||
|
||||
/** Temporal = newest first; categorical/name = A–Z. */
|
||||
|
|
@ -114,7 +104,6 @@ export const leadershipListStateToRawParams = (
|
|||
page: String(page),
|
||||
q: state.q,
|
||||
status: state.statuses,
|
||||
sector: state.sectors,
|
||||
municipality: state.municipalities?.map(String),
|
||||
access: state.access,
|
||||
sort: state.sort,
|
||||
|
|
@ -127,7 +116,6 @@ export const parseLeadershipListParams = (
|
|||
const rawPage = strictDecimalInteger(firstValue(params.page))
|
||||
const q = normalizedText(firstValue(params.q))
|
||||
const statuses = parseExhaustiveEnumParam<SupportStatus>(params.status, supportStatusSet)
|
||||
const sectors = parseExhaustiveEnumParam<LeadershipSector>(params.sector, sectorSet)
|
||||
const municipalities = allParamValues(params.municipality)
|
||||
.map((token) => strictDecimalInteger(token))
|
||||
.filter((id): id is number => typeof id === 'number' && id > 0)
|
||||
|
|
@ -151,7 +139,6 @@ export const parseLeadershipListParams = (
|
|||
page: rawPage ?? 1,
|
||||
...(q ? { q } : {}),
|
||||
...(statuses.length ? { statuses } : {}),
|
||||
...(sectors.length ? { sectors } : {}),
|
||||
...(municipalities.length ? { municipalities } : {}),
|
||||
...(access ? { access } : {}),
|
||||
...(sort ? { sort } : {}),
|
||||
|
|
@ -163,7 +150,6 @@ export const buildLeadershipListWhere = (state: LeadershipListState): Where => {
|
|||
const filters: Where[] = []
|
||||
if (state.q) filters.push({ 'contact.name': { contains: state.q } })
|
||||
if (state.statuses?.length) filters.push({ supportStatus: { in: state.statuses } })
|
||||
if (state.sectors?.length) filters.push({ sector: { in: state.sectors } })
|
||||
if (state.municipalities?.length) {
|
||||
filters.push({ municipalities: { in: state.municipalities } })
|
||||
}
|
||||
|
|
@ -186,7 +172,6 @@ export const serializeCanonicalLeadershipListSearchParams = (
|
|||
|
||||
if (canonicalState.q) params.set('q', canonicalState.q)
|
||||
for (const status of canonicalState.statuses ?? []) params.append('status', status)
|
||||
for (const sector of canonicalState.sectors ?? []) params.append('sector', sector)
|
||||
for (const municipality of canonicalState.municipalities ?? []) {
|
||||
params.append('municipality', String(municipality))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { leadershipGenders, leadershipSectors } from '@/lib/schemas/leadership'
|
||||
import type { leadershipGenders } from '@/lib/schemas/leadership'
|
||||
|
||||
export const leadershipGenderLabels: Record<(typeof leadershipGenders)[number], string> = {
|
||||
feminino: 'Feminino',
|
||||
|
|
@ -6,16 +6,3 @@ export const leadershipGenderLabels: Record<(typeof leadershipGenders)[number],
|
|||
outro: 'Outro',
|
||||
nao_informado: 'Prefiro não informar',
|
||||
}
|
||||
|
||||
export const leadershipSectorLabels: Record<(typeof leadershipSectors)[number], string> = {
|
||||
religioso: 'Religioso',
|
||||
sindical: 'Sindical',
|
||||
comunitario: 'Comunitário',
|
||||
rural: 'Rural',
|
||||
empresarial: 'Empresarial',
|
||||
juventude: 'Juventude',
|
||||
saude: 'Saúde',
|
||||
educacao: 'Educação',
|
||||
cultura: 'Cultura',
|
||||
outro: 'Outro',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,15 +68,13 @@ type LeadershipInput = Partial<
|
|||
| 'contact'
|
||||
| 'municipalities'
|
||||
| 'organizations'
|
||||
| 'sector'
|
||||
| 'sectorNotes'
|
||||
| 'exclusive'
|
||||
| 'supportStatus'
|
||||
| 'user'
|
||||
| 'consent'
|
||||
| 'consentContentHash'
|
||||
| 'consentedAt'
|
||||
| 'notes'
|
||||
| 'consentNote'
|
||||
| 'createdBy'
|
||||
>
|
||||
> &
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ describe('loadLeadershipListPageData', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('filters by supportStatus, sector, access and municipality', async () => {
|
||||
it('filters by supportStatus, access and municipality', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipalityA = await fixtures.getMunicipality()
|
||||
|
|
@ -99,13 +99,11 @@ describe('loadLeadershipListPageData', () => {
|
|||
contact: matchContact.id,
|
||||
municipalities: [municipalityA.id],
|
||||
supportStatus: 'a_abordar',
|
||||
sector: 'religioso',
|
||||
})
|
||||
await fixtures.createLeadership({
|
||||
contact: missContact.id,
|
||||
municipalities: [municipalityB.id],
|
||||
supportStatus: 'engajado',
|
||||
sector: 'sindical',
|
||||
})
|
||||
|
||||
const byStatus = await loadLeadershipListPageData(payload, coordinator, {
|
||||
|
|
@ -115,13 +113,6 @@ describe('loadLeadershipListPageData', () => {
|
|||
})
|
||||
expect(byStatus.rows.map((row) => row.id)).toEqual([match.id])
|
||||
|
||||
const bySector = await loadLeadershipListPageData(payload, coordinator, {
|
||||
page: 1,
|
||||
q: marker,
|
||||
sectors: ['religioso'],
|
||||
})
|
||||
expect(bySector.rows.map((row) => row.id)).toEqual([match.id])
|
||||
|
||||
const byMunicipality = await loadLeadershipListPageData(payload, coordinator, {
|
||||
page: 1,
|
||||
q: marker,
|
||||
|
|
|
|||
|
|
@ -586,7 +586,7 @@ describe('campaign invite domain', () => {
|
|||
email: 'maria@example.com',
|
||||
gender: 'feminino',
|
||||
sector: 'comunitario',
|
||||
sectorNotes: ' Associação do bairro ',
|
||||
sectorNotes: 'forjado',
|
||||
consentAccepted: true,
|
||||
leadership: 999,
|
||||
supportStatus: 'engajado',
|
||||
|
|
@ -602,8 +602,6 @@ describe('campaign invite domain', () => {
|
|||
phone: '71999990000',
|
||||
email: 'maria@example.com',
|
||||
gender: 'feminino',
|
||||
sector: 'comunitario',
|
||||
sectorNotes: 'Associação do bairro',
|
||||
consentAccepted: true,
|
||||
})
|
||||
})
|
||||
|
|
@ -936,8 +934,6 @@ describe('campaign invite domain', () => {
|
|||
phone: campaignFixtures().phone(),
|
||||
email: 'confirmada@example.com',
|
||||
gender: 'feminino' as const,
|
||||
sector: 'comunitario' as const,
|
||||
sectorNotes: 'Associação local',
|
||||
consentAccepted: true as const,
|
||||
supportStatus: 'negativo',
|
||||
notes: 'tentativa de sobrescrita',
|
||||
|
|
@ -969,8 +965,6 @@ describe('campaign invite domain', () => {
|
|||
gender: 'feminino',
|
||||
})
|
||||
expect(updatedLeadership).toMatchObject({
|
||||
sector: 'comunitario',
|
||||
sectorNotes: 'Associação local',
|
||||
supportStatus: 'engajado',
|
||||
notes: 'Nota interna protegida',
|
||||
municipalities: [municipality.id],
|
||||
|
|
@ -1180,11 +1174,6 @@ describe('campaign invite domain', () => {
|
|||
id: cleared.contact.id,
|
||||
data: { email: 'antes@example.com', gender: 'feminino' },
|
||||
})
|
||||
await payload.update({
|
||||
collection: 'leadership',
|
||||
id: cleared.leadership.id,
|
||||
data: { sector: 'comunitario', sectorNotes: 'Antes' },
|
||||
})
|
||||
const clearInvite = await createInviteThroughAction(coordinator, {
|
||||
leadership: cleared.leadership.id,
|
||||
kind: 'autopreenchimento',
|
||||
|
|
@ -1196,17 +1185,12 @@ describe('campaign invite domain', () => {
|
|||
phone: cleared.contact.phone,
|
||||
email: '',
|
||||
gender: '',
|
||||
sector: '',
|
||||
sectorNotes: '',
|
||||
consentAccepted: true,
|
||||
})
|
||||
|
||||
await expect(
|
||||
payload.findByID({ collection: 'contact', id: cleared.contact.id, depth: 0 }),
|
||||
).resolves.toMatchObject({ email: null, gender: null })
|
||||
await expect(
|
||||
payload.findByID({ collection: 'leadership', id: cleared.leadership.id, depth: 0 }),
|
||||
).resolves.toMatchObject({ sector: null, sectorNotes: null })
|
||||
|
||||
const unchanged = await createInviteLeadershipGraph(coordinator, municipality.id)
|
||||
await payload.update({
|
||||
|
|
@ -1214,11 +1198,6 @@ describe('campaign invite domain', () => {
|
|||
id: unchanged.contact.id,
|
||||
data: { email: 'mantido@example.com', gender: 'masculino' },
|
||||
})
|
||||
await payload.update({
|
||||
collection: 'leadership',
|
||||
id: unchanged.leadership.id,
|
||||
data: { sector: 'sindical', sectorNotes: 'Mantido' },
|
||||
})
|
||||
const unchangedInvite = await createInviteThroughAction(coordinator, {
|
||||
leadership: unchanged.leadership.id,
|
||||
kind: 'autopreenchimento',
|
||||
|
|
@ -1234,9 +1213,6 @@ describe('campaign invite domain', () => {
|
|||
await expect(
|
||||
payload.findByID({ collection: 'contact', id: unchanged.contact.id, depth: 0 }),
|
||||
).resolves.toMatchObject({ email: 'mantido@example.com', gender: 'masculino' })
|
||||
await expect(
|
||||
payload.findByID({ collection: 'leadership', id: unchanged.leadership.id, depth: 0 }),
|
||||
).resolves.toMatchObject({ sector: 'sindical', sectorNotes: 'Mantido' })
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -139,11 +139,8 @@ const createPublicInviteScenario = async (
|
|||
data: {
|
||||
contact: contact.id,
|
||||
municipalities: [municipality.id],
|
||||
sector: 'comunitario',
|
||||
sectorNotes: 'Associação do bairro',
|
||||
supportStatus: 'engajado',
|
||||
notes: 'Avaliação interna protegida',
|
||||
consentNote: 'Registro interno protegido',
|
||||
consent: options.consent ? consent.id : null,
|
||||
consentContentHash: options.consent ? hashConsentContent(consent.text) : null,
|
||||
consentedAt: options.consent ? new Date().toISOString() : null,
|
||||
|
|
@ -222,8 +219,6 @@ describe('campaign invite UI contracts', () => {
|
|||
phone: fixture.contact.phone,
|
||||
email: fixture.contact.email,
|
||||
gender: fixture.contact.gender,
|
||||
sector: 'comunitario',
|
||||
sectorNotes: 'Associação do bairro',
|
||||
},
|
||||
requiresConsent: true,
|
||||
consentData: expect.objectContaining({ root: expect.any(Object) }),
|
||||
|
|
@ -409,8 +404,6 @@ describe('campaign invite UI contracts', () => {
|
|||
formData.set('phone', fixture.contact.phone!)
|
||||
formData.set('email', fixture.contact.email ?? '')
|
||||
formData.set('gender', 'feminino')
|
||||
formData.set('sector', 'comunitario')
|
||||
formData.set('sectorNotes', 'Dados revisados pela titular')
|
||||
formData.set('consentAccepted', 'on')
|
||||
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -94,15 +94,13 @@ describe('campaign leadership domain', () => {
|
|||
expect(
|
||||
leadershipInternalUpdateSchema.parse({
|
||||
id: 1,
|
||||
sectorNotes: ' ',
|
||||
notes: null,
|
||||
consentNote: ' Registro removível ',
|
||||
notes: ' ',
|
||||
exclusive: false,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 1,
|
||||
sectorNotes: null,
|
||||
notes: null,
|
||||
consentNote: 'Registro removível',
|
||||
exclusive: false,
|
||||
})
|
||||
expect(leadershipInternalUpdateSchema.parse({ id: 1 })).toEqual({ id: 1 })
|
||||
})
|
||||
|
|
@ -128,7 +126,6 @@ describe('campaign leadership domain', () => {
|
|||
phone: `+55 ${phone}`,
|
||||
email: '',
|
||||
gender: 'feminino',
|
||||
sector: 'comunitario',
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -174,10 +174,8 @@ describe('campaign leadership exported form actions', () => {
|
|||
).docs[0]!
|
||||
expect(leadership).toMatchObject({
|
||||
supportStatus: 'a_abordar',
|
||||
sector: null,
|
||||
sectorNotes: null,
|
||||
exclusive: true,
|
||||
notes: null,
|
||||
consentNote: null,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -210,15 +208,13 @@ describe('campaign leadership exported form actions', () => {
|
|||
municipalities: [municipality.id],
|
||||
name: 'Liderança com campos internos',
|
||||
phone: campaignFixtures().phone(),
|
||||
sectorNotes: 'Setor preservado',
|
||||
exclusive: true,
|
||||
notes: 'Nota para limpar',
|
||||
consentNote: 'Consentimento para limpar',
|
||||
})
|
||||
const formData = new FormData()
|
||||
formData.set('leadershipId', String(created.id))
|
||||
formData.append('municipalities', String(municipality.id))
|
||||
formData.set('notes', ' ')
|
||||
formData.set('consentNote', '')
|
||||
|
||||
await expect(updateLeadershipInternalFormAction({}, formData)).resolves.toMatchObject({
|
||||
status: 'success',
|
||||
|
|
@ -227,12 +223,30 @@ describe('campaign leadership exported form actions', () => {
|
|||
await expect(
|
||||
payload.findByID({ collection: 'leadership', id: created.id, depth: 0 }),
|
||||
).resolves.toMatchObject({
|
||||
sectorNotes: 'Setor preservado',
|
||||
exclusive: false,
|
||||
notes: null,
|
||||
consentNote: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('creates with exclusive true by default and honors an explicit false', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const defaulted = await createLeadershipRecord(payload, coordinator, {
|
||||
municipalities: [municipality.id],
|
||||
name: 'Liderança exclusiva default',
|
||||
phone: campaignFixtures().phone(),
|
||||
})
|
||||
expect(defaulted.exclusive).toBe(true)
|
||||
|
||||
const divided = await createLeadershipRecord(payload, coordinator, {
|
||||
municipalities: [municipality.id],
|
||||
name: 'Liderança dividida',
|
||||
phone: campaignFixtures().phone(),
|
||||
exclusive: false,
|
||||
})
|
||||
expect(divided.exclusive).toBe(false)
|
||||
})
|
||||
|
||||
it('returns denial states for out-of-scope advisor actions', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const advisor = await campaignFixtures().createCampaignUser('advisor')
|
||||
|
|
|
|||
|
|
@ -38,37 +38,24 @@ describe('leadership list URL contract', () => {
|
|||
expect(parseLeadershipListParams({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro')
|
||||
})
|
||||
|
||||
it('parses and dedupes known status/sector values and integer municipality ids', () => {
|
||||
it('parses and dedupes known status values and integer municipality ids', () => {
|
||||
expect(
|
||||
parseLeadershipListParams({
|
||||
status: ['engajado', 'a_abordar', 'engajado', 'unknown'],
|
||||
sector: ['religioso', 'clube', 'sindical'],
|
||||
sector: ['religioso'],
|
||||
municipality: ['12', '12', '0', 'abc', '3'],
|
||||
}),
|
||||
).toEqual({
|
||||
page: 1,
|
||||
statuses: ['engajado', 'a_abordar'],
|
||||
sectors: ['religioso', 'sindical'],
|
||||
municipalities: [12, 3],
|
||||
})
|
||||
})
|
||||
|
||||
it('canonicalizes selecting every status/sector member to the absent filter (B18)', () => {
|
||||
it('canonicalizes selecting every status member to the absent filter (B18)', () => {
|
||||
expect(
|
||||
parseLeadershipListParams({
|
||||
status: ['engajado', 'a_abordar', 'em_disputa', 'negativo'],
|
||||
sector: [
|
||||
'religioso',
|
||||
'sindical',
|
||||
'comunitario',
|
||||
'rural',
|
||||
'empresarial',
|
||||
'juventude',
|
||||
'saude',
|
||||
'educacao',
|
||||
'cultura',
|
||||
'outro',
|
||||
],
|
||||
}),
|
||||
).toEqual({ page: 1 })
|
||||
})
|
||||
|
|
@ -102,10 +89,18 @@ describe('leadership list URL contract', () => {
|
|||
dir: 'asc',
|
||||
})
|
||||
expect(buildLeadershipListHref(state, 1)).toBe(
|
||||
'/campanha/liderancas?q=ana&status=a_abordar§or=religioso&municipality=7&access=sem&sort=name',
|
||||
'/campanha/liderancas?q=ana&status=a_abordar&municipality=7&access=sem&sort=name',
|
||||
)
|
||||
})
|
||||
|
||||
it('drops the legacy sector param from the canonical query', () => {
|
||||
expect(resolveLeadershipListUrl({ sector: 'religioso' })).toEqual({
|
||||
state: { page: 1 },
|
||||
href: '/campanha/liderancas',
|
||||
redirectHref: '/campanha/liderancas',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves filters while toggling sort direction and resets to page 1', () => {
|
||||
const state = parseLeadershipListParams({ q: 'ana', page: '3' })
|
||||
|
||||
|
|
@ -185,10 +180,10 @@ describe('leadership list filter state', () => {
|
|||
page: 4,
|
||||
q: 'ana',
|
||||
statuses: ['engajado'],
|
||||
sort: 'sector',
|
||||
sort: 'supportStatus',
|
||||
dir: 'asc',
|
||||
}),
|
||||
).toEqual({ page: 1, sort: 'sector', dir: 'asc' })
|
||||
).toEqual({ page: 1, sort: 'supportStatus', dir: 'asc' })
|
||||
})
|
||||
|
||||
it('summarizes active filters with municipality labels when provided', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue