Tooling: prettier como autoridade de formatacao (organize-imports deterministico, scripts format/format:check, normalizacao mecanica de 312 arquivos; artefatos gerados e migrations excluidos via .prettierignore)

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 17:22:20 -03:00
parent 51dd779ddd
commit e6f7c2e70f
313 changed files with 2443 additions and 1486 deletions

View file

@ -6,27 +6,21 @@
{
"rule": "design-system-color",
"value": "#111827",
"files": [
"src/collections/CampaignUser.ts"
],
"files": ["src/collections/CampaignUser.ts"],
"createdAt": "2026-07-21T01:54:11.928Z",
"reason": "Pre-existing inline hex in the password-reset EMAIL HTML template; transactional email cannot use CSS tokens"
},
{
"rule": "broken-image",
"value": "*",
"files": [
"src/components/MetaPixel.tsx"
],
"files": ["src/components/MetaPixel.tsx"],
"createdAt": "2026-07-23T22:40:00.000Z",
"reason": "Meta's documented noscript tracking pixel: a hidden 1x1 <img> whose src is a templated facebook.com/tr URL — not a rendered image"
},
{
"rule": "design-system-color",
"value": "#c86112",
"files": [
"src/app/(frontend)/styles.css"
],
"files": ["src/app/(frontend)/styles.css"],
"createdAt": "2026-07-25T02:48:38.641Z",
"reason": "Pre-existing public-site body brand gradient (live in production, predates the hook); flagged while an unrelated print block was added"
}

View file

@ -8,6 +8,7 @@ p2_count: 2
timestamp: 2026-07-19T10-15-47Z
slug: src-app-campaign-campanha
---
Method: dual-agent (A: 89c6189f-1341-4e15-8d45-e1aa7c6f5167 · B: 3e87667e-e21e-4a29-85b4-db65afcc49c5)
# Critique: /campanha
@ -15,7 +16,7 @@ Method: dual-agent (A: 89c6189f-1341-4e15-8d45-e1aa7c6f5167 · B: 3e87667e-e21e-
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | ------------------------------------------------------------------------ |
| 1 | Visibility of System Status | 3 | Pending/aria-live on filters; no freshness/"as of" on KPI aggregates |
| 2 | Match System / Real World | 4 | Domain language precise (Núcleo vs Zona TSE disambiguated) |
| 3 | User Control and Freedom | 3 | Clear filters/back; limited undo for irreversible paths |
@ -49,36 +50,42 @@ A serious field desk with strong a11y/touch discipline and honest empty-state co
## Priority Issues
### [P1] Filter panel decision overload
**What:** `NucleusFilters` shows 6 fields at once on desktop (TI, município, ZE, cobertura, estimativa, prioridade) plus search.
**Why it matters:** Primary triage tool under time pressure; violates clarity-under-pressure and >4 options rule.
**Fix:** Keep territory + search primary; put coverage/estimate/priority behind "Mais filtros" at all breakpoints.
**Suggested command:** `/impeccable distill` / `/impeccable layout`
### [P1] Hero-metric Card grids contradict own anti-references
**What:** General dashboard, NucleusListOverview, SupporterListOverview all use Card → description → big number grids.
**Why it matters:** PRODUCT/DESIGN ban identical card grids / hero-metric templates; this is the strongest "template AI" tell.
**Fix:** Compact secondary metrics into a stat row; reserve Card + priority lift for 1–2 decisive numbers; rank action queues.
**Suggested command:** `/impeccable quieter` / `/impeccable layout`
### [P1] Side-tab accent on baseline row (detector)
**What:** `border-l-4 border-primary` on highlighted candidate in NucleusElectoralBaseline.
**Why it matters:** Absolute ban (side-stripe >1px); deterministic AI-tell.
**Fix:** Full border, background tint only, or leading mark — no thick left stripe.
**Suggested command:** `/impeccable quieter`
### [P2] Liderança empty state is a dead end
**What:** "Nenhum núcleo" / "vínculo engajado" + warning icon, no CTA.
**Why it matters:** Peer persona stranded; jargon without next step.
**Fix:** Mirror forgot-password — name who to contact (coordenador / WhatsApp).
**Suggested command:** `/impeccable clarify` / `/impeccable onboard`
### [P2] No freshness signal on aggregates
**What:** KPIs/overviews never show when data was computed.
**Why it matters:** Field decisions need trust in recency.
**Fix:** Relative "atualizado há…" near overview headers.
**Suggested command:** `/impeccable harden`
### [P3] Split brand chrome (mobile red bar vs desktop stone rail)
**What:** Mobile top bar is solid Mandate Red; desktop sidebar is quiet rail.
**Why it matters:** Breaks Signal Red Rule for Casey/Lia switching devices unless intentional "field mode."
**Fix:** Document as field-mode signal or quiet the mobile chrome.

View file

@ -8,6 +8,7 @@ p3_count: 2
timestamp: 2026-07-19T11-03-23Z
slug: src-app-campaign-campanha
---
Method: dual-agent (A: 43c7ddf0-03c4-40b6-86e4-167ed820fbc3 · B: c685e95f-8884-4bd6-9174-53d1a4613689)
# Critique: /campanha
@ -15,7 +16,7 @@ Method: dual-agent (A: 43c7ddf0-03c4-40b6-86e4-167ed820fbc3 · B: c685e95f-8884-
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | ------------------------------------------------------------------------ |
| 1 | Visibility of System Status | 4 | Freshness on KPI surfaces; filter aria-busy/live region |
| 2 | Match System / Real World | 4 | Núcleo vs Zona TSE stated on list; domain language solid |
| 3 | User Control and Freedom | 4 | Differentiated AlertDialogs; clear filter clear/back |
@ -49,30 +50,35 @@ Prior P1s (filter wall, hero-metric card grids, side-tab) are closed. The surfac
## Priority Issues
### [P2] Domain jargon still opaque beyond one sentence
**What:** engajado, estimativa confirmada vs proposta, support statuses lack tooltips/help.
**Why it matters:** Lia and first-timers translate under pressure; Help heuristic still weakest.
**Fix:** Inline ? / first-visit hint on SupportStatusBadge and estimate labels.
**Suggested command:** `/impeccable clarify`
### [P2] Dashboard queues cannot be triaged in bulk
**What:** Sem coordenador / stale updates / pending estimates are one-row links only.
**Why it matters:** Alex under time pressure on a growing queue; CSV bulk exists for import but not daily triage.
**Fix:** Multi-select + assign on the priority queue only.
**Suggested command:** `/impeccable layout`
### [P2] Dashboard heading outline skips h2
**What:** Detector + source: h1 "Visão geral" then QueueSection h3 before Indicadores h2.
**Why it matters:** Screen-reader document outline; Sam persona.
**Fix:** Promote Filas section title to h2, demote queue titles to h3; or insert sr-only/visible h2.
**Suggested command:** `/impeccable audit`
### [P3] Coordenador empty state has no CTA
**What:** Prose-only empty vs leadership empty with Ver meu perfil.
**Why it matters:** Last sibling of the old dead-end pattern.
**Fix:** Mirror EmptyContent CTA pattern.
**Suggested command:** `/impeccable onboard`
### [P3] MetricStrip double card surface + sidebar height transition
**What:** Overlay nested-cards on strip `bg-card` inside `bg-card`; Sidebar `transition-[width,height,padding]`.
**Why it matters:** Mild visual nesting / layout jank risk — not task-blocking.
**Fix:** Flatten inner metric cells to transparent; prefer transform/opacity on chrome.

View file

@ -6,6 +6,7 @@ p1_count: 2
timestamp: 2026-07-19T15-10-58Z
slug: src-app-campaign-campanha-login
---
Method: dual-agent (A: cd378fde-c091-463a-8e89-ca6cecac8904 · B: 44f7668c-1143-41ec-a147-a2dc2f4d7f9c)
# Critique: /campanha/login
@ -13,7 +14,7 @@ Method: dual-agent (A: cd378fde-c091-463a-8e89-ca6cecac8904 · B: 44f7668c-1143-
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------- |
| 1 | Visibility of System Status | 3 | Pending state is clear (Spinner + “Entrando…”); auth failure is a single generic FieldError not tied to fields |
| 2 | Match System / Real World | 3 | “E-mail ou celular” matches dual auth, but leadership phone path vs email-only reset is invisible on this screen |
| 3 | User Control and Freedom | 3 | Forgot-password link exists; no show-password, no first-access/invite escape |
@ -47,30 +48,35 @@ A clean, trustworthy ops gate that earns familiarity — then stalls for the peo
## Priority Issues
### [P1] Missing document heading landmark
- **What**: `CardTitle` is a `<div>`; page has no `<h1>`. Brand row is plain `font-semibold` text.
- **Why it matters**: Screen-reader users get no clear “where am I” on a critical entry screen; weak WCAG structure.
- **Fix**: Promote “Acessar painel” (or a sr-only “Entrar — Campanha”) to a real `<h1>`.
- **Suggested command**: `/impeccable harden /campanha/login`
### [P1] Phone-first leadership mismatch
- **What**: Identifier uses `inputMode="email"` while leadership auth is phone-as-username; “Esqueceu a senha?” leads to email-only reset that already tells phone users to ask a coordinator — but login itself is silent.
- **Why it matters**: Field leadership gets the wrong keyboard, fails auth, then hits an email form that abandons them.
- **Fix**: Prefer `inputMode="tel"` or pattern-aware mode; add one line of login copy mirroring `ForgotPasswordForm` (“Liderança: celular cadastrado. Sem senha? Peça convite ao coordenador.”).
- **Suggested command**: `/impeccable clarify /campanha/login`
### [P2] Monolithic auth error with no recovery branches
- **What**: `state.error` (“E-mail, celular ou senha inválidos.” / “Dados inválidos.”) sits in one `FieldError` above submit, not linked via `aria-describedby` / `aria-invalid`.
- **Why it matters**: Under pressure users retype everything; no path to invite vs reset.
- **Fix**: Keep message generic for security; add `aria-invalid` + recovery links (esqueci-senha / first-access invite guidance).
- **Suggested command**: `/impeccable harden /campanha/login`
### [P2] No first-timer guidance on the login surface
- **What**: “Campanha” + Megaphone + “Acessar painel” assumes prior context; invite flow undiscoverable.
- **Why it matters**: Jordan abandons; leadership thinks the product is broken when they need a WhatsApp invite.
- **Fix**: One sentence under CardDescription: who this is for + what to do on first access.
- **Suggested command**: `/impeccable onboard /campanha/login` or `/impeccable clarify /campanha/login`
### [P3] Submit width inconsistent with sibling auth forms
- **What**: Login `Button` is `min-h-11` without `w-full`; forgot/reset use `min-h-11 w-full`.
- **Why it matters**: Thumb-target inconsistency on phone; visual drift across auth chain.
- **Fix**: Add `w-full`; extract shared `CampaignAuthPageShell` (already on RS+ backlog).

View file

@ -5,13 +5,16 @@
**Detector:** clean
## Strengths
- Reuses Alert stack and tokens `estimate-confirmed` / `pending` consistent with Gap and Tendência.
- One-line veredito + support numbers match design-ref Baseline-Eleitoral-2022.
- Overview aggregation mirrors existing gap/trend lines.
## Issues addressed in polish
- P1: Duplicated confirmed-alert class strings → extracted `confirmedInsightAlertClass`.
## Minor (deferred)
- Band label (reduto/consolidado/oportunidade) not shown in detail Alert title — product may want chip later.
- Overview distribution counts may be zero-heavy on small filters — acceptable for v1.

View file

@ -7,6 +7,7 @@ p2_count: 2
timestamp: 2026-07-19T15-55-17Z
slug: src-app-campaign-campanha-login
---
Method: dual-agent (A: 103923c2-0d00-49d5-9902-eee91515fbc5 · B: c71afcf4-ae05-432e-900f-a93610cb29a4)
# Critique: /campanha/login (post-polish)
@ -14,7 +15,7 @@ Method: dual-agent (A: 103923c2-0d00-49d5-9902-eee91515fbc5 · B: c71afcf4-ae05-
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | ------------------------------------------------------------------------------------------------ |
| 1 | Visibility of System Status | 4 | Spinner + aria-live; FieldError alert + aria-invalid/describedby on both fields |
| 2 | Match System / Real World | 3 | Dual-path copy clear; description still leads with email before phone; forgot remains email-only |
| 3 | User Control and Freedom | 3 | Forgot link exists; no show-password; no staff/liderança switch |
@ -48,30 +49,35 @@ Prior P1/P2 gaps closed: real h1, mobilizing title, phone-first placeholder, fir
## Priority Issues
### [P2] Empty-field keyboard not phone-first
- **What:** identifierInputMode('') → 'text'; tel only after ≥2 digits.
- **Why:** Leadership first tap still gets QWERTY.
- **Fix:** Default inputMode=tel when empty; switch to email on @.
- **Suggested command:** `/impeccable adapt /campanha/login`
### [P2] No show-password toggle
- **What:** password type only.
- **Why:** One-handed field re-entry under glare.
- **Fix:** Reveal toggle with aria-pressed, keep min-h-11.
- **Suggested command:** `/impeccable harden /campanha/login`
### [P3] Recovery hint outside aria-describedby
- **What:** Only login-credentials-error is described; CAMPAIGN_LEADERSHIP_LOGIN_RECOVERY_HINT is a sibling p.
- **Why:** SR may miss phone-only guidance.
- **Fix:** Append hint id to aria-describedby.
- **Suggested command:** `/impeccable harden /campanha/login`
### [P3] “Esqueceu a senha?” still email-first funnel
- **What:** Link unlabeled as email-only; phone users hit forgot then discover coordinator path.
- **Why:** Reflex after one failed attempt.
- **Fix:** Sub-label or always-visible CAMPAIGN_LEADERSHIP_PHONE_ACCESS_HINT near link.
- **Suggested command:** `/impeccable clarify /campanha/login`
### [P3] Emotional register still flat vs “inspiring”
- **What:** Clear ops gate; no trust/solidarity line.
- **Why:** Field Desk asks for socialist/clear/inspiring.
- **Fix:** One grounded trust line without kitsch.

View file

@ -6,6 +6,7 @@ p1_count: 2
timestamp: 2026-07-19T21-47-04Z
slug: src-app-campaign-campanha-login
---
# Critique: /campanha/login
Method: dual-agent (A: 8a8b1ae0-9a8a-45f7-907f-54afcdf62db8 · B: 3899acad-4f36-4817-91d6-4135d72d2e96)
@ -13,7 +14,7 @@ Method: dual-agent (A: 8a8b1ae0-9a8a-45f7-907f-54afcdf62db8 · B: 3899acad-4f36-
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | ---------------------------------------------------------------- |
| 1 | Visibility of System Status | 3 | Pending state clear; no other status chrome |
| 2 | Match System / Real World | 3 | PT-BR + e-mail/celular fit; megaphone mark generic |
| 3 | User Control and Freedom | 2 | Only “Esqueceu a senha?”; no exit to public site |
@ -47,25 +48,30 @@ Competent Field Desk palette discipline, phone-first form craft — undermined b
## Priority Issues
### P1 — Title misaligned with subtitle
Campaign `[data-theme='campaign'] h1 { text-align: left }` overrides CardHeader `text-center`. Title glyph start ~28px left of centered subtitle.
**Fix:** Scope left-align to app chrome, or `text-center` on auth heading.
**Command:** `/impeccable typeset` or `/impeccable layout`
### P1 — Subtitle vertical rhythm inverted
CardHeader `gap-1` (4px) between title and description; orphan `<p>` inherits base `leading-7` (28px) while CardDescription stays ~20px. Title glued to howto; second muted block breathes like article body.
**Fix:** One muted block with explicit leading-snug; bump title→desc gap; never bare `<p>` under campaign theme without leading override.
**Command:** `/impeccable layout` + `/impeccable typeset`
### P2 — Split-screen + public publications: recommend No
Login is ops gate (noindex); posts are public register. Phone users get scroll soup. Motivation ≠ news feed on unlock.
**Soft maybe:** one quiet purpose line under mark — not a publication panel.
**Command:** `/impeccable shape` only if product insists on panel (internal voice, not posts)
### P2 — Generic Megaphone mark
Not Solla / Field Desk identity.
**Command:** `/impeccable polish` (reuse campaign-logo)
### P3 — Always-on first-access paragraph
Progressive disclosure for returning users.
**Command:** `/impeccable distill` or `/impeccable clarify`

View file

@ -7,12 +7,13 @@ p2_count: 3
timestamp: 2026-07-19T23-22-03Z
slug: src-app-campaign-campanha-app-page-tsx
---
Method: dual-agent (A: design review · B: detector + Playwright screenshots)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | -------------------------------------------------------------------- |
| 1 | Visibility of System Status | 3 | KPIs têm `CampaignDataFreshness`; filas não mostram idade dos itens |
| 2 | Match System / Real World | 3 | Linguagem de núcleo/fila encaixa; **"Lideranças" sem contexto** |
| 3 | User Control and Freedom | 3 | **"Limpar"** em Visitados é ghost sem ícone nem `aria-label` claro |
@ -46,26 +47,31 @@ O dashboard tem ingredientes certos para o Field Desk (fila prioritária, faixa
## Priority Issues
### [P1] Desktop não aproveita largura; ultra-wide deixa vazio à direita
- **Why:** `CampaignPageShell` usa `flex-col gap-8` sem breakpoint grid; `mr-auto max-w-screen-2xl` prende conteúdo à esquerda em monitores largos.
- **Fix:** Reflow `GeneralDashboard` em `lg:grid` (ex.: filas + KPIs à esquerda, mapa sticky + eventos à direita), espelhando `NucleusListOverview`. Considerar `mx-auto` ou remover cap em `xl:` quando houver grid de 2 colunas.
- **Suggested command:** `/impeccable layout`
### [P1] Filas de ação vazias ocupam espaço sem valor
- **Why:** `QueueSection` sempre renderiza; com tudo zerado o usuário lê três mensagens + três badges "0".
- **Fix:** Se todas as filas vazias, um único estado positivo ("Nada pendente — filas em dia"); renderizar subseções só quando `items.length > 0`.
- **Suggested command:** `/impeccable quieter`
### [P2] Escala do mapa sem cores
- **Why:** `choroplethFillColor` implementa gradiente rose→vermelho; UI só mostra números em `ChoroplethMapControls`.
- **Fix:** Barra de legenda com swatches usando o mesmo ramp; rótulos 0 e máximo.
- **Suggested command:** `/impeccable clarify`
### [P2] "Lideranças" sem framing
- **Why:** Linha solta com badges de status + contagem; não fica claro que são contatos de liderança em todos os núcleos por status de apoio.
- **Fix:** Card com título + descrição; link para lista filtrada; tooltip/glossário nos status.
- **Suggested command:** `/impeccable clarify`
### [P2] Visitados recentemente — bullets e botão Limpar
- **Why:** `<ul>` sem `list-none` (padrão do projeto em outras listas); "Limpar" ghost compete com o título sem affordance de ação destrutiva/secundária.
- **Fix:** `list-none m-0 p-0`; botão outline ou ícone + `aria-label="Limpar histórico de visitas"`.
- **Suggested command:** `/impeccable polish`

View file

@ -8,12 +8,13 @@ p3_count: 2
timestamp: 2026-07-20T00-01-43Z
slug: src-app-campaign-campanha
---
Method: dual-agent (A: 22ac0b39-b0f9-4111-b890-4c4125d1218b · B: bacccead-08e1-4dce-9b0d-4fe7984b1853)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | ------------------------------------------------------------------------------------ |
| 1 | Visibility of System Status | 3 | KPIs têm `CampaignDataFreshness`; filas não mostram idade do item na linha |
| 2 | Match System / Real World | 4 | Linguagem de núcleo/fila encaixa; `LeadershipStatusCard` contextualiza status |
| 3 | User Control and Freedom | 4 | `RecentlyVisitedCard` com `aria-label` no limpar; liderança tem CTA no empty |
@ -49,26 +50,31 @@ O dashboard `geral` saiu da pilha vertical ingênua para um desk operacional: fi
## Priority Issues
### [P2] Triagem em lote ainda ausente na fila prioritária
- **Why:** Cada item “Sem coordenador” é um link isolado; 15 núcleos = 15 idas e voltas.
- **Fix:** Multi-select só em `withoutCoordinator` + barra “Atribuir coordenador (N)” reutilizando shell de designação existente.
- **Suggested command:** `/impeccable layout`
### [P2] Jargão de domínio opaco no touch
- **Why:** `SupportStatusBadge`, estimativa confirmada vs proposta, métricas do mapa dependem de `title` (hover) — inútil no celular.
- **Fix:** `CampaignGlossaryHint` com gatilho `?` (`min-h-11`) em badges e labels de KPI.
- **Suggested command:** `/impeccable clarify`
### [P2] Outline de headings pula `h2` em Filas
- **Why:** `h1` → `CardTitle` Filas (não heading) → `h3` nas subfilas → depois `h2` Indicadores — ordem invertida para leitores de tela.
- **Fix:** Promover “Filas de ação” a `h2` semântico (`id="action-queues"` já existe).
- **Suggested command:** `/impeccable audit`
### [P3] Empty do coordenador sem CTA
- **Why:** Texto explica o bloqueio mas não oferece ação (liderança já tem “Ver meu perfil”).
- **Fix:** Espelhar `LeadershipDashboard` empty com `EmptyContent` + CTA.
- **Suggested command:** `/impeccable onboard`
### [P3] Recentes compactos escondem timestamp
- **Why:** Modo `compact` põe idade só em `sr-only`; Alex não distingue visita recente de antiga.
- **Fix:** Mostrar `text-xs text-muted-foreground` truncado no modo compacto.
- **Suggested command:** `/impeccable polish`

View file

@ -8,12 +8,13 @@ p3_count: 1
timestamp: 2026-07-23T21-29-20Z
slug: src-components-campaign-plazalistoverview-tsx
---
Method: dual-agent (A: c0c0346d-83f2-4aa7-b728-c883635b063e · B: 104c4dcf-5d13-4351-b6fe-db8d2d632179)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | ----------------------------------------------------------------------------------------- |
| 1 | Visibility of System Status | 2 | Número muda com filtros, mas nunca diz que é o cenário Média; mapa pode divergir |
| 2 | Match System / Real World | 2 | “Faixa” e “no conjunto” não nomeiam pessimista–otimista nem o recorte filtrado |
| 3 | User Control and Freedom | 3 | Agregado read-only correto; filtros controlam o conjunto; sem seletor de cenário no strip |
@ -36,7 +37,7 @@ Method: dual-agent (A: c0c0346d-83f2-4aa7-b728-c883635b063e · B: 104c4dcf-5d13-
## Overall Impression
O strip está certo em *estrutura* A10 (média grande + faixa secundária, não três KPIs) e no lugar certo (acima da tabela, no recorte filtrado). Falha em *rotular a semântica*: o número hero não diz “Média”, “Faixa” não diz pessimista–otimista, e o mapa acima pode mostrar outro cenário sem o strip confessar o lock em `central`. Em reunião, Marcos não sabe se está olhando chão, caso-base ou teto.
O strip está certo em _estrutura_ A10 (média grande + faixa secundária, não três KPIs) e no lugar certo (acima da tabela, no recorte filtrado). Falha em _rotular a semântica_: o número hero não diz “Média”, “Faixa” não diz pessimista–otimista, e o mapa acima pode mostrar outro cenário sem o strip confessar o lock em `central`. Em reunião, Marcos não sabe se está olhando chão, caso-base ou teto.
## What's Working
@ -47,31 +48,37 @@ O strip está certo em *estrutura* A10 (média grande + faixa secundária, não
## Priority Issues
### [P1] Número grande sem rótulo “Média”
- **Why:** Default A10 é `central`; a UI só diz “Votos estimados no conjunto”. Coordenação pergunta “isso é o otimista?”.
- **Fix:** Label ou detail explícito: “Média” / “Cenário média (padrão)”.
- **Suggested command:** `/impeccable clarify`
### [P1] “Faixa X–Y” sem pessimista–otimista
- **Why:** Pode ser CI, meta ou goals; conflita com vocabulário A10 já nos forms/mapa.
- **Fix:** “Pessimista X · Otimista Y” ou mini `VoteEstimateScenarioStrip` endpoints.
- **Suggested command:** `/impeccable clarify`
### [P1] Mapa Cenário vs strip dessincronizado
- **Why:** Mapa (acima) troca P/M/O; strip fica em central sem cue — duas verdades na mesma scroll.
- **Fix:** Âncora explícita (“sempre média”) **ou** strip segue o cenário do mapa (estado local compartilhado).
- **Suggested command:** `/impeccable layout`
### [P2] `sem estimativa` enterrado em Declarações
- **Why:** Qualidade do hero number some no meio da célula; dashboard já tem célula própria.
- **Fix:** Footnote sob o total ou alinhar layout ao dashboard.
- **Suggested command:** `/impeccable layout`
### [P2] “No conjunto” subespecificado
- **Why:** Não diz “Praças filtradas”; fácil ler como estadual/escopo total.
- **Fix:** “Média nas Praças filtradas” (resolve P1+P2 de naming).
- **Suggested command:** `/impeccable clarify`
### [P3] Linguagem visual ≠ linhas da tabela
- **Why:** Linhas usam ScenarioStrip; overview só tipografia.
- **Fix:** Strip compacto endpoints no detail.
- **Suggested command:** `/impeccable polish`

View file

@ -8,12 +8,13 @@ p3_count: 1
timestamp: 2026-07-23T21-52-39Z
slug: src-components-campaign-plazamappanel-tsx-cenario
---
Method: dual-agent (A: c3613972-8d1f-41bb-b210-501b0ddc14c9 · B: 8ae72814-5de3-4010-9ebb-7c9d64d8ccec)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | ----------------------------------------------------------------------------------- |
| 1 | Visibility of System Status | 1 | Ano default 2022: mudar Cenário não pinta o mapa; efeito no overview abaixo do fold |
| 2 | Match System / Real World | 1 | “overview” / “preenchimento” ≠ linguagem de campo; toolbar diz “filtro do mapa” |
| 3 | User Control and Freedom | 3 | Três opções reversíveis; baixo risco |
@ -47,26 +48,31 @@ A10 labels e sync via context estão certos. O bug de UX é estrutural: **Cenár
## Priority Issues
### [P1] Assento na toolbar do mapa implica choropleth quando Ano≠2026
- **Why:** First paint comum: muda Cenário → mapa igual → sensação de broken.
- **Fix:** Relocar ao overview; **ou** desabilitar/ghost + explicar; **ou** Cenário força Ano→2026; **ou** esconder no mapa quando ≠2026 *e* hospedar o controle onde o efeito é visível.
- **Fix:** Relocar ao overview; **ou** desabilitar/ghost + explicar; **ou** Cenário força Ano→2026; **ou** esconder no mapa quando ≠2026 _e_ hospedar o controle onde o efeito é visível.
- **Suggested command:** `/impeccable layout` (ou `/impeccable shape`)
### [P1] Disclaimer em português de engenheiro
- **Why:** “overview” não está na UI; “preenchimento” é jargão de mapa.
- **Fix:** Uma linha de resultado (“Muda os totais acima da lista. No mapa, só com Ano 2026.”) — ou matar a linha relocando o controle.
- **Suggested command:** `/impeccable clarify` / `/impeccable distill`
### [P2] Feel the action: efeito fora do viewport
- **Why:** Mapa acima; overview/lista abaixo. Em mobile parece no-op.
- **Fix:** Highlight/pending no overview; ou co-localizar o controle.
- **Suggested command:** `/impeccable animate` + layout
### [P2] Always-show na fila do mapa vs hide
- **Why:** Sync global é racional; *nesta* fila treina o affordance errado.
- **Why:** Sync global é racional; _nesta_ fila treina o affordance errado.
- **Fix:** Não esconder sem relocação; se global, não vestir como filtro do mapa.
- **Suggested command:** `/impeccable shape`
### [P3] Densidade Ano / Escala / Cenário / Comparar
- **Why:** Quarto peer + disclaimer wrap em narrow (Casey).
- **Suggested command:** `/impeccable quieter`

View file

@ -6,6 +6,7 @@ p1_count: 2
timestamp: 2026-07-24T20-30-52Z
slug: a11-ranking-votos
---
# Critique — A11 posição em votos
**Alvos:** `MunicipalityBaselineCard`, `MunicipalityList`, `MunicipalitySortableHead`, `MunicipalityFilters`
@ -14,7 +15,7 @@ slug: a11-ranking-votos
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | -------------------------------------------------------------------------- |
| 1 | Visibility of System Status | 3 | Pending/dim ok; sort active mostly chevron — no “ordenado por…” summary |
| 2 | Match System / Real World | 2 | “% da votação” unexplained on list; “Praça” leftovers; seed empty-state |
| 3 | User Control and Freedom | 3 | Limpar is all-or-nothing (sort resets only with full clear) |
@ -48,26 +49,31 @@ Default sort by concentration is the right product move. The biggest opportunity
## Priority Issues
### [P1] Label / metric / sort vocabulary collision
- **Why:** Under pressure, header “Votos 2022” vs cell “% da votação” vs mobile Ordenar invites wrong comparisons.
- **Fix:** One shared term (e.g. Concentração 2022) across column, Ordenar, and primary readout; secondary = votos · Nº de 435.
- **Suggested command:** `/impeccable clarify`
### [P1] Dual-% trap still live on list → detail path
- **Why:** List shows unexplained %; detail introduces local % of válidos — disclaimer one click too late.
- **Fix:** Column hint/caption: “% da votação estadual do candidato”; keep local dominance secondary.
- **Suggested command:** `/impeccable clarify`
### [P2] Card vs table VotePositionReadout hierarchy diverge
- **Why:** Same data, different story (table prioritizes share; card is middot soup).
- **Fix:** Same stack both layouts: concentration → votos · rank.
- **Suggested command:** `/impeccable distill`
### [P2] “Praça” + engineer empty state
- **Why:** Remodel is Município; “Rode o seed…” fails closed for staff.
- **Fix:** Município language; staff-safe empty (“Sem baseline TSE neste ambiente”).
- **Suggested command:** `/impeccable harden`
### [P3] Active sort under-announced for sighted scan
- **Why:** Chevron alone is easy to miss in dense headers.
- **Fix:** Active weight + optional “Ordenado por concentração 2022…”.
- **Suggested command:** `/impeccable polish`

View file

@ -6,6 +6,7 @@ p1_count: 2
timestamp: 2026-07-24T20-48-41Z
slug: a11-ranking-votos
---
# Critique — A11 posição em votos (pós-polish)
**Alvos:** BaselineCard, List, SortableHead, Filters
@ -14,7 +15,7 @@ slug: a11-ranking-votos
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | ----------------------------------------------------- |
| 1 | Visibility of System Status | 3 | Sort summary ok; dormant chevrons faint |
| 2 | Match System / Real World | 3 | Concentração unified; Praças leftovers in visit/scope |
| 3 | User Control and Freedom | 3 | Limpar nuclear |
@ -46,18 +47,23 @@ Vocabulary polish worked (+4 vs 25). Next jump is density: definitions once on t
## Priority Issues
### [P1] Per-cell “da votação estadual” (owner)
Cell = bare %. Explanation on Concentração header tooltip. → `/impeccable distill`
### [P1] “de 435” per row (owner)
Rank = `12º` only; catalog size once in header tooltip. → `/impeccable distill`
### [P2] Sort summary copy-heavy
Short status only; definition → header. → `/impeccable quieter` / polish
### [P2] Baseline dual-% fork
Demote local dominance. → `/impeccable layout`
### [P3] Dormant chevrons + Praças leftovers
→ `/impeccable polish` / clarify
## Persona Red Flags

View file

@ -6,6 +6,7 @@ p1_count: 1
timestamp: 2026-07-24T21-01-02Z
slug: a11-ranking-votos
---
# Critique — A11 (pós density distill)
**Data:** 2026-07-24
@ -14,7 +15,7 @@ slug: a11-ranking-votos
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | ------------------------------- |
| 1 | Visibility of System Status | 3 | Sort status solid |
| 2 | Match System / Real World | 3 | Concentração vocabulary ok |
| 3 | User Control and Freedom | 3 | Limpar nuclear |
@ -44,13 +45,16 @@ Cells landed. Open P1: remove CampaignInfoHint; explanation on header hover/focu
## Priority Issues
### [P1] Owner — no ? button; hover header (validated)
Remove hint chrome; Tooltip on sortable Concentração head. Mobile: no orphan ?. Baseline: same rule.
→ `/impeccable distill`
### [P2] Limpar nukes sort
→ `/impeccable harden`
### [P3] Dormant chevrons after ? dies
→ `/impeccable polish`
## Personas

View file

@ -6,14 +6,15 @@ p1_count: 3
timestamp: 2026-07-25T01-04-34Z
slug: omponents-campaign-municipalitygoalaccountcard-tsx
---
Method: dual-agent (A: 92b63fbf-f200-4a00-9cc5-81db3f317b57 · B: 35312920-2be2-4bac-92aa-0fbb62c2a571)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ----------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Visibility of System Status | 3 | Coverage %, deficit text, and provenance ("estimativa da mesa") are all stated plainly; the 0%-coverage `Progress` bar is a near-invisible 4px hairline, understating urgency at the most common early-campaign state. |
| 2 | Match Between System and Real World | 2 | "Teto do campo," "share intracampo," "roll-off," "campo" are coined composites — confirmed by the product's own coordinator calling these "conceitos complicados" *after* this exact tooltip fix shipped (`docs/plans/documentacao-conceitos-campanha.md`). |
| 2 | Match Between System and Real World | 2 | "Teto do campo," "share intracampo," "roll-off," "campo" are coined composites — confirmed by the product's own coordinator calling these "conceitos complicados" _after_ this exact tooltip fix shipped (`docs/plans/documentacao-conceitos-campanha.md`). |
| 3 | User Control and Freedom | 3 | Popover/tooltip dismiss cleanly (Escape, click-outside, blur); no traps. |
| 4 | Consistency and Standards | 2 | Two disclosure idioms collide in one card: `CampaignInfoHint` opens-on-click-and-stays-open; the metric `Tooltip` closes-on-click and never opens on click. A user primed by the "?" icon one line above will click a metric next and get nothing, or dismiss an open tooltip. |
| 5 | Error Prevention | 3 | n/a mostly (read-only diagnostics); sane zero/negative fallbacks ("Sem meta definida," "Sem teto do campo projetado..."). |
@ -21,7 +22,7 @@ Method: dual-agent (A: 92b63fbf-f200-4a00-9cc5-81db3f317b57 · B: 35312920-2be2-
| 7 | Flexibility and Efficiency of Use | 3 | `Tab` reaches every metric and opens its tooltip on focus with a visible ring — solid keyboard path, measured live. |
| 8 | Aesthetic and Minimalist Design | 3 | Restrained, on-brand, correctly dense for a staff surface; undermined live by the tooltip/popover collision covering sibling content (screenshotted at both desktop and 390px). |
| 9 | Error Recovery | 3 | n/a — no error states exercised; fallback copy is clear and non-technical. |
| 10 | Help and Documentation | 2 | Good *density* of contextual help (card popover + 4 inline tooltips), not yet *effective* — field evidence says the explanations didn't resolve confusion, and there's no link to deeper docs yet (that's roadmap E18). |
| 10 | Help and Documentation | 2 | Good _density_ of contextual help (card popover + 4 inline tooltips), not yet _effective_ — field evidence says the explanations didn't resolve confusion, and there's no link to deeper docs yet (that's roadmap E18). |
| **Total** | | **26/40** | **Acceptable — significant improvements needed before users are happy** |
## Anti-Patterns Verdict
@ -45,30 +46,35 @@ The card itself is well-crafted and on-brand — restrained, honest about data p
## Priority Issues
**[P1] Touch users cannot open the metric tooltips at all.**
- **Why it matters**: PRODUCT.md names "used in the field and on the go" as this surface's defining context, and this exact follow-up exists for comprehension. It's unreachable on the device class that context implies.
- **What/evidence**: `@radix-ui/react-tooltip@1.2.8`'s `TooltipTrigger` explicitly ignores touch pointer events and gates focus-open on a pointer-down ref that a tap sets first — confirmed against the installed source, not inference. At 390×844, no tap affordance exists for any of the 4 metrics; the adjacent "Editar votos estimados" control works fine at the same width, so this reads as an oversight, not a deliberate mobile choice.
- **Fix**: Add an explicit tap trigger — drop `asChild` for a focusable/clickable element with its own `onClick` toggling open, or give each metric a small click-openable affordance like `CampaignInfoHint`'s.
- **Suggested command**: `/impeccable harden`
**[P1] Tooltips render on top of the number/coverage block they explain.**
- **Why it matters**: The moment someone seeks clarification is the moment the clarified figure disappears — a direct "one thing at a time" violation at the card's highest-stakes row.
- **What/evidence**: Reproduced independently by both assessments via hover, real keyboard focus, and screenshots at 1280×720 and 390×844 — the bubble (Radix collision-flips to `data-side="top"` since the trigger sits near the viewport bottom) fully covers "Cobertura da meta" / the progress bar / "Faltam X votos para a meta" at both widths.
- **Fix**: Force collision-safe placement for this usage (`side="top"` with generous `sideOffset` plus bottom padding, or reorder the diagnostic row above the coverage block so there's room for the tooltip to open without overlap).
- **Suggested command**: `/impeccable layout`
**[P1] Tooltip/popover copy is dense, cross-referential jargon — and the real user already said it didn't help.**
- **Why it matters**: `docs/plans/documentacao-conceitos-campanha.md` quotes the actual coordinator calling these "conceitos complicados" *after* these exact tooltips shipped — the fix under critique didn't fix the problem it was built to fix.
- **Why it matters**: `docs/plans/documentacao-conceitos-campanha.md` quotes the actual coordinator calling these "conceitos complicados" _after_ these exact tooltips shipped — the fix under critique didn't fix the problem it was built to fix.
- **What/evidence**: e.g. the "Teto do campo" explanation is one ~40-word sentence with three technical terms and no break; "Share intracampo"'s explanation requires recalling "Captura"'s definition mid-read, but the two tooltips can never be open at the same time (opening one closes the other) — a genuine working-memory violation.
- **Fix**: Split each explanation into a short lead sentence (what it measures) + a visually secondary formula line; bold only the one term each tooltip defines; stop cross-referencing metrics that can't be open simultaneously — state the difference inline instead of by reference.
- **Suggested command**: `/impeccable clarify`
**[P2] Two incompatible disclosure idioms coexist in one card.**
- **Why it matters**: Breaking Consistency *within* a single component teaches the wrong mental model in real time — a user who just learned "click the small icon" naturally tries clicking a metric next and gets nothing or an unwanted dismissal.
- **Why it matters**: Breaking Consistency _within_ a single component teaches the wrong mental model in real time — a user who just learned "click the small icon" naturally tries clicking a metric next and gets nothing or an unwanted dismissal.
- **What/evidence**: `CampaignInfoHint` = click-to-open-and-stay-open Popover; `GoalAccountMetric` = hover/focus Tooltip that closes on click (Radix default), never opens on click.
- **Fix**: Pick one idiom for this card, or make the "click for detail" vs. "hover/focus for definition" visual distinction stronger than dotted-underline vs. circle-icon alone.
- **Suggested command**: `/impeccable clarify`
**[P3] The 0%-coverage progress bar is nearly invisible.**
- **Why it matters**: Understates urgency exactly when it should read loudest (0% coverage is the most common early-campaign state).
- **Fix**: Give the near-zero state deliberate visual weight instead of relying solely on the adjacent "0%" text.
- **Suggested command**: `/impeccable polish`
@ -91,6 +97,6 @@ The card itself is well-crafted and on-brand — restrained, honest about data p
## Questions to Consider
- If the coordinator needed a *separate* documentation surface (E18) days after these exact tooltips shipped specifically because they didn't resolve the confusion, is hover/focus disclosure the right mechanism for this density of explanation at all — or does the card need one worked example inline before a glossary page ever ships?
- If the coordinator needed a _separate_ documentation surface (E18) days after these exact tooltips shipped specifically because they didn't resolve the confusion, is hover/focus disclosure the right mechanism for this density of explanation at all — or does the card need one worked example inline before a glossary page ever ships?
- "Captura" and "Share intracampo" exist as two numbers whose entire tooltip copy is dedicated to explaining how they differ from each other — should this be one metric with a denominator toggle instead of two numbers that each require reading the other's footnote?
- As E9–E17 add more diagnostic metrics to this row, does the card need a settled disclosure model *now* — before there are 8 metrics fighting over two incompatible idioms instead of 4?
- As E9–E17 add more diagnostic metrics to this row, does the card need a settled disclosure model _now_ — before there are 8 metrics fighting over two incompatible idioms instead of 4?

View file

@ -6,6 +6,7 @@ p1_count: 3
timestamp: 2026-07-25T02-37-03Z
slug: src-components-campaign-municipalitylist-tsx
---
# Critique — B16 Municipality Header Filters
Method: dual-agent (A: 8cdcb249-95fe-4f3d-8937-081b10b442bc · B: c641769d-fc5c-4a83-9442-4329cff5cf96)
@ -13,7 +14,7 @@ Method: dual-agent (A: 8cdcb249-95fe-4f3d-8937-081b10b442bc · B: c641769d-fc5c-
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| --------- | ------------------------------- | --------- | ------------------------------------------------------- |
| 1 | Visibility of System Status | 2 | Control lag until RSC (fixed post-critique: optimistic) |
| 2 | Match System / Real World | 3 | Priority on Município column |
| 3 | User Control and Freedom | 3 | Limpar + Todos clear |

View file

@ -1,8 +1,5 @@
{
"files": [
"src/app/(frontend)/layout.tsx",
"src/app/(campaign)/layout.tsx"
],
"files": ["src/app/(frontend)/layout.tsx", "src/app/(campaign)/layout.tsx"],
"insertBefore": "</body>",
"commentSyntax": "jsx",
"cspChecked": true

32
.prettierignore Normal file
View file

@ -0,0 +1,32 @@
# Build outputs and tool caches
.next/
out/
build/
playwright-report/
test-results/
data/
pnpm-lock.yaml
# Generated files (owned by their generators, not by Prettier)
src/payload-types.ts
src/app/(payload)/admin/importMap.js
# Frozen history — migrations are never edited retroactively
src/migrations/
# Committed data artifacts (rebuilt by scripts; pretty-printing would explode them
# and the next regeneration would revert it)
src/lib/geometries/
src/lib/electionAggregates/
src/lib/bahiaMunicipalityDemographics.ts
src/lib/bahiaTseCityCodes.ts
src/lib/bahiaMunicipalityCodes.ts
src/lib/bahiaTseZones.ts
tests/fixtures/
output/
# Static assets and vendored agent material
public/
.agents/
.cursor/
docs/sheets/

View file

@ -2,5 +2,6 @@
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
"semi": false,
"plugins": ["prettier-plugin-organize-imports"]
}

View file

@ -1,11 +1,7 @@
{
"languages": {
"CSS": {
"language_servers": [
"tailwindcss-intellisense-css",
"!vscode-css-language-server",
"..."
]
"language_servers": ["tailwindcss-intellisense-css", "!vscode-css-language-server", "..."]
}
}
}

View file

@ -127,23 +127,28 @@ flowchart TD
Todas as cinco fases foram implementadas e validadas localmente (`tsc --noEmit`, `pnpm lint`, `pnpm test:int`, `pnpm test:unit`) contra `teqo_test`. Sem regressões nas rotas de núcleos/apoiadores/planos já entregues.
### Fase 1 — UX alinhada
- `SupporterForm` agora usa `StrictCombobox` + `municipalityComboboxOptions()` no município (validação continua em `optionalBahiaCity`).
- `RemoveSupporterDataButton` trocou `window.confirm` por `AlertDialog` no padrão de `ArchiveNucleusDialog.tsx`.
### Fase 2 — DRY com núcleos / leadership
- `src/utilities/campaignListUrl.ts` (novo): `firstValue`, `normalizedText`, `strictDecimalInteger`, `inspectRawListParams`, `resolveListUrl`, `buildListHref`. `nucleusUi.ts`, `supporterUi.ts` e `actionPlanUi.ts` delegam para ele (domínio permanece nos módulos de domínio).
- `src/components/campaign/CampaignListPagination.tsx` (novo) + `getPaginationPages`: substitui `NucleusPagination`, `SupporterPagination` e `ActionPlanPagination` (os três foram removidos). Listagens de núcleos/apoiadores/planos e o teste unitário `campaignNucleusUi` apontam para o novo componente.
- `src/utilities/campaignFormFields.ts` (novo): `fieldError` / `errorProps` compartilhados por `SupporterForm` e `LeadershipForm` (cada form liga um `errorProps` local ao seu prefixo de id).
- `src/utilities/campaignFormActionError.ts` (novo): `mapCampaignFormActionError` consolida FormDataBoundaryError → Zod → safelist → genérico. `apoiadores/novo/formActions.ts`, `apoiadores/[id]/formActions.ts` e `nucleos/[slug]/leadershipFormActions.ts` usam o mapper.
### Fase 3 — KPI da lista
- `src/utilities/supporterListOverviewAggregate.ts` (novo): `computeSupporterListOverviewAggregate` faz um único `SELECT COUNT(*) FILTER (...)` via drizzle na sessão da txn Payload, espelhando `buildSupporterListWhere` + a constraint de acesso (`nucleus_id IN (...)` para coordenador, sem constraint para geral, `none` para liderança). `loadSupporterListOverviewData` passou de 3× `payload.count` para um round-trip. Testes int cobrem geral, coordenador (escopo + filtro de cidade) e base vazia.
### Fase 4 — Import em escala
- `src/utilities/supporterImportBulk.ts` (novo): `bulkInsertSupporterImport` insere contacts e supporters via `payload.db.drizzle` na sessão da txn Payload (`getPostgresTransactionDatabase`), em chunks de 500, com `ON CONFLICT DO NOTHING` no índice unique `(contact_id, nucleus_id)`. Substitui até 10k `payload.create` por poucos inserts bulk.
- `src/collections/Contact.ts`: `enforceUniqueContactPhone` honra `context.skipContactPhoneInvariant` **fail-closed** — só respeita quando `req.transactionID` está ativo (sem txn, os locks xact-level não podem estar segurados → throw). `upsertContactByPhone` passa o context (o caller já segurou os locks), eliminando o re-lock/re-check redundante do path single-create.
### Fase 5 — Preview sem round-trip
- `src/collections/SupporterImportBatch.ts` (novo, admin hidden, acesso `geral`/admin) + migration `20260719_011015_add_supporter_import_batch`: staging efêmero do conjunto `ok` no servidor.
- `src/utilities/supporterImportToken.ts` (novo): token HMAC-SHA256(`${batchId}.${actorID}.${expiresAt}`, PAYLOAD_SECRET) com `timingSafeEqual` e TTL 10 min. `previewSupporterImportText` agora retorna `{ counts, sampleRows (100), errorReportCsv, importToken }` e estageia o conjunto `ok` em `supporterImportBatch`. `confirmSupporterImportRecord` aceita `importToken` (schema trocou `rows` por `importToken`), verifica HMAC + ator + expiração, consome o lote (single-use, delete após commit) e alimenta o bulk insert. O wizard parou de guardar/reenviar as rows.
- Testes int: fluxo preview→confirm; token tampered (assinatura alterada) rejeitado; token de outro ator rejeitado; reuso de token consumido rejeitado.

View file

@ -81,7 +81,7 @@ Hierarquia TSE: município → (1+) zona → (muitas) seção; a relação munic
## Entrega as-built (B2, 2026-07-18)
| Artefato | Caminho | Notas |
| -------- | ------- | ----- |
| --------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| TopoJSON municípios | `src/lib/geometries/bahia-municipalities.topo.json` | ~132 KB; 417 features; object `municipalities` |
| TopoJSON TIs | `src/lib/geometries/bahia-identity-territories.topo.json` | ~15 KB; 27 features; object `territories` (dissolução IBGE) |
| Tabela nome→`codarea` | `src/lib/bahiaMunicipalityCodes.ts` | + `codeForMunicipality` / `municipalityForCode` |

View file

@ -8,6 +8,7 @@ Appetite: ~0,5–1 dia eng; sem migration, sem collection, sem server action nov
Responsável: —
Revisões:
- 2026-07-24 (entrega + polish pós-critique): helper `municipalityVoteRank`; baseline + coluna lista; default da lista = `votos` desc (omitido na URL) — **substituído pelo default `deficit` desc do E9 ✓ no mesmo dia**, `?sort=votos` continua disponível; `?sort=name` para alfabético; share lidera a célula; rank no mobile; % dos válidos demoted. B15 amplia keys restantes.
- 2026-07-24 (auditoria pre-implementação): B15 ainda ausente no código — esta entrega introduz `sort`/`dir` mínimo só para `name`|`votos` (fallback do plano); B15 amplia as demais keys depois. Seção Dados → decisão → apresentação preenchida. Questões em aberto fechadas: lista só 2022; sem delta de rank 2018→2022 na v1.

View file

@ -41,7 +41,7 @@ Gate: W0 ([pinagem-superficies-lista.md](pinagem-superficies-lista.md)) verde
## Rabbit holes
- **Generalizar `MunicipalityHeaderFilter` inteiro no D1.** As facetas de município (435 opções + busca) são as mais complexas; a generalização é a *interface* (filterDef + slot), a implementação rica continua específica até um segundo consumidor pedir.
- **Generalizar `MunicipalityHeaderFilter` inteiro no D1.** As facetas de município (435 opções + busca) são as mais complexas; a generalização é a _interface_ (filterDef + slot), a implementação rica continua específica até um segundo consumidor pedir.
- **DataTable do shadcn como base.** Consultar composição, mas a casa já tem `ui/Table` + DESIGN.md; introduzir TanStack Table trocaria o modelo server-first por client state — não.
- **Mexer na semântica de URL "aproveitando".** Congelada (B18 depende dela).

View file

@ -8,6 +8,7 @@ Appetite: ~1 dia eng; sem migration, sem collection
Responsável: —
### Revisão 2026-07-24 (auditoria → implementação)
- **A11 ausente confirmado:** `municipalityVoteRank.ts` não existe no repo → `% da própria votação` calculado localmente em `computeTerritoryRollup` (Σ TI ÷ Σ estadual 2022). A11 unifica o helper de share quando entrar.
- **Access:** loader `loadTerritoryOverview` usa `overrideAccess: true` (a `canReadMunicipality` do advisor escopa por `advisors contains user.id`) — exposição é agregado TI-level (somas/contagens), nunca PII por município; advisor vê a tabela completa (leitura regional é contexto). Confirmado contra o plano.
- **Split client/server:** o rollup puro ficou em `territoryOverview.ts` (client-safe, sem `server-only`) para a tabela client importar `sortTerritoryRows`/tipos; o loader server-only foi para `loadTerritoryOverview.ts`.

View file

@ -6,6 +6,7 @@ Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A → A2)
Responsável: —
Revisões:
- 2026-07-18 (entrega): cadastro estático `bahiaTseZones` a partir do TSE 2024 `detalhe_votacao_munzona` BA (SHA-256 no cabeçalho); motor `territorySuggestions` + chips opt-in no coordenador `NucleusTerritoryAndZonesFields`; sem migration. Corrigido drift factual: a validação server-side existente chama-se `validateTerritoryAndZones` (não `validateGeographyAndZones`). Reuso de `canonicalizeMunicipalityName` / aliases de A3 na geração do dataset.
- 2026-07-18 (as-built / pós-simplify): `TseZoneInput` só controlado; `NucleusTerritoryFields` removido (só `NucleusTerritoryAndZonesFields`); `buildTerritorySuggestions` devolve também `outsideZones`; helpers de membership `isTseZoneOf*` removidos (usar `.includes` nos arrays); caches módulo `citiesByZone` / `zonesByTerritory`; chips com props discriminadas `kind: 'zone'|'city'`; teto UI de 8 + “+N sugestões”.

View file

@ -15,13 +15,7 @@
"server-only": ["tests/helpers/serverOnly.ts"]
},
"ignoreBinaries": [],
"ignoreDependencies": [
"tailwindcss-animate",
"tw-animate-css",
"shadcn",
"eslint-config-next",
"prettier"
],
"ignoreDependencies": ["tailwindcss-animate", "tw-animate-css", "shadcn", "eslint-config-next"],
"rules": {
"exports": "error",
"types": "error",

View file

@ -35,6 +35,9 @@
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check:cycles": "madge --circular --extensions ts,tsx --ts-config tsconfig.json src",
"generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:types",
"migrate": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate",
@ -119,9 +122,11 @@
"husky": "^9.1.7",
"jsdom": "28.0.0",
"knip": "^6.27.0",
"madge": "^8.0.0",
"pg": "^8.22.0",
"postcss": "^8.5.6",
"prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.3.0",
"schema-dts": "^2.0.0",
"tailwindcss": "^4.1.18",
"topojson-server": "^3.0.1",

File diff suppressed because it is too large Load diff

View file

@ -132,8 +132,7 @@ const buildDemographicsByCode = ({ ageCategoryRows, femaleRows, medianRows }) =>
for (const record of byCode.values()) {
record.population = Object.values(record.ageBands).reduce((sum, value) => sum + value, 0)
record.sexShareFemale =
record.population > 0 ? record.femalePopulation / record.population : 0
record.sexShareFemale = record.population > 0 ? record.femalePopulation / record.population : 0
delete record.femalePopulation
}
@ -141,14 +140,15 @@ const buildDemographicsByCode = ({ ageCategoryRows, femaleRows, medianRows }) =>
}
const buildModuleSource = (demographicsByCode, provenance) => {
const entries = [...demographicsByCode.entries()].sort(([left], [right]) => left.localeCompare(right))
const entries = [...demographicsByCode.entries()].sort(([left], [right]) =>
left.localeCompare(right),
)
const lines = entries
.map(([code, record]) => {
const bands = Object.entries(record.ageBands)
.map(([band, value]) => `${JSON.stringify(band)}: ${value}`)
.join(', ')
const median =
record.medianAge == null ? 'null' : JSON.stringify(record.medianAge)
const median = record.medianAge == null ? 'null' : JSON.stringify(record.medianAge)
const sexShare = record.sexShareFemale.toFixed(6)
return ` ${JSON.stringify(code)}: { population: ${record.population}, ageBands: { ${bands} }, sexShareFemale: ${sexShare}, medianAge: ${median} },`
})

View file

@ -22,20 +22,18 @@
*/
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile, access } from 'node:fs/promises'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { topology } from 'topojson-server'
import { merge, quantize } from 'topojson-client'
import { presimplify, simplify, quantile } from 'topojson-simplify'
import { topology } from 'topojson-server'
import { presimplify, quantile, simplify } from 'topojson-simplify'
const { canonicalizeMunicipalityName, UnknownMunicipalityError } = await import(
'../src/lib/electionResults.ts'
)
const { bahiaMunicipalities, bahiaIdentityTerritoryRecords } = await import(
'../src/lib/bahiaTerritories.ts'
)
const { canonicalizeMunicipalityName, UnknownMunicipalityError } =
await import('../src/lib/electionResults.ts')
const { bahiaMunicipalities, bahiaIdentityTerritoryRecords } =
await import('../src/lib/bahiaTerritories.ts')
const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
@ -224,7 +222,8 @@ const main = async () => {
const memberGeometries = territory.municipalities.map((name) => {
const code = codeByCanonical.get(name)
const geometry = geometriesByCodarea.get(code)
if (!geometry) die(`Missing municipality geometry for ${name} (${code}) in territory ${territory.code}`)
if (!geometry)
die(`Missing municipality geometry for ${name} (${code}) in territory ${territory.code}`)
return geometry
})
return {

View file

@ -16,13 +16,12 @@
*/
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile, access } from 'node:fs/promises'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const { canonicalizeMunicipalityName, UnknownMunicipalityError } = await import(
'../src/lib/electionResults.ts'
)
const { canonicalizeMunicipalityName, UnknownMunicipalityError } =
await import('../src/lib/electionResults.ts')
const { bahiaMunicipalities } = await import('../src/lib/bahiaTerritories.ts')
const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts')
const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.ts')
@ -160,7 +159,9 @@ const main = async () => {
const missing = bahiaMunicipalities.filter((city) => !codeByCanonical.has(city))
if (missing.length > 0) {
die(`Missing TSE city codes for: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? '…' : ''}`)
die(
`Missing TSE city codes for: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? '…' : ''}`,
)
}
const accessed = new Date().toISOString().slice(0, 10)

View file

@ -27,10 +27,10 @@
* writes only src/lib/electionAggregates/.
*/
import { config as loadEnv } from 'dotenv'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { config as loadEnv } from 'dotenv'
import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs'
@ -42,9 +42,8 @@ const payloadConfig = (await import('../src/payload.config.ts')).default
const { BASELINE_TICKET_2022, ELECTION_YEAR_2022, FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } =
await import('../src/lib/electionResults.ts')
const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts')
const { municipalityElectionGeography } = await import(
'../src/utilities/municipalityElectionGeography.ts'
)
const { municipalityElectionGeography } =
await import('../src/utilities/municipalityElectionGeography.ts')
const {
loadCampoFederalVotesByCityZone,
loadCandidateVotesByCityZone,
@ -84,7 +83,11 @@ for (const year of HISTORICAL_SERIES_YEARS) {
loadCandidateVotesByCityZone(payload, cliReader, { year, candidateNumber }),
loadValidVotesByCityZone(payload, cliReader, { year }),
loadCampoFederalVotesByCityZone(payload, cliReader, { year }),
loadOfficeTallyByCityZone(payload, cliReader, { year, office: FEDERAL_DEPUTY_OFFICE, turn: '1' }),
loadOfficeTallyByCityZone(payload, cliReader, {
year,
office: FEDERAL_DEPUTY_OFFICE,
turn: '1',
}),
])
if (candidateVotes.size === 0) {
@ -119,8 +122,16 @@ for (const year of HISTORICAL_SERIES_YEARS) {
// 2022-only: majoritarian (presidente/governador #13) votes + tally, used for
// "teto do campo" and roll-off — no majoritária seeded for 2014/2018 (E8 audit).
const majoritarianOffices = [
{ key: 'president', office: 'presidente', candidateNumber: BASELINE_TICKET_2022.president.candidateNumber },
{ key: 'governor', office: 'governador', candidateNumber: BASELINE_TICKET_2022.governor.candidateNumber },
{
key: 'president',
office: 'presidente',
candidateNumber: BASELINE_TICKET_2022.president.candidateNumber,
},
{
key: 'governor',
office: 'governador',
candidateNumber: BASELINE_TICKET_2022.governor.candidateNumber,
},
]
const majoritarianByOffice = {}

View file

@ -274,8 +274,13 @@ const { canonicalizeMunicipalityName, UnknownMunicipalityError, normalizeMunicip
const { municipalityCatalogEntriesForCity } = await import('../src/lib/municipalityCatalog.ts')
const { getMunicipalityFederalBaseline } = await import('../src/lib/bahiaElectionAggregates.ts')
const { territoryForCity } = await import('../src/lib/bahiaTerritories.ts')
const { parseExpectationCell, mapSheetPriority, parseSheetNumber, parseSituationCell, splitNameCell } =
await import('../src/lib/projectionSheetParse.ts')
const {
parseExpectationCell,
mapSheetPriority,
parseSheetNumber,
parseSituationCell,
splitNameCell,
} = await import('../src/lib/projectionSheetParse.ts')
const {
getVoteEstimateOrderViolation,
toVoteEstimateScenarioViewModel,
@ -988,8 +993,11 @@ if (staffPlans.size > 0) {
const status = plan.existing
? `casa com usuário existente #${plan.existing.id} (${plan.existing.name}, ${plan.existing.role})`
: `criar (papel ${plan.role}, sem credenciais)`
const variants = plan.entry.variants.size > 1 ? ` · variantes: ${formatVariants(plan.entry)}` : ''
console.log(` ${plan.name}: ${status} · ${plan.entry.municipalityIds.size} município(s)${variants}`)
const variants =
plan.entry.variants.size > 1 ? ` · variantes: ${formatVariants(plan.entry)}` : ''
console.log(
` ${plan.name}: ${status} · ${plan.entry.municipalityIds.size} município(s)${variants}`,
)
}
}
@ -997,8 +1005,11 @@ if (deputyPlans.size > 0) {
console.log('\n— Dobradinhas (deputados estaduais) —')
for (const plan of [...deputyPlans.values()].sort((a, b) => a.name.localeCompare(b.name))) {
const status = plan.existing ? `casa com dobradinha existente #${plan.existing.id}` : 'criar'
const variants = plan.entry.variants.size > 1 ? ` · variantes: ${formatVariants(plan.entry)}` : ''
console.log(` ${plan.name}: ${status} · ${plan.entry.municipalityIds.size} município(s)${variants}`)
const variants =
plan.entry.variants.size > 1 ? ` · variantes: ${formatVariants(plan.entry)}` : ''
console.log(
` ${plan.name}: ${status} · ${plan.entry.municipalityIds.size} município(s)${variants}`,
)
}
}
@ -1025,7 +1036,9 @@ if (prioritariaOverrides.length > 0) {
}
if (skippedSegments.length > 0) {
console.log('\n— Segmentos descartados (notas/coletivos/incertezas — permanecem só na planilha) —')
console.log(
'\n— Segmentos descartados (notas/coletivos/incertezas — permanecem só na planilha) —',
)
for (const skipped of skippedSegments.slice(0, 80)) {
console.log(` [${skipped.label}] ${skipped.column}: ${JSON.stringify(skipped.segment)}`)
}
@ -1180,7 +1193,13 @@ try {
municipalitiesUpdated += 1
}
return { usersCreated, deputiesCreated, leadershipsCreated, leadershipsLinked, municipalitiesUpdated }
return {
usersCreated,
deputiesCreated,
leadershipsCreated,
leadershipsLinked,
municipalitiesUpdated,
}
})
console.log(

View file

@ -23,10 +23,10 @@
* pnpm db:seed:posts
* (against a remote DB, on purpose:) ALLOW_REMOTE_DB=true pnpm db:seed:posts
*/
import { config as loadEnv } from 'dotenv'
import { JSDOM } from 'jsdom'
import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical'
import { del as blobDel } from '@vercel/blob'
import { config as loadEnv } from 'dotenv'
import { JSDOM } from 'jsdom'
import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs'
@ -223,7 +223,9 @@ async function fetchViaRestApi() {
// ---------------------------------------------------------------------------
const FALLBACK_CATEGORIES = ['noticias', 'uncategorized']
const PERMALINK_RE = new RegExp(`^${BASE_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\d{4}/\\d{2}/\\d{2}/[^/]+/?$`)
const PERMALINK_RE = new RegExp(
`^${BASE_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\d{4}/\\d{2}/\\d{2}/[^/]+/?$`,
)
async function fetchViaHtmlArchive() {
/** @type {Article[]} */
@ -272,8 +274,7 @@ async function fetchSinglePage(url) {
const doc = new JSDOM(await res.text()).window.document
const slug = url.replace(/\/$/, '').split('/').pop()
const contentEl =
doc.querySelector('.entry-content') || doc.querySelector('article') || doc.body
const contentEl = doc.querySelector('.entry-content') || doc.querySelector('article') || doc.body
const time = doc.querySelector('time[datetime]')
const firstImg = contentEl.querySelector('img')

View file

@ -33,10 +33,10 @@
* TSE_CACHE_DIR=./data/tse pnpm db:seed:tse
*/
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile, access } from 'node:fs/promises'
import { join } from 'node:path'
import { config as loadEnv } from 'dotenv'
import { createHash } from 'node:crypto'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs'
@ -46,13 +46,11 @@ loadEnv({ path: '.env' })
const config = (await import('../src/payload.config.ts')).default
const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts')
const { buildElectionResultsFromCsvRows, HISTORICAL_BASELINE_OFFICES } = await import(
'../src/lib/electionResultsBuild.ts'
)
const { buildElectionResultsFromCsvRows, HISTORICAL_BASELINE_OFFICES } =
await import('../src/lib/electionResultsBuild.ts')
const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.ts')
const { buildImportBundles, importElectionBundles } = await import(
'../src/utilities/electionResultsImport.ts'
)
const { buildImportBundles, importElectionBundles } =
await import('../src/utilities/electionResultsImport.ts')
const SUPPORTED_YEARS = [2014, 2018, 2022]

View file

@ -13,8 +13,8 @@ import { VoteIntentionControl } from '@/components/campaign/supporter/VoteIntent
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import {
loadSupporterDetailConsentData,
loadSupporterDetailPageData,

View file

@ -4,8 +4,8 @@ import { redirect } from 'next/navigation'
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { SupporterImportWizard } from '@/components/campaign/supporter/SupporterImportWizard'
import { Button } from '@/components/ui/button'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
export default async function ImportSupportersPage() {
const user = await getCampaignUser()

View file

@ -7,8 +7,8 @@ import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoia
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { SupporterForm } from '@/components/campaign/supporter/SupporterForm'
import { Button } from '@/components/ui/button'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { loadSupporterCreatePageData } from '@/utilities/supporterPageData'
import { canAccessSupporterArea } from '@/utilities/supporterUi'

View file

@ -9,15 +9,12 @@ import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/Adviso
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { formatBrazilianPhoneInput } from '@/lib/phone'
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
import { loadAdvisorDetail } from '@/utilities/advisorData'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { formatBrazilianPhoneInput } from '@/lib/phone'
import {
sendAdvisorPasswordResetFormAction,
updateAdvisorProfileFormAction,
} from '../formActions'
import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions'
type AdvisorDetailPageProps = {
params: Promise<{ id: string }>
@ -35,8 +32,7 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro
const advisor = await loadAdvisorDetail(payload, advisorId)
if (!advisor) notFound()
const email =
advisor.email && !isPlanilhaPlaceholderEmail(advisor.email) ? advisor.email : null
const email = advisor.email && !isPlanilhaPlaceholderEmail(advisor.email) ? advisor.email : null
return (
<CampaignPageShell>

View file

@ -2,14 +2,14 @@ import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { AdvisorsTable } from '@/components/campaign/advisor/AdvisorsTable'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
import { AdvisorsTable } from '@/components/campaign/advisor/AdvisorsTable'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import {
advisorListHrefForPage,
loadAdvisorListPageData,

View file

@ -72,8 +72,8 @@ export default async function CampaignConceptsPage() {
<header className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Conceitos de inteligência</h1>
<p className="max-w-prose text-muted-foreground">
O que cada número da campanha mede e como é calculado. Só o que o produto já calcula
hoje — a lista cresce conforme novas análises entram.
O que cada número da campanha mede e como é calculado. Só o que o produto já calcula hoje
— a lista cresce conforme novas análises entram.
</p>
</header>

View file

@ -4,8 +4,8 @@ import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { DemandWorkflowCard } from '@/components/campaign/demand/DemandWorkflowCard'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {

View file

@ -4,8 +4,8 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { DemandForm } from '@/components/campaign/demand/DemandForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'

View file

@ -10,13 +10,13 @@ import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'

View file

@ -4,16 +4,20 @@ import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { LeadershipInternalForm } from '@/components/campaign/leadership/LeadershipInternalForm'
import { LeadershipInviteButtons } from '@/components/campaign/invite/LeadershipInviteButtons'
import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips'
import { LeadershipInternalForm } from '@/components/campaign/leadership/LeadershipInternalForm'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadOrganizationOptions, loadMunicipalityOptions, loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
import {
loadMunicipalityOptions,
loadOrganizationOptions,
loadStateDeputyOptions,
} from '@/utilities/campaignRelationOptions'
import { loadLeadershipDetail } from '@/utilities/leadershipData'
import { updateLeadershipInternalFormAction } from './formActions'

View file

@ -4,12 +4,16 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { LeadershipForm } from '@/components/campaign/leadership/LeadershipForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadOrganizationOptions, loadMunicipalityOptions, loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
import {
loadMunicipalityOptions,
loadOrganizationOptions,
loadStateDeputyOptions,
} from '@/utilities/campaignRelationOptions'
import { createLeadershipFormAction } from './formActions'
type NewLeadershipPageProps = {
@ -32,9 +36,12 @@ export default async function NewLeadershipPage({ searchParams }: NewLeadershipP
? rawSearchParams.municipality[0]
: rawSearchParams.municipality
const initialMunicipalityID =
rawInitialMunicipality && /^[1-9]\d*$/.test(rawInitialMunicipality) ? Number(rawInitialMunicipality) : null
rawInitialMunicipality && /^[1-9]\d*$/.test(rawInitialMunicipality)
? Number(rawInitialMunicipality)
: null
const initialMunicipalityIDs =
initialMunicipalityID && municipalityOptions.some((option) => option.id === initialMunicipalityID)
initialMunicipalityID &&
municipalityOptions.some((option) => option.id === initialMunicipalityID)
? [initialMunicipalityID]
: []

View file

@ -4,20 +4,20 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'

View file

@ -1,4 +1,3 @@
import { CampaignListPagination } from '@/components/campaign/shared/CampaignListPagination'
import { CandidateComparePicker } from '@/components/campaign/map/CandidateComparePicker'
import { MunicipalityBaselineCard } from '@/components/campaign/municipality/MunicipalityBaselineCard'
import { MunicipalityCandidateComparisonTable } from '@/components/campaign/municipality/MunicipalityCandidateComparisonTable'
@ -10,6 +9,7 @@ import { MunicipalityStrategyCard } from '@/components/campaign/municipality/Mun
import { MunicipalityUpdateFeed } from '@/components/campaign/municipality/MunicipalityUpdateFeed'
import { MunicipalityUpdateForm } from '@/components/campaign/municipality/MunicipalityUpdateForm'
import { MunicipalityZoneNeighborhoodsCard } from '@/components/campaign/municipality/MunicipalityZoneNeighborhoodsCard'
import { CampaignListPagination } from '@/components/campaign/shared/CampaignListPagination'
import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { getCampaignUser } from '@/utilities/campaignAuth'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'

View file

@ -4,9 +4,9 @@ import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { MunicipalityAdvisorsForm } from '@/components/campaign/municipality/MunicipalityAdvisorsForm'
import { MunicipalityStrategyForm } from '@/components/campaign/municipality/MunicipalityStrategyForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'

View file

@ -1,12 +1,12 @@
import { Suspense } from 'react'
import config from '@payload-config'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { Suspense } from 'react'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { MunicipalityTabNav } from '@/components/campaign/municipality/MunicipalityTabNav'
import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitTracker'
import { MunicipalityTabNav } from '@/components/campaign/municipality/MunicipalityTabNav'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { isCampaignLeader } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'

View file

@ -4,11 +4,11 @@ import { z } from 'zod'
import { municipalityStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffEditMessages'
import { setMunicipalityExpectedVotes } from '@/app/(campaign)/campanha/actions/municipality'
import { positiveRelationshipId } from '@/lib/schemas/primitives'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import {
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
} from '@/utilities/campaignFormActionError'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { MunicipalityListExpectedVotesResponse } from './types'

View file

@ -6,14 +6,14 @@ import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitTracker'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { MunicipalityFilters } from '@/components/campaign/municipality/MunicipalityFilters'
import { MunicipalityList } from '@/components/campaign/municipality/MunicipalityList'
import { MunicipalityListOverview } from '@/components/campaign/municipality/MunicipalityListOverview'
import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitTracker'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import {
isCampaignCoordinator,
isCampaignLeader,

View file

@ -4,8 +4,8 @@ import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { actionPlanStatusLabels, actionPlanStatuses } from '@/lib/schemas/actionPlan'
@ -52,7 +52,9 @@ export default async function OrganizationDetailPage({ params }: OrganizationDet
<Badge variant="secondary">{organizationKindLabels[organization.kind]}</Badge>
</div>
{organization.municipalityNames.length ? (
<p className="text-muted-foreground">Atua em {organization.municipalityNames.join(', ')}</p>
<p className="text-muted-foreground">
Atua em {organization.municipalityNames.join(', ')}
</p>
) : null}
{organization.notes ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{organization.notes}</p>

View file

@ -4,8 +4,8 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
@ -35,7 +35,10 @@ export default async function NewOrganizationPage() {
</p>
</header>
<OrganizationForm municipalityOptions={municipalityOptions} formAction={createOrganizationFormAction} />
<OrganizationForm
municipalityOptions={municipalityOptions}
formAction={createOrganizationFormAction}
/>
</CampaignPageShell>
)
}

View file

@ -10,13 +10,13 @@ import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { organizationKindLabels } from '@/lib/schemas/organization'

View file

@ -5,8 +5,8 @@ import { Suspense } from 'react'
import { CampaignDashboard } from '@/components/campaign/dashboard/CampaignDashboard'
import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { MunicipalityMapPanelDynamic } from '@/components/campaign/map/MunicipalityMapPanelDynamic'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { TerritoryOverviewTable } from '@/components/campaign/municipality/TerritoryOverviewTable'
import { campaignPrioritySurfaceClassName } from '@/components/campaign/shell/CampaignPageShell'
import type { CampaignUser } from '@/payload-types'
@ -15,8 +15,8 @@ import { getCampaignUser } from '@/utilities/campaignAuth'
import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData'
import { loadTerritoryOverview } from '@/utilities/loadTerritoryOverview'
import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData'
export const dynamic = 'force-dynamic'

View file

@ -170,9 +170,7 @@ export const ActionPlanOverviewTab = ({
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Nenhuma demanda vinculada a este plano.
</p>
<p className="text-sm text-muted-foreground">Nenhuma demanda vinculada a este plano.</p>
)}
</CardContent>
</Card>

View file

@ -1,6 +1,6 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import {
searchActionPlanContactOptions,
@ -8,13 +8,13 @@ import {
} from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions'
import { updateActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions'
import { ActionPlanForm } from '@/components/campaign/actionPlan/ActionPlanForm'
import { ActionPlanNotFoundError, getActionPlanEditPageData } from '@/utilities/actionPlanPageData'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
ActionPlanNotFoundError,
getActionPlanEditPageData,
} from '@/utilities/actionPlanPageData'
import { loadOrganizationOptions, loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
loadMunicipalityOptions,
loadOrganizationOptions,
} from '@/utilities/campaignRelationOptions'
import { getEligibleAdvisorOptions } from '@/utilities/municipalityViewModels'
type EditActionPlanPageProps = {

View file

@ -35,7 +35,8 @@ export const createActionPlanUpdateFormAction = async (
return mapCampaignFormActionError({
error,
safeMessages,
genericMessage: 'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
genericMessage:
'Não foi possível enviar a atualização. Verifique seu acesso e tente novamente.',
})
}
}

View file

@ -4,15 +4,12 @@ import config from '@payload-config'
import { getPayload } from 'payload'
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
import {
isContactSearchQueryReady,
normalizeContactSearchQuery,
} from '@/lib/contactSearchQuery'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import {
searchActionPlanLeadershipOptions,
type ActionPlanLeadershipOption,
} from '@/utilities/actionPlanLeadershipOptions'
import { getCampaignUser } from '@/utilities/campaignAuth'
const CONTACT_OPTION_LIMIT = 20

View file

@ -1,6 +1,6 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import {
searchActionPlanContactOptions,
@ -10,7 +10,10 @@ import { createActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/plan
import { ActionPlanForm } from '@/components/campaign/actionPlan/ActionPlanForm'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadOrganizationOptions, loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import {
loadMunicipalityOptions,
loadOrganizationOptions,
} from '@/utilities/campaignRelationOptions'
import { getEligibleAdvisorOptions } from '@/utilities/municipalityViewModels'
export default async function NewActionPlanPage() {

View file

@ -6,16 +6,16 @@ import {
actionPlanCreateSchema,
actionPlanDemandDraftsSchema,
actionPlanUpdateSchema,
type ActionPlanDemandDraft,
type ActionPlanCreateInput,
type ActionPlanDemandDraft,
type ActionPlanUpdateInput,
} from '@/lib/schemas/actionPlan'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
const MAX_ACTION_PLAN_UPDATE_BODY_LENGTH = 4000
const MAX_ACTION_PLAN_RESULT_SUMMARY_LENGTH = 6000

View file

@ -100,7 +100,9 @@ const nextAdvisorIdsAfterMembership = (
if (assigned) {
if (currentAdvisorIDs.length >= MAX_ADVISORS_PER_MUNICIPALITY) {
throw new Error(`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`)
throw new Error(
`Cada município aceita no máximo ${MAX_ADVISORS_PER_MUNICIPALITY} assessores.`,
)
}
return [...currentAdvisorIDs, advisorId]
}

View file

@ -5,6 +5,7 @@ import type { Payload } from 'payload'
import { upsertContactByPhone } from '@/app/(campaign)/campanha/actions/supporter'
import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData'
import { sanitizeBrazilianPhoneInput } from '@/lib/phone'
import { leaderSupporterCreateSchema } from '@/lib/schemas/supporter'
import type { CampaignUser, Supporter } from '@/payload-types'
import { getEngagedLeaderMunicipalityIds, isCampaignLeader } from '@/utilities/campaignAccess'
@ -17,7 +18,6 @@ import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { sanitizeBrazilianPhoneInput } from '@/lib/phone'
import { isUniqueSupporterConflict } from '@/utilities/supporterErrors'
export type LeaderSupporterFormState = {

View file

@ -12,6 +12,7 @@ import {
type MunicipalityPoliticalTrendInput,
type MunicipalityStrategyUpdateInput,
} from '@/lib/schemas/municipality'
import { normalizeVoteEstimateOnSave, toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { CampaignUser } from '@/payload-types'
import {
getCampaignActionContext,
@ -21,10 +22,6 @@ import {
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import {
normalizeVoteEstimateOnSave,
toVoteEstimateScenarioViewModel,
} from '@/lib/voteEstimate'
const getFreshStaffActor = (
payload: Payload,

View file

@ -2,11 +2,14 @@
import type { Payload } from 'payload'
import { municipalityUpdateCreateSchema, type MunicipalityUpdateCreateInput } from '@/lib/schemas/municipalityUpdate'
import {
municipalityUpdateCreateSchema,
type MunicipalityUpdateCreateInput,
} from '@/lib/schemas/municipalityUpdate'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
export const createMunicipalityUpdateRecord = async (
payload: Payload,

View file

@ -5,11 +5,22 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { requiredFormSecret, requiredFormText } from '@/lib/formData'
import { normalizeBrazilianPhone } from '@/lib/phone'
import {
campaignChangePasswordSchema,
campaignPasswordResetRequestSchema,
campaignPasswordResetSchema,
} from '@/lib/schemas/campaignPassword'
import {
campaignLoginCredentials,
getCampaignUser,
setCampaignAuthCookie,
} from '@/utilities/campaignAuth'
import {
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import {
assertCampaignEmailConfigured,
CAMPAIGN_LEADERSHIP_FORGOT_PASSWORD_MESSAGE,
@ -17,17 +28,10 @@ import {
CAMPAIGN_PASSWORD_RESET_INVALID_TOKEN_MESSAGE,
isCampaignEmailConfigured,
} from '@/utilities/campaignPasswordReset'
import { campaignLoginCredentials, getCampaignUser, setCampaignAuthCookie } from '@/utilities/campaignAuth'
import {
CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import { normalizeBrazilianPhone } from '@/lib/phone'
export const requestCampaignPasswordReset = async (
input: { email: string },
): Promise<CampaignFormActionState> => {
export const requestCampaignPasswordReset = async (input: {
email: string
}): Promise<CampaignFormActionState> => {
const parsed = campaignPasswordResetRequestSchema.safeParse(input)
if (!parsed.success) {
return mapCampaignFormActionError({
@ -81,9 +85,11 @@ export const requestCampaignPasswordResetFormAction = async (
}
}
const resetCampaignPassword = async (
input: { token: string; password: string; passwordConfirmation: string },
): Promise<CampaignFormActionState> => {
const resetCampaignPassword = async (input: {
token: string
password: string
passwordConfirmation: string
}): Promise<CampaignFormActionState> => {
const parsed = campaignPasswordResetSchema.safeParse(input)
if (!parsed.success) {
return mapCampaignFormActionError({
@ -140,9 +146,11 @@ export const resetCampaignPasswordFormAction = async (
}
}
const changeCampaignPassword = async (
input: { currentPassword: string; password: string; passwordConfirmation: string },
): Promise<CampaignFormActionState> => {
const changeCampaignPassword = async (input: {
currentPassword: string
password: string
passwordConfirmation: string
}): Promise<CampaignFormActionState> => {
const user = await getCampaignUser()
if (!user) {
return { message: CAMPAIGN_SESSION_EXPIRED_MESSAGE }

View file

@ -27,8 +27,6 @@ import {
previewSupporterImportText as previewSupporterImportTextAction,
} from './supporterImport'
const getFreshStaffActor = (
payload: Payload,
actor: CampaignUser,

View file

@ -3,6 +3,7 @@
import { parse } from 'csv-parse/sync'
import type { Payload } from 'payload'
import { normalizeBrazilianPhone } from '@/lib/phone'
import {
resolveBahiaMunicipality,
supporterImportConfirmSchema,
@ -17,7 +18,6 @@ import {
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { normalizeBrazilianPhone } from '@/lib/phone'
import { relationshipId } from '@/utilities/relationship'
import {
isPreviewErrorRow,

View file

@ -8,16 +8,13 @@ import {
type DeclareVotesInput,
type EstimateVotesInput,
} from '@/lib/schemas/votePledge'
import { normalizeVoteEstimateOnSave, toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { CampaignUser } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
import {
normalizeVoteEstimateOnSave,
toVoteEstimateScenarioViewModel,
} from '@/lib/voteEstimate'
const STAFF_DECLARE_REQUIRED = 'Somente a coordenação e a assessoria registram votos declarados.'
const MUNICIPALITY_NOT_LINKED =

View file

@ -8,8 +8,8 @@ import {
redeemCampaignInviteLoginFormAction,
} from '@/app/(campaign)/campanha/convite/[token]/formActions'
import { CampaignInviteForm } from '@/components/campaign/invite/CampaignInviteForm'
import { ConsentText } from '@/components/campaign/shared/ConsentText'
import { InvalidCampaignInvite } from '@/components/campaign/invite/InvalidCampaignInvite'
import { ConsentText } from '@/components/campaign/shared/ConsentText'
import { getCampaignInvitePageData } from '@/utilities/campaignInvitePageData'
export const dynamic = 'force-dynamic'
@ -48,7 +48,9 @@ export default async function CampaignInvitePage({ params }: CampaignInvitePageP
preview.kind === 'login'
? redeemCampaignInviteLoginFormAction.bind(null, token)
: redeemCampaignInviteAutofillFormAction.bind(null, token)
const consentText = preview.requiresConsent ? ConsentText({ data: preview.consentData }) : undefined
const consentText = preview.requiresConsent
? ConsentText({ data: preview.consentData })
: undefined
return (
<main className="flex min-h-screen items-center justify-center p-4 sm:p-6">

View file

@ -3,7 +3,6 @@ import { SiteHeader } from '@/components/SiteHeader'
import { Badge } from '@/components/ui/Badge'
import { Separator } from '@/components/ui/separator'
import { getCachedGlobal } from '@/utilities/globals'
import { stripTrailingSlash } from '@/utilities/seo'
import {
POST_TYPE_LABELS,
getCategoryName,
@ -11,6 +10,7 @@ import {
getVisiblePosts,
isPostType,
} from '@/utilities/posts'
import { stripTrailingSlash } from '@/utilities/seo'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'

View file

@ -3,8 +3,8 @@ import { SiteHeader } from '@/components/SiteHeader'
import { Badge } from '@/components/ui/Badge'
import { Separator } from '@/components/ui/separator'
import { getCachedGlobal } from '@/utilities/globals'
import { stripTrailingSlash } from '@/utilities/seo'
import { POST_TYPE_LABELS, getVisiblePosts, isPostType } from '@/utilities/posts'
import { stripTrailingSlash } from '@/utilities/seo'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'

View file

@ -1,17 +1,17 @@
import { PetitionForm } from '@/components/PetitionForm'
import { MetaPixel } from '@/components/MetaPixel'
import { SignatureCounter } from '@/components/SignatureCounter'
import { getSignatureCount } from '@/app/(frontend)/actions/getSignatureCount'
import { MetaPixel } from '@/components/MetaPixel'
import { PetitionForm } from '@/components/PetitionForm'
import { SignatureCounter } from '@/components/SignatureCounter'
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
import { getCachedDocumentById, getPetitionIds } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals'
import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
import type { Media } from '@/payload-types'
import { getCachedDocumentById, getPetitionIds } from '@/utilities/documents'
import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical'
import { getCachedGlobal } from '@/utilities/globals'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
import type { Metadata } from 'next'
import type { Article, WithContext } from 'schema-dts'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import type { Article, WithContext } from 'schema-dts'
const MAX_DESCRIPTION_LENGTH = 200

View file

@ -1,7 +1,7 @@
'use server'
import config from '@payload-config'
import { petitionFormSchema, type PetitionFormInput } from '@/lib/schemas/petition-form'
import config from '@payload-config'
import { getPayload } from 'payload'
type SubmitPetitionSignatureInput = PetitionFormInput & {

View file

@ -1,7 +1,7 @@
import { resolveRevalidateTag } from '@/utilities/revalidateRequest'
import { timingSafeEqual } from 'node:crypto'
import { revalidateTag } from 'next/cache'
import { NextResponse } from 'next/server'
import { timingSafeEqual } from 'node:crypto'
/**
* On-demand revalidation endpoint for content written straight to the database
@ -69,8 +69,7 @@ export async function POST(request: Request): Promise<NextResponse> {
const url = new URL(request.url)
const queryTag = url.searchParams.get('tag')
const bodyResult =
queryTag != null && queryTag !== '' ? {} : await parseBodyTag(request)
const bodyResult = queryTag != null && queryTag !== '' ? {} : await parseBodyTag(request)
if ('invalid' in bodyResult) {
return NextResponse.json({ revalidated: false, error: 'Invalid JSON body' }, { status: 400 })

View file

@ -1,13 +1,13 @@
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave'
import { ThemeProvider } from '@/components/ThemeProvider'
import { getCachedDocumentById } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave'
import { Analytics } from '@vercel/analytics/next'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Metadata } from 'next'
import { Inter } from 'next/font/google'
import React from 'react'
import './styles.css'
import { ThemeProvider } from '@/components/ThemeProvider'
import { Inter } from 'next/font/google'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Analytics } from '@vercel/analytics/next'
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })

View file

@ -2,8 +2,8 @@
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import type { ServerFunctionClient } from 'payload'
import React from 'react'
import { importMap } from './admin/importMap.js'

View file

@ -6,19 +6,20 @@ import type {
import { APIError } from 'payload'
import {
actionPlanOriginLabels,
actionPlanOrigins,
actionPlanKindLabels,
actionPlanKinds,
actionPlanOriginLabels,
actionPlanOrigins,
actionPlanStatusLabels,
actionPlanStatuses,
} from '@/lib/schemas/actionPlan'
import { slugify } from '@/lib/slug'
import {
canCreateActionPlan,
canCreateActionPlanAdvisors,
canDeleteActionPlan,
canManageCampaignStaffField,
canManageActionPlanAdvisors,
canManageCampaignStaffField,
canReadActionPlan,
canReadCampaignStaffField,
canSetActionPlanStatus,
@ -27,7 +28,6 @@ import {
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
import { slugify } from '@/lib/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')

View file

@ -13,6 +13,7 @@ import {
campaignDemandTransitions,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import { slugify } from '@/lib/slug'
import {
canCreateCampaignDemand,
canDeleteCampaignDemand,
@ -24,7 +25,6 @@ import {
isCampaignUnrestricted,
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
import { slugify } from '@/lib/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')

View file

@ -1,3 +1,4 @@
import { normalizeBrazilianPhone } from '@/lib/phone'
import type { CampaignUser as CampaignUserDocument } from '@/payload-types'
import {
canCreateCampaignUserPhone,
@ -11,7 +12,6 @@ import {
canUpdateCampaignUserPhone,
} from '@/utilities/campaignAccess'
import { buildCampaignPasswordResetUrl } from '@/utilities/campaignPasswordReset'
import { normalizeBrazilianPhone } from '@/lib/phone'
import {
APIError,
type CollectionAfterReadHook,

View file

@ -1,13 +1,10 @@
import { CitiesByState } from '@/lib/cities'
import { normalizeBrazilianPhone } from '@/lib/phone'
import { canManageContacts, canReadContacts } from '@/utilities/campaignAccess'
import {
acquireContactPhoneLocks,
assertContactPhoneAvailable,
} from '@/utilities/contactPhoneInvariant'
import {
canManageContacts,
canReadContacts,
} from '@/utilities/campaignAccess'
import { normalizeBrazilianPhone } from '@/lib/phone'
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
@ -35,9 +32,7 @@ const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({
// Phone-less contacts (e.g. name-only leadership imports) have no uniqueness to enforce.
if (!phone) return data
const oldPhone =
operation === 'update' && typeof originalDoc?.phone === 'string'
? originalDoc.phone
: undefined
operation === 'update' && typeof originalDoc?.phone === 'string' ? originalDoc.phone : undefined
await acquireContactPhoneLocks(
req.payload,

View file

@ -1,6 +1,7 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership'
import {
canCreateLeadership,
canDeleteLeadership,
@ -10,7 +11,6 @@ import {
canReadLeadership,
canSetAdministrativeLeadershipField,
} from '@/utilities/campaignAccess'
import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership'
import { relationshipId } from '@/utilities/relationship'
const requireAtLeastOneMunicipality: CollectionBeforeValidateHook = ({

View file

@ -5,6 +5,12 @@ import type {
} from 'payload'
import { APIError } from 'payload'
import { politicalTrendStatuses } from '@/lib/schemas/municipality'
import {
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
type VoteEstimateScenarioFields,
} from '@/lib/voteEstimate'
import {
canAssignMunicipalityAdvisors,
canCreateMunicipality,
@ -17,14 +23,8 @@ import {
canUpdateMunicipality,
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { politicalTrendStatuses } from '@/lib/schemas/municipality'
import { politicalTrendLabels } from '@/utilities/municipalityLabels'
import { relationshipId } from '@/utilities/relationship'
import {
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
type VoteEstimateScenarioFields,
} from '@/lib/voteEstimate'
import {
voteEstimateScenarioGroupAccess,
voteEstimateScenarioGroupFields,
@ -99,7 +99,10 @@ const validateMunicipalityAdvisors: CollectionBeforeValidateHook = async ({ data
})
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
throw new APIError('Cada assessor deve ter papel de Coordenador Geral, Assessor ou Candidato.', 400)
throw new APIError(
'Cada assessor deve ter papel de Coordenador Geral, Assessor ou Candidato.',
400,
)
}
return data

View file

@ -1,6 +1,7 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import { slugify } from '@/lib/slug'
import {
canCreateOrganization,
canDeleteOrganization,
@ -8,7 +9,6 @@ import {
canReadOrganization,
canSetCampaignSystemField,
} from '@/utilities/campaignAccess'
import { slugify } from '@/lib/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')

View file

@ -1,6 +1,6 @@
import { slugify } from '@/lib/slug'
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents'
import { slugify } from '@/lib/slug'
import type { CollectionConfig } from 'payload'
const slug = 'post'

View file

@ -1,6 +1,7 @@
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import { slugify } from '@/lib/slug'
import {
canCreateStateDeputy,
canDeleteStateDeputy,
@ -8,7 +9,6 @@ import {
canReadStateDeputy,
canSetCampaignSystemField,
} from '@/utilities/campaignAccess'
import { slugify } from '@/lib/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')

View file

@ -1,6 +1,6 @@
import { slugify } from '@/lib/slug'
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents'
import { slugify } from '@/lib/slug'
import type { CollectionConfig } from 'payload'
const slug = 'tag'

View file

@ -1,6 +1,11 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
import {
type VoteEstimateScenarioFields,
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
} from '@/lib/voteEstimate'
import {
canCreateVotePledge,
canDeleteVotePledge,
@ -12,11 +17,6 @@ import {
payloadAdminOnly,
} from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship'
import {
type VoteEstimateScenarioFields,
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
} from '@/lib/voteEstimate'
import {
voteEstimateScenarioGroupAccess,
voteEstimateScenarioGroupFields,
@ -85,12 +85,17 @@ const validatePledgeIntegrity: CollectionBeforeChangeHook = async ({
overrideAccess: true,
req,
})
const linkedMunicipalityIDs = (Array.isArray(leadership.municipalities) ? leadership.municipalities : [])
const linkedMunicipalityIDs = (
Array.isArray(leadership.municipalities) ? leadership.municipalities : []
)
.map(relationshipId)
.filter((id): id is number => id !== null)
if (!linkedMunicipalityIDs.includes(municipalityID)) {
throw new APIError('A liderança precisa estar vinculada ao município para declarar votos nele.', 409)
throw new APIError(
'A liderança precisa estar vinculada ao município para declarar votos nele.',
409,
)
}
return data
@ -138,7 +143,13 @@ export const VotePledge: CollectionConfig = {
admin: {
group: 'Campanha',
useAsTitle: 'id',
defaultColumns: ['leadership', 'municipality', 'declaredVotes', 'estimatedVotes.central', 'updatedAt'],
defaultColumns: [
'leadership',
'municipality',
'declaredVotes',
'estimatedVotes.central',
'updatedAt',
],
},
access: {
create: canCreateVotePledge,

View file

@ -1,5 +1,6 @@
'use client'
import { submitPetitionSignature } from '@/app/(frontend)/actions/submitPetitionSignature'
import {
Field,
FieldDescription,
@ -8,26 +9,22 @@ import {
FieldLegend,
FieldSet,
} from '@/components/ui/field'
import { trackMetaLead } from '@/lib/facebookPixel'
import { petitionFormSchema, type PetitionFormInput } from '@/lib/schemas/petition-form'
import { Petition } from '@/payload-types'
import { zodResolver } from '@hookform/resolvers/zod'
import { FormProvider, type SubmitHandler, useForm } from 'react-hook-form'
import { useState, useTransition } from 'react'
import { Textarea } from './ui/textarea'
import { Button } from './ui/button'
import { petitionFormSchema, type PetitionFormInput } from '@/lib/schemas/petition-form'
import { trackMetaLead } from '@/lib/facebookPixel'
import { submitPetitionSignature } from '@/app/(frontend)/actions/submitPetitionSignature'
import { NameInput } from './NameInput'
import { EmailInput } from './EmailInput'
import { PhoneInput } from './PhoneInput'
import { StateSelect } from './StateSelect'
import { FormProvider, useForm, type SubmitHandler } from 'react-hook-form'
import { CitySelect } from './CitySelect'
import { PostalCodeInput } from './PostalCodeInput'
import { EmailInput } from './EmailInput'
import { NameInput } from './NameInput'
import { PetitionSuccessDialog } from './PetitionSuccessDialog'
import {
SIGNATURE_CREATED_EVENT,
type SignatureCreatedDetail,
} from './SignatureCounter'
import { PhoneInput } from './PhoneInput'
import { PostalCodeInput } from './PostalCodeInput'
import { SIGNATURE_CREATED_EVENT, type SignatureCreatedDetail } from './SignatureCounter'
import { StateSelect } from './StateSelect'
import { Button } from './ui/button'
import { Textarea } from './ui/textarea'
interface PetitionFormProps {
id: string

View file

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { WhatsAppIcon } from '@/components/socialIcons'
import { Button } from '@/components/ui/button'
import {
Dialog,
@ -9,8 +9,8 @@ import {
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog'
import { WhatsAppIcon } from '@/components/socialIcons'
import { XIcon } from 'lucide-react'
import { useEffect, useState } from 'react'
interface PetitionSuccessDialogProps {
open: boolean

View file

@ -1,6 +1,6 @@
import type { Post } from '@/payload-types'
import { Badge } from '@/components/ui/Badge'
import { Card } from '@/components/ui/card'
import type { Post } from '@/payload-types'
import { formatPostDate, getCategoryName, getPostCanonicalPath } from '@/utilities/posts'
import Image from 'next/image'
import Link from 'next/link'

View file

@ -1,8 +1,8 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { getSignatureCount } from '@/app/(frontend)/actions/getSignatureCount'
import { cn } from '@/lib/utils'
import { useEffect, useRef, useState } from 'react'
const POLL_INTERVAL_MS = 10_000
const ANIMATION_DURATION_MS = 1200

View file

@ -1,7 +1,7 @@
'use client'
import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import * as React from 'react'
export function ThemeProvider({
children,

View file

@ -1,5 +1,6 @@
'use client'
import { submitWhatsapp } from '@/app/(frontend)/actions/submitWhatsapp'
import { CitySelect } from '@/components/CitySelect'
import { EmailInput } from '@/components/EmailInput'
import { NameInput } from '@/components/NameInput'
@ -10,9 +11,8 @@ import { Field, FieldDescription, FieldGroup } from '@/components/ui/field'
import { Textarea } from '@/components/ui/textarea'
import { WhatsAppFormInput, whatsAppFormSchema } from '@/lib/schemas/whatsapp-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form'
import { useTransition } from 'react'
import { submitWhatsapp } from '@/app/(frontend)/actions/submitWhatsapp'
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form'
interface WhatsappFormProps {
classname?: string

View file

@ -1,12 +1,12 @@
import Link from 'next/link'
import { CalendarIcon, MapPinIcon, UserIcon } from 'lucide-react'
import Link from 'next/link'
import { ActionPlanStatusBadge } from '@/components/campaign/actionPlan/ActionPlanStatusBadge'
import { Badge } from '@/components/ui/Badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import type { ActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
const formatWhen = (plan: ActionPlanListViewModel): string =>
plan.startAt ? formatBahiaDateTimeLabel(plan.startAt) : 'Data a definir'

View file

@ -1,15 +1,15 @@
'use client'
import { useId, useState } from 'react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { useId, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Textarea } from '@/components/ui/textarea'
import { campaignDemandKindLabels, campaignDemandKinds } from '@/lib/schemas/campaignDemand'
import { MAX_ACTION_PLAN_DEMAND_DRAFTS } from '@/lib/schemas/actionPlan'
import { campaignDemandKindLabels, campaignDemandKinds } from '@/lib/schemas/campaignDemand'
type DemandFieldValue = {
key: string

View file

@ -1,17 +1,17 @@
'use client'
import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending'
import { useRef, useState, useTransition } from 'react'
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
import { useRef, useState, useTransition } from 'react'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { cn } from '@/lib/utils'
import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
import { cn } from '@/lib/utils'
import {
actionPlanTabLabels,
actionPlanTabs,

View file

@ -1,13 +1,19 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { useActionState, useState } from 'react'
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
import { ActionPlanDemandFields } from '@/components/campaign/actionPlan/ActionPlanDemandFields'
import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
import { ActionPlanTaskFields } from '@/components/campaign/actionPlan/ActionPlanTaskFields'
import { RelationMultiSelect, type RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
import {
ContactCombobox,
type ContactComboboxOption,
} from '@/components/campaign/shared/ContactCombobox'
import {
RelationMultiSelect,
type RelationOption,
} from '@/components/campaign/shared/RelationMultiSelect'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -23,9 +29,9 @@ import {
} 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 { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { Spinner } from '@/components/ui/Spinner'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import {
actionPlanKindLabels,

Some files were not shown because too many files have changed in this diff Show more