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", "rule": "design-system-color",
"value": "#111827", "value": "#111827",
"files": [ "files": ["src/collections/CampaignUser.ts"],
"src/collections/CampaignUser.ts"
],
"createdAt": "2026-07-21T01:54:11.928Z", "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" "reason": "Pre-existing inline hex in the password-reset EMAIL HTML template; transactional email cannot use CSS tokens"
}, },
{ {
"rule": "broken-image", "rule": "broken-image",
"value": "*", "value": "*",
"files": [ "files": ["src/components/MetaPixel.tsx"],
"src/components/MetaPixel.tsx"
],
"createdAt": "2026-07-23T22:40:00.000Z", "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" "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", "rule": "design-system-color",
"value": "#c86112", "value": "#c86112",
"files": [ "files": ["src/app/(frontend)/styles.css"],
"src/app/(frontend)/styles.css"
],
"createdAt": "2026-07-25T02:48:38.641Z", "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" "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,25 +8,26 @@ p2_count: 2
timestamp: 2026-07-19T10-15-47Z timestamp: 2026-07-19T10-15-47Z
slug: src-app-campaign-campanha slug: src-app-campaign-campanha
--- ---
Method: dual-agent (A: 89c6189f-1341-4e15-8d45-e1aa7c6f5167 · B: 3e87667e-e21e-4a29-85b4-db65afcc49c5) Method: dual-agent (A: 89c6189f-1341-4e15-8d45-e1aa7c6f5167 · B: 3e87667e-e21e-4a29-85b4-db65afcc49c5)
# Critique: /campanha # Critique: /campanha
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------| | --------- | ------------------------------- | --------- | ------------------------------------------------------------------------ |
| 1 | Visibility of System Status | 3 | Pending/aria-live on filters; no freshness/"as of" on KPI aggregates | | 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) | | 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 | | 3 | User Control and Freedom | 3 | Clear filters/back; limited undo for irreversible paths |
| 4 | Consistency and Standards | 3 | Strong component discipline; role dashboards diverge in max-width/chrome | | 4 | Consistency and Standards | 3 | Strong component discipline; role dashboards diverge in max-width/chrome |
| 5 | Error Prevention | 3 | Constrained selects + inline ZE validation | | 5 | Error Prevention | 3 | Constrained selects + inline ZE validation |
| 6 | Recognition Rather Than Recall | 4 | Filter state in URL; labels on actions | | 6 | Recognition Rather Than Recall | 4 | Filter state in URL; labels on actions |
| 7 | Flexibility and Efficiency | 2 | No saved views; queues are one-by-one, no bulk | | 7 | Flexibility and Efficiency | 2 | No saved views; queues are one-by-one, no bulk |
| 8 | Aesthetic and Minimalist Design | 2 | geral dashboard = even wall of same-weight Cards | | 8 | Aesthetic and Minimalist Design | 2 | geral dashboard = even wall of same-weight Cards |
| 9 | Error Recovery | 3 | Forgot-password names fallback; leadership empty does not | | 9 | Error Recovery | 3 | Forgot-password names fallback; leadership empty does not |
| 10 | Help and Documentation | 1 | Almost no glossary/tooltips for ZE / engajado / estimativa | | 10 | Help and Documentation | 1 | Almost no glossary/tooltips for ZE / engajado / estimativa |
| **Total** | | **28/40** | **Good** | | **Total** | | **28/40** | **Good** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -49,36 +50,42 @@ A serious field desk with strong a11y/touch discipline and honest empty-state co
## Priority Issues ## Priority Issues
### [P1] Filter panel decision overload ### [P1] Filter panel decision overload
**What:** `NucleusFilters` shows 6 fields at once on desktop (TI, município, ZE, cobertura, estimativa, prioridade) plus search. **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. **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. **Fix:** Keep territory + search primary; put coverage/estimate/priority behind "Mais filtros" at all breakpoints.
**Suggested command:** `/impeccable distill` / `/impeccable layout` **Suggested command:** `/impeccable distill` / `/impeccable layout`
### [P1] Hero-metric Card grids contradict own anti-references ### [P1] Hero-metric Card grids contradict own anti-references
**What:** General dashboard, NucleusListOverview, SupporterListOverview all use Card → description → big number grids. **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. **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. **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` **Suggested command:** `/impeccable quieter` / `/impeccable layout`
### [P1] Side-tab accent on baseline row (detector) ### [P1] Side-tab accent on baseline row (detector)
**What:** `border-l-4 border-primary` on highlighted candidate in NucleusElectoralBaseline. **What:** `border-l-4 border-primary` on highlighted candidate in NucleusElectoralBaseline.
**Why it matters:** Absolute ban (side-stripe >1px); deterministic AI-tell. **Why it matters:** Absolute ban (side-stripe >1px); deterministic AI-tell.
**Fix:** Full border, background tint only, or leading mark — no thick left stripe. **Fix:** Full border, background tint only, or leading mark — no thick left stripe.
**Suggested command:** `/impeccable quieter` **Suggested command:** `/impeccable quieter`
### [P2] Liderança empty state is a dead end ### [P2] Liderança empty state is a dead end
**What:** "Nenhum núcleo" / "vínculo engajado" + warning icon, no CTA. **What:** "Nenhum núcleo" / "vínculo engajado" + warning icon, no CTA.
**Why it matters:** Peer persona stranded; jargon without next step. **Why it matters:** Peer persona stranded; jargon without next step.
**Fix:** Mirror forgot-password — name who to contact (coordenador / WhatsApp). **Fix:** Mirror forgot-password — name who to contact (coordenador / WhatsApp).
**Suggested command:** `/impeccable clarify` / `/impeccable onboard` **Suggested command:** `/impeccable clarify` / `/impeccable onboard`
### [P2] No freshness signal on aggregates ### [P2] No freshness signal on aggregates
**What:** KPIs/overviews never show when data was computed. **What:** KPIs/overviews never show when data was computed.
**Why it matters:** Field decisions need trust in recency. **Why it matters:** Field decisions need trust in recency.
**Fix:** Relative "atualizado há…" near overview headers. **Fix:** Relative "atualizado há…" near overview headers.
**Suggested command:** `/impeccable harden` **Suggested command:** `/impeccable harden`
### [P3] Split brand chrome (mobile red bar vs desktop stone rail) ### [P3] Split brand chrome (mobile red bar vs desktop stone rail)
**What:** Mobile top bar is solid Mandate Red; desktop sidebar is quiet 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." **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. **Fix:** Document as field-mode signal or quiet the mobile chrome.

View file

@ -8,25 +8,26 @@ p3_count: 2
timestamp: 2026-07-19T11-03-23Z timestamp: 2026-07-19T11-03-23Z
slug: src-app-campaign-campanha slug: src-app-campaign-campanha
--- ---
Method: dual-agent (A: 43c7ddf0-03c4-40b6-86e4-167ed820fbc3 · B: c685e95f-8884-4bd6-9174-53d1a4613689) Method: dual-agent (A: 43c7ddf0-03c4-40b6-86e4-167ed820fbc3 · B: c685e95f-8884-4bd6-9174-53d1a4613689)
# Critique: /campanha # Critique: /campanha
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------| | --------- | ------------------------------- | --------- | ------------------------------------------------------------------------ |
| 1 | Visibility of System Status | 4 | Freshness on KPI surfaces; filter aria-busy/live region | | 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 | | 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 | | 3 | User Control and Freedom | 4 | Differentiated AlertDialogs; clear filter clear/back |
| 4 | Consistency and Standards | 3 | List/dashboard on CampaignPageShell; detail/forms still ad-hoc max-w | | 4 | Consistency and Standards | 3 | List/dashboard on CampaignPageShell; detail/forms still ad-hoc max-w |
| 5 | Error Prevention | 3 | Constrained selects + ZE validation; otherwise unchanged | | 5 | Error Prevention | 3 | Constrained selects + ZE validation; otherwise unchanged |
| 6 | Recognition Rather Than Recall | 4 | URL filters; RecentlyVisited; freshness timestamps | | 6 | Recognition Rather Than Recall | 4 | URL filters; RecentlyVisited; freshness timestamps |
| 7 | Flexibility and Efficiency | 2 | No saved views; triage queues still one-by-one (CSV bulk only) | | 7 | Flexibility and Efficiency | 2 | No saved views; triage queues still one-by-one (CSV bulk only) |
| 8 | Aesthetic and Minimalist Design | 3 | Metric strips + one priority lift; MetricStrip still double-card surface | | 8 | Aesthetic and Minimalist Design | 3 | Metric strips + one priority lift; MetricStrip still double-card surface |
| 9 | Error Recovery | 3 | Leadership empty has CTA; coordenador empty still prose-only | | 9 | Error Recovery | 3 | Leadership empty has CTA; coordenador empty still prose-only |
| 10 | Help and Documentation | 2 | One Núcleo/ZE line; engajado / estimativa vocabulary still unexplained | | 10 | Help and Documentation | 2 | One Núcleo/ZE line; engajado / estimativa vocabulary still unexplained |
| **Total** | | **32/40** | **Good** | | **Total** | | **32/40** | **Good** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -49,30 +50,35 @@ Prior P1s (filter wall, hero-metric card grids, side-tab) are closed. The surfac
## Priority Issues ## Priority Issues
### [P2] Domain jargon still opaque beyond one sentence ### [P2] Domain jargon still opaque beyond one sentence
**What:** engajado, estimativa confirmada vs proposta, support statuses lack tooltips/help. **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. **Why it matters:** Lia and first-timers translate under pressure; Help heuristic still weakest.
**Fix:** Inline ? / first-visit hint on SupportStatusBadge and estimate labels. **Fix:** Inline ? / first-visit hint on SupportStatusBadge and estimate labels.
**Suggested command:** `/impeccable clarify` **Suggested command:** `/impeccable clarify`
### [P2] Dashboard queues cannot be triaged in bulk ### [P2] Dashboard queues cannot be triaged in bulk
**What:** Sem coordenador / stale updates / pending estimates are one-row links only. **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. **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. **Fix:** Multi-select + assign on the priority queue only.
**Suggested command:** `/impeccable layout` **Suggested command:** `/impeccable layout`
### [P2] Dashboard heading outline skips h2 ### [P2] Dashboard heading outline skips h2
**What:** Detector + source: h1 "Visão geral" then QueueSection h3 before Indicadores h2. **What:** Detector + source: h1 "Visão geral" then QueueSection h3 before Indicadores h2.
**Why it matters:** Screen-reader document outline; Sam persona. **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. **Fix:** Promote Filas section title to h2, demote queue titles to h3; or insert sr-only/visible h2.
**Suggested command:** `/impeccable audit` **Suggested command:** `/impeccable audit`
### [P3] Coordenador empty state has no CTA ### [P3] Coordenador empty state has no CTA
**What:** Prose-only empty vs leadership empty with Ver meu perfil. **What:** Prose-only empty vs leadership empty with Ver meu perfil.
**Why it matters:** Last sibling of the old dead-end pattern. **Why it matters:** Last sibling of the old dead-end pattern.
**Fix:** Mirror EmptyContent CTA pattern. **Fix:** Mirror EmptyContent CTA pattern.
**Suggested command:** `/impeccable onboard` **Suggested command:** `/impeccable onboard`
### [P3] MetricStrip double card surface + sidebar height transition ### [P3] MetricStrip double card surface + sidebar height transition
**What:** Overlay nested-cards on strip `bg-card` inside `bg-card`; Sidebar `transition-[width,height,padding]`. **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. **Why it matters:** Mild visual nesting / layout jank risk — not task-blocking.
**Fix:** Flatten inner metric cells to transparent; prefer transform/opacity on chrome. **Fix:** Flatten inner metric cells to transparent; prefer transform/opacity on chrome.

View file

@ -6,25 +6,26 @@ p1_count: 2
timestamp: 2026-07-19T15-10-58Z timestamp: 2026-07-19T15-10-58Z
slug: src-app-campaign-campanha-login slug: src-app-campaign-campanha-login
--- ---
Method: dual-agent (A: cd378fde-c091-463a-8e89-ca6cecac8904 · B: 44f7668c-1143-41ec-a147-a2dc2f4d7f9c) Method: dual-agent (A: cd378fde-c091-463a-8e89-ca6cecac8904 · B: 44f7668c-1143-41ec-a147-a2dc2f4d7f9c)
# Critique: /campanha/login # Critique: /campanha/login
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | 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 | | 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 | | 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 | | 3 | User Control and Freedom | 3 | Forgot-password link exists; no show-password, no first-access/invite escape |
| 4 | Consistency and Standards | 3 | Auth shell duplicated across login/esqueci/redefinir; Entrar lacks `w-full` unlike sibling forms | | 4 | Consistency and Standards | 3 | Auth shell duplicated across login/esqueci/redefinir; Entrar lacks `w-full` unlike sibling forms |
| 5 | Error Prevention | 2 | `inputMode="email"` on a dual identifier; validation authoritative only on server with generic messages | | 5 | Error Prevention | 2 | `inputMode="email"` on a dual identifier; validation authoritative only on server with generic messages |
| 6 | Recognition Rather Than Recall | 4 | Standard login IA; placeholder shows both formats | | 6 | Recognition Rather Than Recall | 4 | Standard login IA; placeholder shows both formats |
| 7 | Flexibility and Efficiency | 2 | Single rigid credential path; no returning-user / PWA shortcut | | 7 | Flexibility and Efficiency | 2 | Single rigid credential path; no returning-user / PWA shortcut |
| 8 | Aesthetic and Minimalist Design | 4 | Restrained Field Desk card on muted; Signal Red budget respected | | 8 | Aesthetic and Minimalist Design | 4 | Restrained Field Desk card on muted; Signal Red budget respected |
| 9 | Error Recovery | 3 | Generic security error is correct but offers no next-step branch for phone-only / invite users | | 9 | Error Recovery | 3 | Generic security error is correct but offers no next-step branch for phone-only / invite users |
| 10 | Help and Documentation | 1 | Zero first-timer guidance on login; forgot-password already explains phone→coordenador invite | | 10 | Help and Documentation | 1 | Zero first-timer guidance on login; forgot-password already explains phone→coordenador invite |
| **Total** | | **28/40** | **Good** | | **Total** | | **28/40** | **Good** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -47,30 +48,35 @@ A clean, trustworthy ops gate that earns familiarity — then stalls for the peo
## Priority Issues ## Priority Issues
### [P1] Missing document heading landmark ### [P1] Missing document heading landmark
- **What**: `CardTitle` is a `<div>`; page has no `<h1>`. Brand row is plain `font-semibold` text. - **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. - **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>`. - **Fix**: Promote “Acessar painel” (or a sr-only “Entrar — Campanha”) to a real `<h1>`.
- **Suggested command**: `/impeccable harden /campanha/login` - **Suggested command**: `/impeccable harden /campanha/login`
### [P1] Phone-first leadership mismatch ### [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. - **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. - **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.”). - **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` - **Suggested command**: `/impeccable clarify /campanha/login`
### [P2] Monolithic auth error with no recovery branches ### [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`. - **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. - **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). - **Fix**: Keep message generic for security; add `aria-invalid` + recovery links (esqueci-senha / first-access invite guidance).
- **Suggested command**: `/impeccable harden /campanha/login` - **Suggested command**: `/impeccable harden /campanha/login`
### [P2] No first-timer guidance on the login surface ### [P2] No first-timer guidance on the login surface
- **What**: “Campanha” + Megaphone + “Acessar painel” assumes prior context; invite flow undiscoverable. - **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. - **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. - **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` - **Suggested command**: `/impeccable onboard /campanha/login` or `/impeccable clarify /campanha/login`
### [P3] Submit width inconsistent with sibling auth forms ### [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`. - **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. - **Why it matters**: Thumb-target inconsistency on phone; visual drift across auth chain.
- **Fix**: Add `w-full`; extract shared `CampaignAuthPageShell` (already on RS+ backlog). - **Fix**: Add `w-full`; extract shared `CampaignAuthPageShell` (already on RS+ backlog).

View file

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

View file

@ -7,25 +7,26 @@ p2_count: 2
timestamp: 2026-07-19T15-55-17Z timestamp: 2026-07-19T15-55-17Z
slug: src-app-campaign-campanha-login slug: src-app-campaign-campanha-login
--- ---
Method: dual-agent (A: 103923c2-0d00-49d5-9902-eee91515fbc5 · B: c71afcf4-ae05-432e-900f-a93610cb29a4) Method: dual-agent (A: 103923c2-0d00-49d5-9902-eee91515fbc5 · B: c71afcf4-ae05-432e-900f-a93610cb29a4)
# Critique: /campanha/login (post-polish) # Critique: /campanha/login (post-polish)
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------| | --------- | ------------------------------- | --------- | ------------------------------------------------------------------------------------------------ |
| 1 | Visibility of System Status | 4 | Spinner + aria-live; FieldError alert + aria-invalid/describedby on both fields | | 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 | | 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 | | 3 | User Control and Freedom | 3 | Forgot link exists; no show-password; no staff/liderança switch |
| 4 | Consistency and Standards | 4 | CampaignAuthPageShell + shared heading class; w-full matches siblings | | 4 | Consistency and Standards | 4 | CampaignAuthPageShell + shared heading class; w-full matches siblings |
| 5 | Error Prevention | 3 | Dynamic inputMode after typing; empty field still inputMode=text | | 5 | Error Prevention | 3 | Dynamic inputMode after typing; empty field still inputMode=text |
| 6 | Recognition Rather Than Recall | 4 | Phone-first placeholder; standard login IA | | 6 | Recognition Rather Than Recall | 4 | Phone-first placeholder; standard login IA |
| 7 | Flexibility and Efficiency | 2 | Single rigid credential path | | 7 | Flexibility and Efficiency | 2 | Single rigid credential path |
| 8 | Aesthetic and Minimalist Design | 4 | Restrained Field Desk; Signal Red budget OK; header slightly dense | | 8 | Aesthetic and Minimalist Design | 4 | Restrained Field Desk; Signal Red budget OK; header slightly dense |
| 9 | Error Recovery | 3 | Generic error + leadership recovery hint; hint not in aria-describedby | | 9 | Error Recovery | 3 | Generic error + leadership recovery hint; hint not in aria-describedby |
| 10 | Help and Documentation | 3 | First-access WhatsApp tip onboarded; phone recovery still partly error-gated | | 10 | Help and Documentation | 3 | First-access WhatsApp tip onboarded; phone recovery still partly error-gated |
| **Total** | | **32/40** | **Good** (+4 vs prior 28) | | **Total** | | **32/40** | **Good** (+4 vs prior 28) |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -48,30 +49,35 @@ Prior P1/P2 gaps closed: real h1, mobilizing title, phone-first placeholder, fir
## Priority Issues ## Priority Issues
### [P2] Empty-field keyboard not phone-first ### [P2] Empty-field keyboard not phone-first
- **What:** identifierInputMode('') → 'text'; tel only after ≥2 digits. - **What:** identifierInputMode('') → 'text'; tel only after ≥2 digits.
- **Why:** Leadership first tap still gets QWERTY. - **Why:** Leadership first tap still gets QWERTY.
- **Fix:** Default inputMode=tel when empty; switch to email on @. - **Fix:** Default inputMode=tel when empty; switch to email on @.
- **Suggested command:** `/impeccable adapt /campanha/login` - **Suggested command:** `/impeccable adapt /campanha/login`
### [P2] No show-password toggle ### [P2] No show-password toggle
- **What:** password type only. - **What:** password type only.
- **Why:** One-handed field re-entry under glare. - **Why:** One-handed field re-entry under glare.
- **Fix:** Reveal toggle with aria-pressed, keep min-h-11. - **Fix:** Reveal toggle with aria-pressed, keep min-h-11.
- **Suggested command:** `/impeccable harden /campanha/login` - **Suggested command:** `/impeccable harden /campanha/login`
### [P3] Recovery hint outside aria-describedby ### [P3] Recovery hint outside aria-describedby
- **What:** Only login-credentials-error is described; CAMPAIGN_LEADERSHIP_LOGIN_RECOVERY_HINT is a sibling p. - **What:** Only login-credentials-error is described; CAMPAIGN_LEADERSHIP_LOGIN_RECOVERY_HINT is a sibling p.
- **Why:** SR may miss phone-only guidance. - **Why:** SR may miss phone-only guidance.
- **Fix:** Append hint id to aria-describedby. - **Fix:** Append hint id to aria-describedby.
- **Suggested command:** `/impeccable harden /campanha/login` - **Suggested command:** `/impeccable harden /campanha/login`
### [P3] “Esqueceu a senha?” still email-first funnel ### [P3] “Esqueceu a senha?” still email-first funnel
- **What:** Link unlabeled as email-only; phone users hit forgot then discover coordinator path. - **What:** Link unlabeled as email-only; phone users hit forgot then discover coordinator path.
- **Why:** Reflex after one failed attempt. - **Why:** Reflex after one failed attempt.
- **Fix:** Sub-label or always-visible CAMPAIGN_LEADERSHIP_PHONE_ACCESS_HINT near link. - **Fix:** Sub-label or always-visible CAMPAIGN_LEADERSHIP_PHONE_ACCESS_HINT near link.
- **Suggested command:** `/impeccable clarify /campanha/login` - **Suggested command:** `/impeccable clarify /campanha/login`
### [P3] Emotional register still flat vs “inspiring” ### [P3] Emotional register still flat vs “inspiring”
- **What:** Clear ops gate; no trust/solidarity line. - **What:** Clear ops gate; no trust/solidarity line.
- **Why:** Field Desk asks for socialist/clear/inspiring. - **Why:** Field Desk asks for socialist/clear/inspiring.
- **Fix:** One grounded trust line without kitsch. - **Fix:** One grounded trust line without kitsch.

View file

@ -6,25 +6,26 @@ p1_count: 2
timestamp: 2026-07-19T21-47-04Z timestamp: 2026-07-19T21-47-04Z
slug: src-app-campaign-campanha-login slug: src-app-campaign-campanha-login
--- ---
# Critique: /campanha/login # Critique: /campanha/login
Method: dual-agent (A: 8a8b1ae0-9a8a-45f7-907f-54afcdf62db8 · B: 3899acad-4f36-4817-91d6-4135d72d2e96) Method: dual-agent (A: 8a8b1ae0-9a8a-45f7-907f-54afcdf62db8 · B: 3899acad-4f36-4817-91d6-4135d72d2e96)
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------| | --------- | ------------------------------- | --------- | ---------------------------------------------------------------- |
| 1 | Visibility of System Status | 3 | Pending state clear; no other status chrome | | 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 | | 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 | | 3 | User Control and Freedom | 2 | Only “Esqueceu a senha?”; no exit to public site |
| 4 | Consistency and Standards | 2 | h1 left vs centered muted copy; leading clash | | 4 | Consistency and Standards | 2 | h1 left vs centered muted copy; leading clash |
| 5 | Error Prevention | 2 | required + inputMode help; dual identifier still easy to mistype | | 5 | Error Prevention | 2 | required + inputMode help; dual identifier still easy to mistype |
| 6 | Recognition Rather Than Recall | 3 | Labels/placeholder clear | | 6 | Recognition Rather Than Recall | 3 | Labels/placeholder clear |
| 7 | Flexibility and Efficiency | 3 | E-mail or phone + adaptive inputMode | | 7 | Flexibility and Efficiency | 3 | E-mail or phone + adaptive inputMode |
| 8 | Aesthetic and Minimalist Design | 2 | Header rhythm/alignment + always-on help prose | | 8 | Aesthetic and Minimalist Design | 2 | Header rhythm/alignment + always-on help prose |
| 9 | Error Recovery | 3 | FieldError + leadership recovery hint | | 9 | Error Recovery | 3 | FieldError + leadership recovery hint |
| 10 | Help and Documentation | 3 | Help exists; always-visible first-access competes | | 10 | Help and Documentation | 3 | Help exists; always-visible first-access competes |
| **Total** | | **26/40** | **Acceptable** | | **Total** | | **26/40** | **Acceptable** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -47,25 +48,30 @@ Competent Field Desk palette discipline, phone-first form craft — undermined b
## Priority Issues ## Priority Issues
### P1 — Title misaligned with subtitle ### 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. 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. **Fix:** Scope left-align to app chrome, or `text-center` on auth heading.
**Command:** `/impeccable typeset` or `/impeccable layout` **Command:** `/impeccable typeset` or `/impeccable layout`
### P1 — Subtitle vertical rhythm inverted ### 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. 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. **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` **Command:** `/impeccable layout` + `/impeccable typeset`
### P2 — Split-screen + public publications: recommend No ### 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. 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. **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) **Command:** `/impeccable shape` only if product insists on panel (internal voice, not posts)
### P2 — Generic Megaphone mark ### P2 — Generic Megaphone mark
Not Solla / Field Desk identity. Not Solla / Field Desk identity.
**Command:** `/impeccable polish` (reuse campaign-logo) **Command:** `/impeccable polish` (reuse campaign-logo)
### P3 — Always-on first-access paragraph ### P3 — Always-on first-access paragraph
Progressive disclosure for returning users. Progressive disclosure for returning users.
**Command:** `/impeccable distill` or `/impeccable clarify` **Command:** `/impeccable distill` or `/impeccable clarify`

View file

@ -7,23 +7,24 @@ p2_count: 3
timestamp: 2026-07-19T23-22-03Z timestamp: 2026-07-19T23-22-03Z
slug: src-app-campaign-campanha-app-page-tsx slug: src-app-campaign-campanha-app-page-tsx
--- ---
Method: dual-agent (A: design review · B: detector + Playwright screenshots) Method: dual-agent (A: design review · B: detector + Playwright screenshots)
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------| | --------- | ------------------------------- | --------- | -------------------------------------------------------------------- |
| 1 | Visibility of System Status | 3 | KPIs têm `CampaignDataFreshness`; filas não mostram idade dos itens | | 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** | | 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 | | 3 | User Control and Freedom | 3 | **"Limpar"** em Visitados é ghost sem ícone nem `aria-label` claro |
| 4 | Consistency and Standards | 2 | **Lista de núcleos usa `lg:grid-cols-2`; dashboard geral não** | | 4 | Consistency and Standards | 2 | **Lista de núcleos usa `lg:grid-cols-2`; dashboard geral não** |
| 5 | Error Prevention | 3 | Dashboard read-only; sem riscos destrutivos aqui | | 5 | Error Prevention | 3 | Dashboard read-only; sem riscos destrutivos aqui |
| 6 | Recognition Rather Than Recall | 3 | Visitados ajuda recall; **cores do mapa não são decodificáveis** | | 6 | Recognition Rather Than Recall | 3 | Visitados ajuda recall; **cores do mapa não são decodificáveis** |
| 7 | Flexibility and Efficiency | 2 | **Coluna única + `max-w-screen-2xl`** desperdiça desktop largo | | 7 | Flexibility and Efficiency | 2 | **Coluna única + `max-w-screen-2xl`** desperdiça desktop largo |
| 8 | Aesthetic and Minimalist Design | 2 | **Três subseções vazias nas filas**; cards com muito respiro interno | | 8 | Aesthetic and Minimalist Design | 2 | **Três subseções vazias nas filas**; cards com muito respiro interno |
| 9 | Error Recovery | 3 | N/A nesta tela | | 9 | Error Recovery | 3 | N/A nesta tela |
| 10 | Help and Documentation | 2 | Status de apoio e escala do mapa sem glossário/legenda visual | | 10 | Help and Documentation | 2 | Status de apoio e escala do mapa sem glossário/legenda visual |
| **Total** | | **27/40** | **Acceptable — melhorias significativas antes de sentir "pronto"** | | **Total** | | **27/40** | **Acceptable — melhorias significativas antes de sentir "pronto"** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -46,26 +47,31 @@ O dashboard tem ingredientes certos para o Field Desk (fila prioritária, faixa
## Priority Issues ## Priority Issues
### [P1] Desktop não aproveita largura; ultra-wide deixa vazio à direita ### [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. - **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. - **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` - **Suggested command:** `/impeccable layout`
### [P1] Filas de ação vazias ocupam espaço sem valor ### [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". - **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`. - **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` - **Suggested command:** `/impeccable quieter`
### [P2] Escala do mapa sem cores ### [P2] Escala do mapa sem cores
- **Why:** `choroplethFillColor` implementa gradiente rose→vermelho; UI só mostra números em `ChoroplethMapControls`. - **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. - **Fix:** Barra de legenda com swatches usando o mesmo ramp; rótulos 0 e máximo.
- **Suggested command:** `/impeccable clarify` - **Suggested command:** `/impeccable clarify`
### [P2] "Lideranças" sem framing ### [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. - **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. - **Fix:** Card com título + descrição; link para lista filtrada; tooltip/glossário nos status.
- **Suggested command:** `/impeccable clarify` - **Suggested command:** `/impeccable clarify`
### [P2] Visitados recentemente — bullets e botão Limpar ### [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. - **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"`. - **Fix:** `list-none m-0 p-0`; botão outline ou ícone + `aria-label="Limpar histórico de visitas"`.
- **Suggested command:** `/impeccable polish` - **Suggested command:** `/impeccable polish`

View file

@ -8,23 +8,24 @@ p3_count: 2
timestamp: 2026-07-20T00-01-43Z timestamp: 2026-07-20T00-01-43Z
slug: src-app-campaign-campanha slug: src-app-campaign-campanha
--- ---
Method: dual-agent (A: 22ac0b39-b0f9-4111-b890-4c4125d1218b · B: bacccead-08e1-4dce-9b0d-4fe7984b1853) Method: dual-agent (A: 22ac0b39-b0f9-4111-b890-4c4125d1218b · B: bacccead-08e1-4dce-9b0d-4fe7984b1853)
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------| | --------- | ------------------------------- | --------- | ------------------------------------------------------------------------------------ |
| 1 | Visibility of System Status | 3 | KPIs têm `CampaignDataFreshness`; filas não mostram idade do item na linha | | 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 | | 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 | | 3 | User Control and Freedom | 4 | `RecentlyVisitedCard` com `aria-label` no limpar; liderança tem CTA no empty |
| 4 | Consistency and Standards | 3 | Dashboard `geral` alinhado ao grid coordenador; empty de eventos ainda é `<p>` solto | | 4 | Consistency and Standards | 3 | Dashboard `geral` alinhado ao grid coordenador; empty de eventos ainda é `<p>` solto |
| 5 | Error Prevention | 3 | Dashboard read-only; sem riscos destrutivos nesta tela | | 5 | Error Prevention | 3 | Dashboard read-only; sem riscos destrutivos nesta tela |
| 6 | Recognition Rather Than Recall | 4 | `ChoroplethLegend` + visitados recentes; glossário ainda só em `title` | | 6 | Recognition Rather Than Recall | 4 | `ChoroplethLegend` + visitados recentes; glossário ainda só em `title` |
| 7 | Flexibility and Efficiency | 2 | Fila prioritária ainda é um clique por núcleo; sem triagem em lote | | 7 | Flexibility and Efficiency | 2 | Fila prioritária ainda é um clique por núcleo; sem triagem em lote |
| 8 | Aesthetic and Minimalist Design | 3 | Top row 3:2:1 funciona; dois `CampaignMetricStrip` + mapa sem header | | 8 | Aesthetic and Minimalist Design | 3 | Top row 3:2:1 funciona; dois `CampaignMetricStrip` + mapa sem header |
| 9 | Error Recovery | 3 | Empty de liderança com CTA; coordenador sem próximo passo | | 9 | Error Recovery | 3 | Empty de liderança com CTA; coordenador sem próximo passo |
| 10 | Help and Documentation | 2 | Status de apoio e estimativa sem affordance touch (`?`/Popover) | | 10 | Help and Documentation | 2 | Status de apoio e estimativa sem affordance touch (`?`/Popover) |
| **Total** | | **32/40** | **Good — fundação sólida; flexibilidade e ajuda ainda abaixo do ideal** | | **Total** | | **32/40** | **Good — fundação sólida; flexibilidade e ajuda ainda abaixo do ideal** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -49,26 +50,31 @@ O dashboard `geral` saiu da pilha vertical ingênua para um desk operacional: fi
## Priority Issues ## Priority Issues
### [P2] Triagem em lote ainda ausente na fila prioritária ### [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. - **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. - **Fix:** Multi-select só em `withoutCoordinator` + barra “Atribuir coordenador (N)” reutilizando shell de designação existente.
- **Suggested command:** `/impeccable layout` - **Suggested command:** `/impeccable layout`
### [P2] Jargão de domínio opaco no touch ### [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. - **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. - **Fix:** `CampaignGlossaryHint` com gatilho `?` (`min-h-11`) em badges e labels de KPI.
- **Suggested command:** `/impeccable clarify` - **Suggested command:** `/impeccable clarify`
### [P2] Outline de headings pula `h2` em Filas ### [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. - **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). - **Fix:** Promover “Filas de ação” a `h2` semântico (`id="action-queues"` já existe).
- **Suggested command:** `/impeccable audit` - **Suggested command:** `/impeccable audit`
### [P3] Empty do coordenador sem CTA ### [P3] Empty do coordenador sem CTA
- **Why:** Texto explica o bloqueio mas não oferece ação (liderança já tem “Ver meu perfil”). - **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. - **Fix:** Espelhar `LeadershipDashboard` empty com `EmptyContent` + CTA.
- **Suggested command:** `/impeccable onboard` - **Suggested command:** `/impeccable onboard`
### [P3] Recentes compactos escondem timestamp ### [P3] Recentes compactos escondem timestamp
- **Why:** Modo `compact` põe idade só em `sr-only`; Alex não distingue visita recente de antiga. - **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. - **Fix:** Mostrar `text-xs text-muted-foreground` truncado no modo compacto.
- **Suggested command:** `/impeccable polish` - **Suggested command:** `/impeccable polish`

View file

@ -8,23 +8,24 @@ p3_count: 1
timestamp: 2026-07-23T21-29-20Z timestamp: 2026-07-23T21-29-20Z
slug: src-components-campaign-plazalistoverview-tsx slug: src-components-campaign-plazalistoverview-tsx
--- ---
Method: dual-agent (A: c0c0346d-83f2-4aa7-b728-c883635b063e · B: 104c4dcf-5d13-4351-b6fe-db8d2d632179) Method: dual-agent (A: c0c0346d-83f2-4aa7-b728-c883635b063e · B: 104c4dcf-5d13-4351-b6fe-db8d2d632179)
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | 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 | | 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 | | 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 | | 3 | User Control and Freedom | 3 | Agregado read-only correto; filtros controlam o conjunto; sem seletor de cenário no strip |
| 4 | Consistency and Standards | 2 | Dashboard: “Votos estimados” + célula própria p/ sem estimativa; lista empacota no meio | | 4 | Consistency and Standards | 2 | Dashboard: “Votos estimados” + célula própria p/ sem estimativa; lista empacota no meio |
| 5 | Error Prevention | 2 | `—` quando total=0; faixa some se falta um extremo (pós-backfill só central) | | 5 | Error Prevention | 2 | `—` quando total=0; faixa some se falta um extremo (pós-backfill só central) |
| 6 | Recognition Rather Than Recall | 1 | Exige lembrar A10: default=média; Faixa=P–O; conjunto=filtrado | | 6 | Recognition Rather Than Recall | 1 | Exige lembrar A10: default=média; Faixa=P–O; conjunto=filtrado |
| 7 | Flexibility and Efficiency | 2 | Denso p/ Alex; cenário só no mapa / Popover da linha | | 7 | Flexibility and Efficiency | 2 | Denso p/ Alex; cenário só no mapa / Popover da linha |
| 8 | Aesthetic and Minimalist Design | 3 | Field Desk limpo; ênfase justificada; leve hero-KPI | | 8 | Aesthetic and Minimalist Design | 3 | Field Desk limpo; ênfase justificada; leve hero-KPI |
| 9 | Error Recovery | 2 | Qualidade do total (`sem estimativa`) enterrada em Declarações | | 9 | Error Recovery | 2 | Qualidade do total (`sem estimativa`) enterrada em Declarações |
| 10 | Help and Documentation | 1 | Sem microcopy “Média” / “Pessimista–Otimista” | | 10 | Help and Documentation | 1 | Sem microcopy “Média” / “Pessimista–Otimista” |
| **Total** | | **20/40** | **Acceptable — craft visual ok; informação do cenário fraca** | | **Total** | | **20/40** | **Acceptable — craft visual ok; informação do cenário fraca** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -36,7 +37,7 @@ Method: dual-agent (A: c0c0346d-83f2-4aa7-b728-c883635b063e · B: 104c4dcf-5d13-
## Overall Impression ## 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 ## What's Working
@ -47,31 +48,37 @@ O strip está certo em *estrutura* A10 (média grande + faixa secundária, não
## Priority Issues ## Priority Issues
### [P1] Número grande sem rótulo “Média” ### [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?”. - **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)”. - **Fix:** Label ou detail explícito: “Média” / “Cenário média (padrão)”.
- **Suggested command:** `/impeccable clarify` - **Suggested command:** `/impeccable clarify`
### [P1] “Faixa X–Y” sem pessimista–otimista ### [P1] “Faixa X–Y” sem pessimista–otimista
- **Why:** Pode ser CI, meta ou goals; conflita com vocabulário A10 já nos forms/mapa. - **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. - **Fix:** “Pessimista X · Otimista Y” ou mini `VoteEstimateScenarioStrip` endpoints.
- **Suggested command:** `/impeccable clarify` - **Suggested command:** `/impeccable clarify`
### [P1] Mapa Cenário vs strip dessincronizado ### [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. - **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). - **Fix:** Âncora explícita (“sempre média”) **ou** strip segue o cenário do mapa (estado local compartilhado).
- **Suggested command:** `/impeccable layout` - **Suggested command:** `/impeccable layout`
### [P2] `sem estimativa` enterrado em Declarações ### [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. - **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. - **Fix:** Footnote sob o total ou alinhar layout ao dashboard.
- **Suggested command:** `/impeccable layout` - **Suggested command:** `/impeccable layout`
### [P2] “No conjunto” subespecificado ### [P2] “No conjunto” subespecificado
- **Why:** Não diz “Praças filtradas”; fácil ler como estadual/escopo total. - **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). - **Fix:** “Média nas Praças filtradas” (resolve P1+P2 de naming).
- **Suggested command:** `/impeccable clarify` - **Suggested command:** `/impeccable clarify`
### [P3] Linguagem visual ≠ linhas da tabela ### [P3] Linguagem visual ≠ linhas da tabela
- **Why:** Linhas usam ScenarioStrip; overview só tipografia. - **Why:** Linhas usam ScenarioStrip; overview só tipografia.
- **Fix:** Strip compacto endpoints no detail. - **Fix:** Strip compacto endpoints no detail.
- **Suggested command:** `/impeccable polish` - **Suggested command:** `/impeccable polish`

View file

@ -8,23 +8,24 @@ p3_count: 1
timestamp: 2026-07-23T21-52-39Z timestamp: 2026-07-23T21-52-39Z
slug: src-components-campaign-plazamappanel-tsx-cenario slug: src-components-campaign-plazamappanel-tsx-cenario
--- ---
Method: dual-agent (A: c3613972-8d1f-41bb-b210-501b0ddc14c9 · B: 8ae72814-5de3-4010-9ebb-7c9d64d8ccec) Method: dual-agent (A: c3613972-8d1f-41bb-b210-501b0ddc14c9 · B: 8ae72814-5de3-4010-9ebb-7c9d64d8ccec)
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | 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 | | 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” | | 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 | | 3 | User Control and Freedom | 3 | Três opções reversíveis; baixo risco |
| 4 | Consistency and Standards | 2 | Mesmo NativeSelect que Ano/Escala, mas escopo de página (não só mapa) | | 4 | Consistency and Standards | 2 | Mesmo NativeSelect que Ano/Escala, mas escopo de página (não só mapa) |
| 5 | Error Prevention | 1 | Posição convida o modelo errado; disclaimer é pós-fato | | 5 | Error Prevention | 1 | Posição convida o modelo errado; disclaimer é pós-fato |
| 6 | Recognition Rather Than Recall | 2 | Labels P/M/O ok; regra “só 2026” exige ler o rodapé | | 6 | Recognition Rather Than Recall | 2 | Labels P/M/O ok; regra “só 2026” exige ler o rodapé |
| 7 | Flexibility and Efficiency | 2 | Sync global é eficiente se entendido; sem atalho Ano→2026 | | 7 | Flexibility and Efficiency | 2 | Sync global é eficiente se entendido; sem atalho Ano→2026 |
| 8 | Aesthetic and Minimalist Design | 2 | Quarto controle + disclaimer xs no estado comum (Ano≠2026) | | 8 | Aesthetic and Minimalist Design | 2 | Quarto controle + disclaimer xs no estado comum (Ano≠2026) |
| 9 | Error Recovery | 2 | Mapa “não fez nada” sem live region no overview | | 9 | Error Recovery | 2 | Mapa “não fez nada” sem live region no overview |
| 10 | Help and Documentation | 2 | Hint existe; copy de engenheiro; sempre ligada no first paint | | 10 | Help and Documentation | 2 | Hint existe; copy de engenheiro; sempre ligada no first paint |
| **Total** | | **18/40** | **Poor — labels A10 ok; assento + disclaimer quebram o controle** | | **Total** | | **18/40** | **Poor — labels A10 ok; assento + disclaimer quebram o controle** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -47,26 +48,31 @@ A10 labels e sync via context estão certos. O bug de UX é estrutural: **Cenár
## Priority Issues ## Priority Issues
### [P1] Assento na toolbar do mapa implica choropleth quando Ano≠2026 ### [P1] Assento na toolbar do mapa implica choropleth quando Ano≠2026
- **Why:** First paint comum: muda Cenário → mapa igual → sensação de broken. - **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`) - **Suggested command:** `/impeccable layout` (ou `/impeccable shape`)
### [P1] Disclaimer em português de engenheiro ### [P1] Disclaimer em português de engenheiro
- **Why:** “overview” não está na UI; “preenchimento” é jargão de mapa. - **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. - **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` - **Suggested command:** `/impeccable clarify` / `/impeccable distill`
### [P2] Feel the action: efeito fora do viewport ### [P2] Feel the action: efeito fora do viewport
- **Why:** Mapa acima; overview/lista abaixo. Em mobile parece no-op. - **Why:** Mapa acima; overview/lista abaixo. Em mobile parece no-op.
- **Fix:** Highlight/pending no overview; ou co-localizar o controle. - **Fix:** Highlight/pending no overview; ou co-localizar o controle.
- **Suggested command:** `/impeccable animate` + layout - **Suggested command:** `/impeccable animate` + layout
### [P2] Always-show na fila do mapa vs hide ### [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. - **Fix:** Não esconder sem relocação; se global, não vestir como filtro do mapa.
- **Suggested command:** `/impeccable shape` - **Suggested command:** `/impeccable shape`
### [P3] Densidade Ano / Escala / Cenário / Comparar ### [P3] Densidade Ano / Escala / Cenário / Comparar
- **Why:** Quarto peer + disclaimer wrap em narrow (Casey). - **Why:** Quarto peer + disclaimer wrap em narrow (Casey).
- **Suggested command:** `/impeccable quieter` - **Suggested command:** `/impeccable quieter`

View file

@ -6,6 +6,7 @@ p1_count: 2
timestamp: 2026-07-24T20-30-52Z timestamp: 2026-07-24T20-30-52Z
slug: a11-ranking-votos slug: a11-ranking-votos
--- ---
# Critique — A11 posição em votos # Critique — A11 posição em votos
**Alvos:** `MunicipalityBaselineCard`, `MunicipalityList`, `MunicipalitySortableHead`, `MunicipalityFilters` **Alvos:** `MunicipalityBaselineCard`, `MunicipalityList`, `MunicipalitySortableHead`, `MunicipalityFilters`
@ -13,19 +14,19 @@ slug: a11-ranking-votos
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------| | --------- | ------------------------------- | --------- | -------------------------------------------------------------------------- |
| 1 | Visibility of System Status | 3 | Pending/dim ok; sort active mostly chevron — no “ordenado por…” summary | | 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 | | 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) | | 3 | User Control and Freedom | 3 | Limpar is all-or-nothing (sort resets only with full clear) |
| 4 | Consistency and Standards | 2 | Mobile “% da votação” vs desktop “Votos 2022”; card vs table readout order | | 4 | Consistency and Standards | 2 | Mobile “% da votação” vs desktop “Votos 2022”; card vs table readout order |
| 5 | Error Prevention | 3 | Detail separates dual-% well; list has no equivalent guardrail | | 5 | Error Prevention | 3 | Detail separates dual-% well; list has no equivalent guardrail |
| 6 | Recognition Rather Than Recall | 2 | List % without denominator; definition lives on detail | | 6 | Recognition Rather Than Recall | 2 | List % without denominator; definition lives on detail |
| 7 | Flexibility and Efficiency | 3 | Default votos desc is right mesa lens; inactive columns under-afforded | | 7 | Flexibility and Efficiency | 3 | Default votos desc is right mesa lens; inactive columns under-afforded |
| 8 | Aesthetic and Minimalist Design | 3 | Restrained desk; quiet secondary lines also hide meaning | | 8 | Aesthetic and Minimalist Design | 3 | Restrained desk; quiet secondary lines also hide meaning |
| 9 | Error Recovery | 2 | “—” for missing rank; engineer empty baseline copy | | 9 | Error Recovery | 2 | “—” for missing rank; engineer empty baseline copy |
| 10 | Help and Documentation | 2 | Baseline intro good; list column gives zero definition | | 10 | Help and Documentation | 2 | Baseline intro good; list column gives zero definition |
| **Total** | | **25/40** | **Acceptable — vocabulary/consistency is the drag** | | **Total** | | **25/40** | **Acceptable — vocabulary/consistency is the drag** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -48,26 +49,31 @@ Default sort by concentration is the right product move. The biggest opportunity
## Priority Issues ## Priority Issues
### [P1] Label / metric / sort vocabulary collision ### [P1] Label / metric / sort vocabulary collision
- **Why:** Under pressure, header “Votos 2022” vs cell “% da votação” vs mobile Ordenar invites wrong comparisons. - **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. - **Fix:** One shared term (e.g. Concentração 2022) across column, Ordenar, and primary readout; secondary = votos · Nº de 435.
- **Suggested command:** `/impeccable clarify` - **Suggested command:** `/impeccable clarify`
### [P1] Dual-% trap still live on list → detail path ### [P1] Dual-% trap still live on list → detail path
- **Why:** List shows unexplained %; detail introduces local % of válidos — disclaimer one click too late. - **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. - **Fix:** Column hint/caption: “% da votação estadual do candidato”; keep local dominance secondary.
- **Suggested command:** `/impeccable clarify` - **Suggested command:** `/impeccable clarify`
### [P2] Card vs table VotePositionReadout hierarchy diverge ### [P2] Card vs table VotePositionReadout hierarchy diverge
- **Why:** Same data, different story (table prioritizes share; card is middot soup). - **Why:** Same data, different story (table prioritizes share; card is middot soup).
- **Fix:** Same stack both layouts: concentration → votos · rank. - **Fix:** Same stack both layouts: concentration → votos · rank.
- **Suggested command:** `/impeccable distill` - **Suggested command:** `/impeccable distill`
### [P2] “Praça” + engineer empty state ### [P2] “Praça” + engineer empty state
- **Why:** Remodel is Município; “Rode o seed…” fails closed for staff. - **Why:** Remodel is Município; “Rode o seed…” fails closed for staff.
- **Fix:** Município language; staff-safe empty (“Sem baseline TSE neste ambiente”). - **Fix:** Município language; staff-safe empty (“Sem baseline TSE neste ambiente”).
- **Suggested command:** `/impeccable harden` - **Suggested command:** `/impeccable harden`
### [P3] Active sort under-announced for sighted scan ### [P3] Active sort under-announced for sighted scan
- **Why:** Chevron alone is easy to miss in dense headers. - **Why:** Chevron alone is easy to miss in dense headers.
- **Fix:** Active weight + optional “Ordenado por concentração 2022…”. - **Fix:** Active weight + optional “Ordenado por concentração 2022…”.
- **Suggested command:** `/impeccable polish` - **Suggested command:** `/impeccable polish`

View file

@ -6,6 +6,7 @@ p1_count: 2
timestamp: 2026-07-24T20-48-41Z timestamp: 2026-07-24T20-48-41Z
slug: a11-ranking-votos slug: a11-ranking-votos
--- ---
# Critique — A11 posição em votos (pós-polish) # Critique — A11 posição em votos (pós-polish)
**Alvos:** BaselineCard, List, SortableHead, Filters **Alvos:** BaselineCard, List, SortableHead, Filters
@ -13,19 +14,19 @@ slug: a11-ranking-votos
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------| | --------- | ------------------------------- | --------- | ----------------------------------------------------- |
| 1 | Visibility of System Status | 3 | Sort summary ok; dormant chevrons faint | | 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 | | 2 | Match System / Real World | 3 | Concentração unified; Praças leftovers in visit/scope |
| 3 | User Control and Freedom | 3 | Limpar nuclear | | 3 | User Control and Freedom | 3 | Limpar nuclear |
| 4 | Consistency and Standards | 3 | Mobile/desktop labels aligned | | 4 | Consistency and Standards | 3 | Mobile/desktop labels aligned |
| 5 | Error Prevention | 3 | Dual-% taught; cells still noisy | | 5 | Error Prevention | 3 | Dual-% taught; cells still noisy |
| 6 | Recognition Rather Than Recall | 3 | Definition in body/cells, not on header | | 6 | Recognition Rather Than Recall | 3 | Definition in body/cells, not on header |
| 7 | Flexibility and Efficiency | 3 | Default votos desc right | | 7 | Flexibility and Efficiency | 3 | Default votos desc right |
| 8 | Aesthetic and Minimalist Design | 2 | Per-cell “da votação estadual” + “de 435” | | 8 | Aesthetic and Minimalist Design | 2 | Per-cell “da votação estadual” + “de 435” |
| 9 | Error Recovery | 3 | Staff-safe empty baseline | | 9 | Error Recovery | 3 | Staff-safe empty baseline |
| 10 | Help and Documentation | 3 | Help misplaced (cells vs header) | | 10 | Help and Documentation | 3 | Help misplaced (cells vs header) |
| **Total** | | **29/40** | **Good** | | **Total** | | **29/40** | **Good** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -46,18 +47,23 @@ Vocabulary polish worked (+4 vs 25). Next jump is density: definitions once on t
## Priority Issues ## Priority Issues
### [P1] Per-cell “da votação estadual” (owner) ### [P1] Per-cell “da votação estadual” (owner)
Cell = bare %. Explanation on Concentração header tooltip. → `/impeccable distill` Cell = bare %. Explanation on Concentração header tooltip. → `/impeccable distill`
### [P1] “de 435” per row (owner) ### [P1] “de 435” per row (owner)
Rank = `12º` only; catalog size once in header tooltip. → `/impeccable distill` Rank = `12º` only; catalog size once in header tooltip. → `/impeccable distill`
### [P2] Sort summary copy-heavy ### [P2] Sort summary copy-heavy
Short status only; definition → header. → `/impeccable quieter` / polish Short status only; definition → header. → `/impeccable quieter` / polish
### [P2] Baseline dual-% fork ### [P2] Baseline dual-% fork
Demote local dominance. → `/impeccable layout` Demote local dominance. → `/impeccable layout`
### [P3] Dormant chevrons + Praças leftovers ### [P3] Dormant chevrons + Praças leftovers
→ `/impeccable polish` / clarify → `/impeccable polish` / clarify
## Persona Red Flags ## Persona Red Flags

View file

@ -6,6 +6,7 @@ p1_count: 1
timestamp: 2026-07-24T21-01-02Z timestamp: 2026-07-24T21-01-02Z
slug: a11-ranking-votos slug: a11-ranking-votos
--- ---
# Critique — A11 (pós density distill) # Critique — A11 (pós density distill)
**Data:** 2026-07-24 **Data:** 2026-07-24
@ -13,19 +14,19 @@ slug: a11-ranking-votos
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------| | --------- | ------------------------------- | --------- | ------------------------------- |
| 1 | Visibility of System Status | 3 | Sort status solid | | 1 | Visibility of System Status | 3 | Sort status solid |
| 2 | Match System / Real World | 3 | Concentração vocabulary ok | | 2 | Match System / Real World | 3 | Concentração vocabulary ok |
| 3 | User Control and Freedom | 3 | Limpar nuclear | | 3 | User Control and Freedom | 3 | Limpar nuclear |
| 4 | Consistency and Standards | 3 | Help chrome fights distill | | 4 | Consistency and Standards | 3 | Help chrome fights distill |
| 5 | Error Prevention | 3 | Bare cells help | | 5 | Error Prevention | 3 | Bare cells help |
| 6 | Recognition Rather Than Recall | 3 | Definition on ? not header | | 6 | Recognition Rather Than Recall | 3 | Definition on ? not header |
| 7 | Flexibility and Efficiency | 3 | Default votos desc | | 7 | Flexibility and Efficiency | 3 | Default votos desc |
| 8 | Aesthetic and Minimalist Design | 3 | Cells good; ? residual | | 8 | Aesthetic and Minimalist Design | 3 | Cells good; ? residual |
| 9 | Error Recovery | 3 | Empty baseline ok | | 9 | Error Recovery | 3 | Empty baseline ok |
| 10 | Help and Documentation | 2 | Wrong chrome: ? vs hover header | | 10 | Help and Documentation | 2 | Wrong chrome: ? vs hover header |
| **Total** | | **30/40** | **Good** | | **Total** | | **30/40** | **Good** |
## Anti-Patterns ## Anti-Patterns
@ -44,13 +45,16 @@ Cells landed. Open P1: remove CampaignInfoHint; explanation on header hover/focu
## Priority Issues ## Priority Issues
### [P1] Owner — no ? button; hover header (validated) ### [P1] Owner — no ? button; hover header (validated)
Remove hint chrome; Tooltip on sortable Concentração head. Mobile: no orphan ?. Baseline: same rule. Remove hint chrome; Tooltip on sortable Concentração head. Mobile: no orphan ?. Baseline: same rule.
→ `/impeccable distill` → `/impeccable distill`
### [P2] Limpar nukes sort ### [P2] Limpar nukes sort
→ `/impeccable harden` → `/impeccable harden`
### [P3] Dormant chevrons after ? dies ### [P3] Dormant chevrons after ? dies
→ `/impeccable polish` → `/impeccable polish`
## Personas ## Personas

View file

@ -6,23 +6,24 @@ p1_count: 3
timestamp: 2026-07-25T01-04-34Z timestamp: 2026-07-25T01-04-34Z
slug: omponents-campaign-municipalitygoalaccountcard-tsx slug: omponents-campaign-municipalitygoalaccountcard-tsx
--- ---
Method: dual-agent (A: 92b63fbf-f200-4a00-9cc5-81db3f317b57 · B: 35312920-2be2-4bac-92aa-0fbb62c2a571) Method: dual-agent (A: 92b63fbf-f200-4a00-9cc5-81db3f317b57 · B: 35312920-2be2-4bac-92aa-0fbb62c2a571)
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | 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. | | 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. | | 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. | | 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..."). | | 5 | Error Prevention | 3 | n/a mostly (read-only diagnostics); sane zero/negative fallbacks ("Sem meta definida," "Sem teto do campo projetado..."). |
| 6 | Recognition Rather Than Recall | 2 | Dotted-underline is a fair desktop cue, but on touch there is **no cue at all** — confirmed by both assessments (Radix's own touch-suppression logic, and no tap affordance reachable at 390×844). | | 6 | Recognition Rather Than Recall | 2 | Dotted-underline is a fair desktop cue, but on touch there is **no cue at all** — confirmed by both assessments (Radix's own touch-suppression logic, and no tap affordance reachable at 390×844). |
| 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. | | 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). | | 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. | | 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** | | **Total** | | **26/40** | **Acceptable — significant improvements needed before users are happy** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict
@ -45,30 +46,35 @@ The card itself is well-crafted and on-brand — restrained, honest about data p
## Priority Issues ## Priority Issues
**[P1] Touch users cannot open the metric tooltips at all.** **[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. - **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. - **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. - **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` - **Suggested command**: `/impeccable harden`
**[P1] Tooltips render on top of the number/coverage block they explain.** **[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. - **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. - **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). - **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` - **Suggested command**: `/impeccable layout`
**[P1] Tooltip/popover copy is dense, cross-referential jargon — and the real user already said it didn't help.** **[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. - **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. - **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` - **Suggested command**: `/impeccable clarify`
**[P2] Two incompatible disclosure idioms coexist in one card.** **[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. - **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. - **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` - **Suggested command**: `/impeccable clarify`
**[P3] The 0%-coverage progress bar is nearly invisible.** **[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). - **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. - **Fix**: Give the near-zero state deliberate visual weight instead of relying solely on the adjacent "0%" text.
- **Suggested command**: `/impeccable polish` - **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 ## 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? - "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,25 +6,26 @@ p1_count: 3
timestamp: 2026-07-25T02-37-03Z timestamp: 2026-07-25T02-37-03Z
slug: src-components-campaign-municipalitylist-tsx slug: src-components-campaign-municipalitylist-tsx
--- ---
# Critique — B16 Municipality Header Filters # Critique — B16 Municipality Header Filters
Method: dual-agent (A: 8cdcb249-95fe-4f3d-8937-081b10b442bc · B: c641769d-fc5c-4a83-9442-4329cff5cf96) Method: dual-agent (A: 8cdcb249-95fe-4f3d-8937-081b10b442bc · B: c641769d-fc5c-4a83-9442-4329cff5cf96)
## Design Health Score ## Design Health Score
| # | Heuristic | Score | Key Issue | | # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------| | --------- | ------------------------------- | --------- | ------------------------------------------------------- |
| 1 | Visibility of System Status | 2 | Control lag until RSC (fixed post-critique: optimistic) | | 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 | | 2 | Match System / Real World | 3 | Priority on Município column |
| 3 | User Control and Freedom | 3 | Limpar + Todos clear | | 3 | User Control and Freedom | 3 | Limpar + Todos clear |
| 4 | Consistency and Standards | 2 | Header vs mobile select duality | | 4 | Consistency and Standards | 2 | Header vs mobile select duality |
| 5 | Error Prevention | 3 | Constrained options | | 5 | Error Prevention | 3 | Constrained options |
| 6 | Recognition Rather Than Recall | 2 | Icon-only funnels (Priority now labeled) | | 6 | Recognition Rather Than Recall | 2 | Icon-only funnels (Priority now labeled) |
| 7 | Flexibility and Efficiency | 3 | Column-local filters | | 7 | Flexibility and Efficiency | 3 | Column-local filters |
| 8 | Aesthetic and Minimalist Design | 3 | Slim bar succeeds | | 8 | Aesthetic and Minimalist Design | 3 | Slim bar succeeds |
| 9 | Error Recovery | 2 | Limpar primary recovery | | 9 | Error Recovery | 2 | Limpar primary recovery |
| 10 | Help and Documentation | 2 | Filter meanings sparse | | 10 | Help and Documentation | 2 | Filter meanings sparse |
| **Total** | | **25/40** | **Acceptable** | | **Total** | | **25/40** | **Acceptable** |
## Anti-Patterns Verdict ## Anti-Patterns Verdict

View file

@ -1,8 +1,5 @@
{ {
"files": [ "files": ["src/app/(frontend)/layout.tsx", "src/app/(campaign)/layout.tsx"],
"src/app/(frontend)/layout.tsx",
"src/app/(campaign)/layout.tsx"
],
"insertBefore": "</body>", "insertBefore": "</body>",
"commentSyntax": "jsx", "commentSyntax": "jsx",
"cspChecked": true "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, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"printWidth": 100, "printWidth": 100,
"semi": false "semi": false,
"plugins": ["prettier-plugin-organize-imports"]
} }

View file

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

View file

@ -24,7 +24,7 @@ Modules the 2026-07 hardening phases touch, and where their behavior is pinned:
| Leader supporter create | linked-municipality scope; out-of-scope rejection; staff rejection | `collectionAccessLockdown.int.spec.ts` | | | Leader supporter create | linked-municipality scope; out-of-scope rejection; staff rejection | `collectionAccessLockdown.int.spec.ts` | |
| Posts visibility/caching | `isPostVisible` fail-closed; cached listing | (indirect via routes) | no dedicated `posts` spec — backlog | | Posts visibility/caching | `isPostVisible` fail-closed; cached listing | (indirect via routes) | no dedicated `posts` spec — backlog |
| List URL contracts (Pass 2 W0, pinned 2026-07-25) | `campaignListUrl` canonicalize/clamp/redirect; supporter + actionPlan parse/serialize/href; 5 entity parsers | `campaignListUrl`, `supporterUi`, `actionPlanUi`, `campaignEntityListParsers` (unit) | municipality list URL pinned separately in `municipalityUi.unit.spec.ts` | | List URL contracts (Pass 2 W0, pinned 2026-07-25) | `campaignListUrl` canonicalize/clamp/redirect; supporter + actionPlan parse/serialize/href; 5 entity parsers | `campaignListUrl`, `supporterUi`, `actionPlanUi`, `campaignEntityListParsers` (unit) | municipality list URL pinned separately in `municipalityUi.unit.spec.ts` |
| Form-action ladders (Pass 2 W4d) | `runCampaignFormAction`: success spread, boundary/zod/safe/generic mapping, values+revision echo | `campaignFormActions.unit.spec.ts` | per-route ladders are thin shells; behavior pinned at the wrapper | | Form-action ladders (Pass 2 W4d) | `runCampaignFormAction`: success spread, boundary/zod/safe/generic mapping, values+revision echo | `campaignFormActions.unit.spec.ts` | per-route ladders are thin shells; behavior pinned at the wrapper |
| WhatsApp subscription consent (Pass 2 D3) | fail-closed without `whatsapp-inscricao`; happy path links subscription → keyed consent | `submitWhatsapp.int.spec.ts` | | | WhatsApp subscription consent (Pass 2 D3) | fail-closed without `whatsapp-inscricao`; happy path links subscription → keyed consent | `submitWhatsapp.int.spec.ts` | |
| Vote trend + election number format (Pass 2 W4a survivors) | `computeVoteTrend` pair priority/±10% band; `formatElectionNumber` E18 fraction defect | `voteTrend.unit.spec.ts` | | | Vote trend + election number format (Pass 2 W4a survivors) | `computeVoteTrend` pair priority/±10% band; `formatElectionNumber` E18 fraction defect | `voteTrend.unit.spec.ts` | |
| Entity list loaders (Pass 2 W0) | scope (leader empty/denied), marker-narrowed q, kind/status filters, pagination shape | `campaignEntityListData.int.spec.ts` | advisor loader runs post-gate with `overrideAccess: true` (gate asserted upstream) | | Entity list loaders (Pass 2 W0) | scope (leader empty/denied), marker-narrowed q, kind/status filters, pagination shape | `campaignEntityListData.int.spec.ts` | advisor loader runs post-gate with `overrideAccess: true` (gate asserted upstream) |

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. 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 ### Fase 1 — UX alinhada
- `SupporterForm` agora usa `StrictCombobox` + `municipalityComboboxOptions()` no município (validação continua em `optionalBahiaCity`). - `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`. - `RemoveSupporterDataButton` trocou `window.confirm` por `AlertDialog` no padrão de `ArchiveNucleusDialog.tsx`.
### Fase 2 — DRY com núcleos / leadership ### 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/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/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/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. - `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 ### 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. - `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 ### 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/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. - `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 ### 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/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. - `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. - Testes int: fluxo preview→confirm; token tampered (assinatura alterada) rejeitado; token de outro ator rejeitado; reuso de token consumido rejeitado.

View file

@ -55,7 +55,7 @@ Regras transversais (de §6.2/§6.4, viram comportamento do produto): **barato-a
| --- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ------ | -------- | --------- | | --- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ------ | -------- | --------- |
| E8 | **[Conta da cadeira](conta-da-cadeira.md)** | Global `campaignGoals`; potencial por município (válidos projetados, captura do campo, share intracampo via `campoParties.ts`, roll-off, headroom); decomposição meta estadual→município com sanity por TI; cobertura Σ(estimativa do cenário — default média via **A10**)÷meta no dashboard/lista; denominadores sempre válidos projetados (I-B) | O-A | B | ~2d | 2 | | E8 | **[Conta da cadeira](conta-da-cadeira.md)** | Global `campaignGoals`; potencial por município (válidos projetados, captura do campo, share intracampo via `campoParties.ts`, roll-off, headroom); decomposição meta estadual→município com sanity por TI; cobertura Σ(estimativa do cenário — default média via **A10**)÷meta no dashboard/lista; denominadores sempre válidos projetados (I-B) | O-A | B | ~2d | 2 |
| E9 | **[Fila de alocação](fila-de-alocacao.md)** | Lista de decisão com as 7 colunas (§5.2 do relatório: votos em jogo, meta/cobertura/delta, classe, LQ/captura, rede+frescor, competição, tendência) + **% da própria votação** (âncora da mesa, via A11); ordenação default por déficit descoberto (default do staff quando houver metas); ordenação de risco (defesa por frescor); "coluna da vergonha" | O-B, O-F | B | ~1,5d | 2 | | E9 | **[Fila de alocação](fila-de-alocacao.md)** | Lista de decisão com as 7 colunas (§5.2 do relatório: votos em jogo, meta/cobertura/delta, classe, LQ/captura, rede+frescor, competição, tendência) + **% da própria votação** (âncora da mesa, via A11); ordenação default por déficit descoberto (default do staff quando houver metas); ordenação de risco (defesa por frescor); "coluna da vergonha" | O-B, O-F | B | ~1,5d | 2 |
| C12 | **[Registro-fundação](registro-fundacao.md)** | Versions em `votePledge`; sinais tipados no `municipalityUpdate`; `actionPlan.origin` + demandas/custo vinculado; collection `allocationDecision`; garantia "nunca sobrescrever" | O-E, O-G(base) | B | ~2d | 2 | | C12 | **[Registro-fundação](registro-fundacao.md)** | Versions em `votePledge`; sinais tipados no `municipalityUpdate`; `actionPlan.origin` + demandas/custo vinculado; collection `allocationDecision`; garantia "nunca sobrescrever" | O-E, O-G(base) | B | ~2d | 2 |
| E16 | **[Dossiê do município](dossie-municipio.md)** | Dossiê pré-visita/print compondo série+captura, pledges/cobertura, lideranças, conjuntura (`politicalTrend`), forças/riscos, dobradinhas, sinais recentes e perfil A8; emendas quando houver dado (G11) — pedido explícito de campo (O6 do CUSTOMER.md) | O6 (campo) | B | ~1d | 2 | | E16 | **[Dossiê do município](dossie-municipio.md)** | Dossiê pré-visita/print compondo série+captura, pledges/cobertura, lideranças, conjuntura (`politicalTrend`), forças/riscos, dobradinhas, sinais recentes e perfil A8; emendas quando houver dado (G11) — pedido explícito de campo (O6 do CUSTOMER.md) | O6 (campo) | B | ~1d | 2 |
| E10 | **[Classificação territorial relativa](classificacao-territorial-relativa.md)** | Recalibra defesa/ataque/indecisa/perdida com âncoras relativas (LQ vs. share estadual próprio; múltiplos do share da cadeira marginal), multi-eixo (dominância+importância+competição+teto do campo); substitui limiares 35/20/10 de `electionInsights.ts` para DF | O-D | B | ~1d | 3 | | E10 | **[Classificação territorial relativa](classificacao-territorial-relativa.md)** | Recalibra defesa/ataque/indecisa/perdida com âncoras relativas (LQ vs. share estadual próprio; múltiplos do share da cadeira marginal), multi-eixo (dominância+importância+competição+teto do campo); substitui limiares 35/20/10 de `electionInsights.ts` para DF | O-D | B | ~1d | 3 |
| B13 | **[Escala relativa no mapa](escala-relativa-mapa.md)** | Modo de escala novo: quantis do próprio candidato (5 classes) default, LQ e rank no município; símbolo proporcional por votos em jogo sobre o polígono; % dos válidos mantido como opção; roll-off como métrica adicional | O-C | B | ~2d | 3 | | B13 | **[Escala relativa no mapa](escala-relativa-mapa.md)** | Modo de escala novo: quantis do próprio candidato (5 classes) default, LQ e rank no município; símbolo proporcional por votos em jogo sobre o polígono; % dos válidos mantido como opção; roll-off como métrica adicional | O-C | B | ~2d | 3 |

View file

@ -80,16 +80,16 @@ Hierarquia TSE: município → (1+) zona → (muitas) seção; a relação munic
## Entrega as-built (B2, 2026-07-18) ## Entrega as-built (B2, 2026-07-18)
| Artefato | Caminho | Notas | | Artefato | Caminho | Notas |
| -------- | ------- | ----- | | --------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| TopoJSON municípios | `src/lib/geometries/bahia-municipalities.topo.json` | ~132 KB; 417 features; object `municipalities` | | 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) | | 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` | | Tabela nome→`codarea` | `src/lib/bahiaMunicipalityCodes.ts` | + `codeForMunicipality` / `municipalityForCode` |
| Helpers runtime | `src/lib/bahiaGeometries.ts` | `topojson-client` `feature()` + Maps no load do módulo | | Helpers runtime | `src/lib/bahiaGeometries.ts` | `topojson-client` `feature()` + Maps no load do módulo |
| Fixture oficial | `tests/fixtures/bahia-municipality-codes.official.json` | evidence SHA + bijeção | | Fixture oficial | `tests/fixtures/bahia-municipality-codes.official.json` | evidence SHA + bijeção |
| Testes int | `tests/int/bahiaMunicipalityCodes.int.spec.ts`, `tests/int/bahiaGeometries.int.spec.ts` | cobertura 417/27, teto de bytes, nomes de objects via módulo | | Testes int | `tests/int/bahiaMunicipalityCodes.int.spec.ts`, `tests/int/bahiaGeometries.int.spec.ts` | cobertura 417/27, teto de bytes, nomes de objects via módulo |
| Script | `scripts/build-bahia-geometries.mjs` | `pnpm build:geometries`; deps: `topojson-client` (runtime), `topojson-server` / `topojson-simplify` (dev) | | Script | `scripts/build-bahia-geometries.mjs` | `pnpm build:geometries`; deps: `topojson-client` (runtime), `topojson-server` / `topojson-simplify` (dev) |
| Cache | `data/geometries/` | gitignored | | Cache | `data/geometries/` | gitignored |
**Cleanup `/simplify` já aplicado no PR da Fase 1:** reuso de `downloadToBuffer`; catch só de `UnknownMunicipalityError`; remoção de dead code de codegen (`stableStringifyTopo` enganoso, tipo `BahiaMunicipalityCode` não usado). **Não aplicado (→ B5):** lazy load split mun/TI; helper CLI compartilhado `ensureCachedDownload`. **Cleanup `/simplify` já aplicado no PR da Fase 1:** reuso de `downloadToBuffer`; catch só de `UnknownMunicipalityError`; remoção de dead code de codegen (`stableStringifyTopo` enganoso, tipo `BahiaMunicipalityCode` não usado). **Não aplicado (→ B5):** lazy load split mun/TI; helper CLI compartilhado `ensureCachedDownload`.

View file

@ -8,6 +8,7 @@ Appetite: ~0,5–1 dia eng; sem migration, sem collection, sem server action nov
Responsável: — Responsável: —
Revisões: 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 (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. - 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 ## 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. - **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). - **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: — Responsável: —
### Revisão 2026-07-24 (auditoria → implementação) ### 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. - **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. - **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`. - **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: — Responsável: —
Revisões: 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 (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”. - 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"] "server-only": ["tests/helpers/serverOnly.ts"]
}, },
"ignoreBinaries": [], "ignoreBinaries": [],
"ignoreDependencies": [ "ignoreDependencies": ["tailwindcss-animate", "tw-animate-css", "shadcn", "eslint-config-next"],
"tailwindcss-animate",
"tw-animate-css",
"shadcn",
"eslint-config-next",
"prettier"
],
"rules": { "rules": {
"exports": "error", "exports": "error",
"types": "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", "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", "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", "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: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", "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", "migrate": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate",
@ -119,9 +122,11 @@
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "28.0.0", "jsdom": "28.0.0",
"knip": "^6.27.0", "knip": "^6.27.0",
"madge": "^8.0.0",
"pg": "^8.22.0", "pg": "^8.22.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.3.0",
"schema-dts": "^2.0.0", "schema-dts": "^2.0.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"topojson-server": "^3.0.1", "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()) { for (const record of byCode.values()) {
record.population = Object.values(record.ageBands).reduce((sum, value) => sum + value, 0) record.population = Object.values(record.ageBands).reduce((sum, value) => sum + value, 0)
record.sexShareFemale = record.sexShareFemale = record.population > 0 ? record.femalePopulation / record.population : 0
record.population > 0 ? record.femalePopulation / record.population : 0
delete record.femalePopulation delete record.femalePopulation
} }
@ -141,14 +140,15 @@ const buildDemographicsByCode = ({ ageCategoryRows, femaleRows, medianRows }) =>
} }
const buildModuleSource = (demographicsByCode, provenance) => { 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 const lines = entries
.map(([code, record]) => { .map(([code, record]) => {
const bands = Object.entries(record.ageBands) const bands = Object.entries(record.ageBands)
.map(([band, value]) => `${JSON.stringify(band)}: ${value}`) .map(([band, value]) => `${JSON.stringify(band)}: ${value}`)
.join(', ') .join(', ')
const median = const median = record.medianAge == null ? 'null' : JSON.stringify(record.medianAge)
record.medianAge == null ? 'null' : JSON.stringify(record.medianAge)
const sexShare = record.sexShareFemale.toFixed(6) const sexShare = record.sexShareFemale.toFixed(6)
return ` ${JSON.stringify(code)}: { population: ${record.population}, ageBands: { ${bands} }, sexShareFemale: ${sexShare}, medianAge: ${median} },` 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 { 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 { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { topology } from 'topojson-server'
import { merge, quantize } from 'topojson-client' 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( const { canonicalizeMunicipalityName, UnknownMunicipalityError } =
'../src/lib/electionResults.ts' await import('../src/lib/electionResults.ts')
) const { bahiaMunicipalities, bahiaIdentityTerritoryRecords } =
const { bahiaMunicipalities, bahiaIdentityTerritoryRecords } = await import( await import('../src/lib/bahiaTerritories.ts')
'../src/lib/bahiaTerritories.ts'
)
const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts') const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
@ -224,7 +222,8 @@ const main = async () => {
const memberGeometries = territory.municipalities.map((name) => { const memberGeometries = territory.municipalities.map((name) => {
const code = codeByCanonical.get(name) const code = codeByCanonical.get(name)
const geometry = geometriesByCodarea.get(code) 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 geometry
}) })
return { return {

View file

@ -16,13 +16,12 @@
*/ */
import { createHash } from 'node:crypto' 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 { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
const { canonicalizeMunicipalityName, UnknownMunicipalityError } = await import( const { canonicalizeMunicipalityName, UnknownMunicipalityError } =
'../src/lib/electionResults.ts' await import('../src/lib/electionResults.ts')
)
const { bahiaMunicipalities } = await import('../src/lib/bahiaTerritories.ts') const { bahiaMunicipalities } = await import('../src/lib/bahiaTerritories.ts')
const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts') const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts')
const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.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)) const missing = bahiaMunicipalities.filter((city) => !codeByCanonical.has(city))
if (missing.length > 0) { 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) const accessed = new Date().toISOString().slice(0, 10)

View file

@ -27,10 +27,10 @@
* writes only src/lib/electionAggregates/. * writes only src/lib/electionAggregates/.
*/ */
import { config as loadEnv } from 'dotenv'
import { mkdir, writeFile } from 'node:fs/promises' import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { config as loadEnv } from 'dotenv'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs' 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 } = const { BASELINE_TICKET_2022, ELECTION_YEAR_2022, FEDERAL_DEPUTY_OFFICE, HISTORICAL_SERIES_YEARS } =
await import('../src/lib/electionResults.ts') await import('../src/lib/electionResults.ts')
const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts') const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts')
const { municipalityElectionGeography } = await import( const { municipalityElectionGeography } =
'../src/utilities/municipalityElectionGeography.ts' await import('../src/utilities/municipalityElectionGeography.ts')
)
const { const {
loadCampoFederalVotesByCityZone, loadCampoFederalVotesByCityZone,
loadCandidateVotesByCityZone, loadCandidateVotesByCityZone,
@ -84,7 +83,11 @@ for (const year of HISTORICAL_SERIES_YEARS) {
loadCandidateVotesByCityZone(payload, cliReader, { year, candidateNumber }), loadCandidateVotesByCityZone(payload, cliReader, { year, candidateNumber }),
loadValidVotesByCityZone(payload, cliReader, { year }), loadValidVotesByCityZone(payload, cliReader, { year }),
loadCampoFederalVotesByCityZone(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) { 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 // 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). // "teto do campo" and roll-off — no majoritária seeded for 2014/2018 (E8 audit).
const majoritarianOffices = [ 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 = {} const majoritarianByOffice = {}

View file

@ -274,8 +274,13 @@ const { canonicalizeMunicipalityName, UnknownMunicipalityError, normalizeMunicip
const { municipalityCatalogEntriesForCity } = await import('../src/lib/municipalityCatalog.ts') const { municipalityCatalogEntriesForCity } = await import('../src/lib/municipalityCatalog.ts')
const { getMunicipalityFederalBaseline } = await import('../src/lib/bahiaElectionAggregates.ts') const { getMunicipalityFederalBaseline } = await import('../src/lib/bahiaElectionAggregates.ts')
const { territoryForCity } = await import('../src/lib/bahiaTerritories.ts') const { territoryForCity } = await import('../src/lib/bahiaTerritories.ts')
const { parseExpectationCell, mapSheetPriority, parseSheetNumber, parseSituationCell, splitNameCell } = const {
await import('../src/lib/projectionSheetParse.ts') parseExpectationCell,
mapSheetPriority,
parseSheetNumber,
parseSituationCell,
splitNameCell,
} = await import('../src/lib/projectionSheetParse.ts')
const { const {
getVoteEstimateOrderViolation, getVoteEstimateOrderViolation,
toVoteEstimateScenarioViewModel, toVoteEstimateScenarioViewModel,
@ -512,12 +517,12 @@ for (const sheetRow of canonicalRows.values()) {
priorityRaw: sheetRow.priorityRaw, priorityRaw: sheetRow.priorityRaw,
hasStrategy: Boolean( hasStrategy: Boolean(
sheetRow.situationRaw || sheetRow.situationRaw ||
sheetRow.dobradinhasRaw || sheetRow.dobradinhasRaw ||
sheetRow.leadershipsRaw || sheetRow.leadershipsRaw ||
sheetRow.advisorsRaw || sheetRow.advisorsRaw ||
sheetRow.nextStepsRaw || sheetRow.nextStepsRaw ||
sheetRow.observationRaw || sheetRow.observationRaw ||
prioritariaRow, prioritariaRow,
), ),
}) })
continue continue
@ -988,8 +993,11 @@ if (staffPlans.size > 0) {
const status = plan.existing const status = plan.existing
? `casa com usuário existente #${plan.existing.id} (${plan.existing.name}, ${plan.existing.role})` ? `casa com usuário existente #${plan.existing.id} (${plan.existing.name}, ${plan.existing.role})`
: `criar (papel ${plan.role}, sem credenciais)` : `criar (papel ${plan.role}, sem credenciais)`
const variants = plan.entry.variants.size > 1 ? ` · variantes: ${formatVariants(plan.entry)}` : '' const variants =
console.log(` ${plan.name}: ${status} · ${plan.entry.municipalityIds.size} município(s)${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) —') console.log('\n— Dobradinhas (deputados estaduais) —')
for (const plan of [...deputyPlans.values()].sort((a, b) => a.name.localeCompare(b.name))) { 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 status = plan.existing ? `casa com dobradinha existente #${plan.existing.id}` : 'criar'
const variants = plan.entry.variants.size > 1 ? ` · variantes: ${formatVariants(plan.entry)}` : '' const variants =
console.log(` ${plan.name}: ${status} · ${plan.entry.municipalityIds.size} município(s)${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) { 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)) { for (const skipped of skippedSegments.slice(0, 80)) {
console.log(` [${skipped.label}] ${skipped.column}: ${JSON.stringify(skipped.segment)}`) console.log(` [${skipped.label}] ${skipped.column}: ${JSON.stringify(skipped.segment)}`)
} }
@ -1180,7 +1193,13 @@ try {
municipalitiesUpdated += 1 municipalitiesUpdated += 1
} }
return { usersCreated, deputiesCreated, leadershipsCreated, leadershipsLinked, municipalitiesUpdated } return {
usersCreated,
deputiesCreated,
leadershipsCreated,
leadershipsLinked,
municipalitiesUpdated,
}
}) })
console.log( console.log(

View file

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

View file

@ -33,10 +33,10 @@
* TSE_CACHE_DIR=./data/tse pnpm db:seed:tse * 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 { 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 { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs' import { assertLocalDatabase } from './assert-local-database.mjs'
@ -46,13 +46,11 @@ loadEnv({ path: '.env' })
const config = (await import('../src/payload.config.ts')).default const config = (await import('../src/payload.config.ts')).default
const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts') const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts')
const { buildElectionResultsFromCsvRows, HISTORICAL_BASELINE_OFFICES } = await import( const { buildElectionResultsFromCsvRows, HISTORICAL_BASELINE_OFFICES } =
'../src/lib/electionResultsBuild.ts' await import('../src/lib/electionResultsBuild.ts')
)
const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.ts') const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.ts')
const { buildImportBundles, importElectionBundles } = await import( const { buildImportBundles, importElectionBundles } =
'../src/utilities/electionResultsImport.ts' await import('../src/utilities/electionResultsImport.ts')
)
const SUPPORTED_YEARS = [2014, 2018, 2022] 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 { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { getCampaignUser } from '@/utilities/campaignAuth' import { getCampaignUser } from '@/utilities/campaignAuth'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { import {
loadSupporterDetailConsentData, loadSupporterDetailConsentData,
loadSupporterDetailPageData, loadSupporterDetailPageData,

View file

@ -4,8 +4,8 @@ import { redirect } from 'next/navigation'
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge' import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { SupporterImportWizard } from '@/components/campaign/supporter/SupporterImportWizard' import { SupporterImportWizard } from '@/components/campaign/supporter/SupporterImportWizard'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { isCampaignCoordinator } from '@/utilities/campaignAccess' import { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
export default async function ImportSupportersPage() { export default async function ImportSupportersPage() {
const user = await getCampaignUser() 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 { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { SupporterForm } from '@/components/campaign/supporter/SupporterForm' import { SupporterForm } from '@/components/campaign/supporter/SupporterForm'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { getCampaignUser } from '@/utilities/campaignAuth' import { getCampaignUser } from '@/utilities/campaignAuth'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { loadSupporterCreatePageData } from '@/utilities/supporterPageData' import { loadSupporterCreatePageData } from '@/utilities/supporterPageData'
import { canAccessSupporterArea } from '@/utilities/supporterUi' 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 { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge' import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { formatBrazilianPhoneInput } from '@/lib/phone'
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor' import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
import { loadAdvisorDetail } from '@/utilities/advisorData' import { loadAdvisorDetail } from '@/utilities/advisorData'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess' import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth' import { getCampaignUser } from '@/utilities/campaignAuth'
import { formatBrazilianPhoneInput } from '@/lib/phone' import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions'
import {
sendAdvisorPasswordResetFormAction,
updateAdvisorProfileFormAction,
} from '../formActions'
type AdvisorDetailPageProps = { type AdvisorDetailPageProps = {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@ -35,8 +32,7 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro
const advisor = await loadAdvisorDetail(payload, advisorId) const advisor = await loadAdvisorDetail(payload, advisorId)
if (!advisor) notFound() if (!advisor) notFound()
const email = const email = advisor.email && !isPlanilhaPlaceholderEmail(advisor.email) ? advisor.email : null
advisor.email && !isPlanilhaPlaceholderEmail(advisor.email) ? advisor.email : null
return ( return (
<CampaignPageShell> <CampaignPageShell>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,16 +4,20 @@ import Link from 'next/link'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload' 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 { 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 { 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 { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess' import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth' 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 { loadLeadershipDetail } from '@/utilities/leadershipData'
import { updateLeadershipInternalFormAction } from './formActions' import { updateLeadershipInternalFormAction } from './formActions'

View file

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

View file

@ -4,20 +4,20 @@ import Link from 'next/link'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { import {
CampaignListPendingBoundary, CampaignListPendingBoundary,
CampaignListResults, CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending' } from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm' import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
import { import {
CampaignTable, CampaignTable,
CampaignTableHead, CampaignTableHead,
type CampaignTableColumn, type CampaignTableColumn,
} from '@/components/campaign/shared/CampaignTable' } 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 { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess' 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 { CandidateComparePicker } from '@/components/campaign/map/CandidateComparePicker'
import { MunicipalityBaselineCard } from '@/components/campaign/municipality/MunicipalityBaselineCard' import { MunicipalityBaselineCard } from '@/components/campaign/municipality/MunicipalityBaselineCard'
import { MunicipalityCandidateComparisonTable } from '@/components/campaign/municipality/MunicipalityCandidateComparisonTable' 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 { MunicipalityUpdateFeed } from '@/components/campaign/municipality/MunicipalityUpdateFeed'
import { MunicipalityUpdateForm } from '@/components/campaign/municipality/MunicipalityUpdateForm' import { MunicipalityUpdateForm } from '@/components/campaign/municipality/MunicipalityUpdateForm'
import { MunicipalityZoneNeighborhoodsCard } from '@/components/campaign/municipality/MunicipalityZoneNeighborhoodsCard' import { MunicipalityZoneNeighborhoodsCard } from '@/components/campaign/municipality/MunicipalityZoneNeighborhoodsCard'
import { CampaignListPagination } from '@/components/campaign/shared/CampaignListPagination'
import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate' import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { getCampaignUser } from '@/utilities/campaignAuth' import type { getCampaignUser } from '@/utilities/campaignAuth'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions' import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'

View file

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

View file

@ -1,12 +1,12 @@
import { Suspense } from 'react'
import config from '@payload-config' import config from '@payload-config'
import Link from 'next/link' import Link from 'next/link'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload' 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 { 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 { Badge } from '@/components/ui/Badge'
import { isCampaignLeader } from '@/utilities/campaignAccess' import { isCampaignLeader } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth' 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 { municipalityStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffEditMessages'
import { setMunicipalityExpectedVotes } from '@/app/(campaign)/campanha/actions/municipality' import { setMunicipalityExpectedVotes } from '@/app/(campaign)/campanha/actions/municipality'
import { positiveRelationshipId } from '@/lib/schemas/primitives' import { positiveRelationshipId } from '@/lib/schemas/primitives'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import { import {
CAMPAIGN_SESSION_EXPIRED_MESSAGE, CAMPAIGN_SESSION_EXPIRED_MESSAGE,
mapCampaignFormActionError, mapCampaignFormActionError,
} from '@/utilities/campaignFormActionError' } from '@/utilities/campaignFormActionError'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { MunicipalityListExpectedVotesResponse } from './types' import type { MunicipalityListExpectedVotesResponse } from './types'

View file

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

View file

@ -4,8 +4,8 @@ import Link from 'next/link'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm' import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge' import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { actionPlanStatusLabels, actionPlanStatuses } from '@/lib/schemas/actionPlan' 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> <Badge variant="secondary">{organizationKindLabels[organization.kind]}</Badge>
</div> </div>
{organization.municipalityNames.length ? ( {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} ) : null}
{organization.notes ? ( {organization.notes ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{organization.notes}</p> <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 { redirect } from 'next/navigation'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm' import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { isCampaignStaff } from '@/utilities/campaignAccess' import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth' import { getCampaignUser } from '@/utilities/campaignAuth'
@ -35,7 +35,10 @@ export default async function NewOrganizationPage() {
</p> </p>
</header> </header>
<OrganizationForm municipalityOptions={municipalityOptions} formAction={createOrganizationFormAction} /> <OrganizationForm
municipalityOptions={municipalityOptions}
formAction={createOrganizationFormAction}
/>
</CampaignPageShell> </CampaignPageShell>
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -35,7 +35,8 @@ export const createActionPlanUpdateFormAction = async (
return mapCampaignFormActionError({ return mapCampaignFormActionError({
error, error,
safeMessages, 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 { getPayload } from 'payload'
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox' import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
import { import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
isContactSearchQueryReady,
normalizeContactSearchQuery,
} from '@/lib/contactSearchQuery'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { import {
searchActionPlanLeadershipOptions, searchActionPlanLeadershipOptions,
type ActionPlanLeadershipOption, type ActionPlanLeadershipOption,
} from '@/utilities/actionPlanLeadershipOptions' } from '@/utilities/actionPlanLeadershipOptions'
import { getCampaignUser } from '@/utilities/campaignAuth'
const CONTACT_OPTION_LIMIT = 20 const CONTACT_OPTION_LIMIT = 20

View file

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

View file

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

View file

@ -100,7 +100,9 @@ const nextAdvisorIdsAfterMembership = (
if (assigned) { if (assigned) {
if (currentAdvisorIDs.length >= MAX_ADVISORS_PER_MUNICIPALITY) { 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] return [...currentAdvisorIDs, advisorId]
} }

View file

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

View file

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

View file

@ -2,11 +2,14 @@
import type { Payload } from 'payload' 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 type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { hookFilledCreateData } from '@/utilities/hookFilledData' import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
export const createMunicipalityUpdateRecord = async ( export const createMunicipalityUpdateRecord = async (
payload: Payload, payload: Payload,

View file

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

View file

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

View file

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

View file

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

View file

@ -8,8 +8,8 @@ import {
redeemCampaignInviteLoginFormAction, redeemCampaignInviteLoginFormAction,
} from '@/app/(campaign)/campanha/convite/[token]/formActions' } from '@/app/(campaign)/campanha/convite/[token]/formActions'
import { CampaignInviteForm } from '@/components/campaign/invite/CampaignInviteForm' import { CampaignInviteForm } from '@/components/campaign/invite/CampaignInviteForm'
import { ConsentText } from '@/components/campaign/shared/ConsentText'
import { InvalidCampaignInvite } from '@/components/campaign/invite/InvalidCampaignInvite' import { InvalidCampaignInvite } from '@/components/campaign/invite/InvalidCampaignInvite'
import { ConsentText } from '@/components/campaign/shared/ConsentText'
import { getCampaignInvitePageData } from '@/utilities/campaignInvitePageData' import { getCampaignInvitePageData } from '@/utilities/campaignInvitePageData'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@ -48,7 +48,9 @@ export default async function CampaignInvitePage({ params }: CampaignInvitePageP
preview.kind === 'login' preview.kind === 'login'
? redeemCampaignInviteLoginFormAction.bind(null, token) ? redeemCampaignInviteLoginFormAction.bind(null, token)
: redeemCampaignInviteAutofillFormAction.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 ( return (
<main className="flex min-h-screen items-center justify-center p-4 sm:p-6"> <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 { Badge } from '@/components/ui/Badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { getCachedGlobal } from '@/utilities/globals' import { getCachedGlobal } from '@/utilities/globals'
import { stripTrailingSlash } from '@/utilities/seo'
import { import {
POST_TYPE_LABELS, POST_TYPE_LABELS,
getCategoryName, getCategoryName,
@ -11,6 +10,7 @@ import {
getVisiblePosts, getVisiblePosts,
isPostType, isPostType,
} from '@/utilities/posts' } from '@/utilities/posts'
import { stripTrailingSlash } from '@/utilities/seo'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'

View file

@ -3,8 +3,8 @@ import { SiteHeader } from '@/components/SiteHeader'
import { Badge } from '@/components/ui/Badge' import { Badge } from '@/components/ui/Badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { getCachedGlobal } from '@/utilities/globals' import { getCachedGlobal } from '@/utilities/globals'
import { stripTrailingSlash } from '@/utilities/seo'
import { POST_TYPE_LABELS, getVisiblePosts, isPostType } from '@/utilities/posts' import { POST_TYPE_LABELS, getVisiblePosts, isPostType } from '@/utilities/posts'
import { stripTrailingSlash } from '@/utilities/seo'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { notFound } from 'next/navigation' 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 { 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 { 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 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 { Metadata } from 'next'
import type { Article, WithContext } from 'schema-dts'
import Link from 'next/link' import Link from 'next/link'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import type { Article, WithContext } from 'schema-dts'
const MAX_DESCRIPTION_LENGTH = 200 const MAX_DESCRIPTION_LENGTH = 200

View file

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

View file

@ -1,7 +1,7 @@
import { resolveRevalidateTag } from '@/utilities/revalidateRequest' import { resolveRevalidateTag } from '@/utilities/revalidateRequest'
import { timingSafeEqual } from 'node:crypto'
import { revalidateTag } from 'next/cache' import { revalidateTag } from 'next/cache'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { timingSafeEqual } from 'node:crypto'
/** /**
* On-demand revalidation endpoint for content written straight to the database * 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 url = new URL(request.url)
const queryTag = url.searchParams.get('tag') const queryTag = url.searchParams.get('tag')
const bodyResult = const bodyResult = queryTag != null && queryTag !== '' ? {} : await parseBodyTag(request)
queryTag != null && queryTag !== '' ? {} : await parseBodyTag(request)
if ('invalid' in bodyResult) { if ('invalid' in bodyResult) {
return NextResponse.json({ revalidated: false, error: 'Invalid JSON body' }, { status: 400 }) 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 { getCachedDocumentById } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals' 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 { Metadata } from 'next'
import { Inter } from 'next/font/google'
import React from 'react' import React from 'react'
import './styles.css' 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' }) 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. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from '@payload-config'
import '@payloadcms/next/css' import '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import type { ServerFunctionClient } from 'payload'
import React from 'react' import React from 'react'
import { importMap } from './admin/importMap.js' import { importMap } from './admin/importMap.js'

View file

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

View file

@ -13,6 +13,7 @@ import {
campaignDemandTransitions, campaignDemandTransitions,
type CampaignDemandStatus, type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand' } from '@/lib/schemas/campaignDemand'
import { slugify } from '@/lib/slug'
import { import {
canCreateCampaignDemand, canCreateCampaignDemand,
canDeleteCampaignDemand, canDeleteCampaignDemand,
@ -24,7 +25,6 @@ import {
isCampaignUnrestricted, isCampaignUnrestricted,
} from '@/utilities/campaignAccess' } from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship' import { relationshipId } from '@/utilities/relationship'
import { slugify } from '@/lib/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') 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 type { CampaignUser as CampaignUserDocument } from '@/payload-types'
import { import {
canCreateCampaignUserPhone, canCreateCampaignUserPhone,
@ -11,7 +12,6 @@ import {
canUpdateCampaignUserPhone, canUpdateCampaignUserPhone,
} from '@/utilities/campaignAccess' } from '@/utilities/campaignAccess'
import { buildCampaignPasswordResetUrl } from '@/utilities/campaignPasswordReset' import { buildCampaignPasswordResetUrl } from '@/utilities/campaignPasswordReset'
import { normalizeBrazilianPhone } from '@/lib/phone'
import { import {
APIError, APIError,
type CollectionAfterReadHook, type CollectionAfterReadHook,

View file

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

View file

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

View file

@ -5,6 +5,12 @@ import type {
} from 'payload' } from 'payload'
import { APIError } 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 { import {
canAssignMunicipalityAdvisors, canAssignMunicipalityAdvisors,
canCreateMunicipality, canCreateMunicipality,
@ -17,14 +23,8 @@ import {
canUpdateMunicipality, canUpdateMunicipality,
eligibleCampaignStaffWhere, eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess' } from '@/utilities/campaignAccess'
import { politicalTrendStatuses } from '@/lib/schemas/municipality'
import { politicalTrendLabels } from '@/utilities/municipalityLabels' import { politicalTrendLabels } from '@/utilities/municipalityLabels'
import { relationshipId } from '@/utilities/relationship' import { relationshipId } from '@/utilities/relationship'
import {
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
type VoteEstimateScenarioFields,
} from '@/lib/voteEstimate'
import { import {
voteEstimateScenarioGroupAccess, voteEstimateScenarioGroupAccess,
voteEstimateScenarioGroupFields, voteEstimateScenarioGroupFields,
@ -99,7 +99,10 @@ const validateMunicipalityAdvisors: CollectionBeforeValidateHook = async ({ data
}) })
if (eligibleAdvisors.docs.length !== advisorIDs.length) { 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 return data

View file

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

View file

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

View file

@ -1,6 +1,11 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload' import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
import { APIError } from 'payload' import { APIError } from 'payload'
import {
type VoteEstimateScenarioFields,
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
} from '@/lib/voteEstimate'
import { import {
canCreateVotePledge, canCreateVotePledge,
canDeleteVotePledge, canDeleteVotePledge,
@ -12,11 +17,6 @@ import {
payloadAdminOnly, payloadAdminOnly,
} from '@/utilities/campaignAccess' } from '@/utilities/campaignAccess'
import { relationshipId } from '@/utilities/relationship' import { relationshipId } from '@/utilities/relationship'
import {
type VoteEstimateScenarioFields,
getVoteEstimateOrderViolation,
VOTE_ESTIMATE_ORDER_ERROR_MESSAGE,
} from '@/lib/voteEstimate'
import { import {
voteEstimateScenarioGroupAccess, voteEstimateScenarioGroupAccess,
voteEstimateScenarioGroupFields, voteEstimateScenarioGroupFields,
@ -85,12 +85,17 @@ const validatePledgeIntegrity: CollectionBeforeChangeHook = async ({
overrideAccess: true, overrideAccess: true,
req, req,
}) })
const linkedMunicipalityIDs = (Array.isArray(leadership.municipalities) ? leadership.municipalities : []) const linkedMunicipalityIDs = (
Array.isArray(leadership.municipalities) ? leadership.municipalities : []
)
.map(relationshipId) .map(relationshipId)
.filter((id): id is number => id !== null) .filter((id): id is number => id !== null)
if (!linkedMunicipalityIDs.includes(municipalityID)) { 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 return data
@ -138,7 +143,13 @@ export const VotePledge: CollectionConfig = {
admin: { admin: {
group: 'Campanha', group: 'Campanha',
useAsTitle: 'id', useAsTitle: 'id',
defaultColumns: ['leadership', 'municipality', 'declaredVotes', 'estimatedVotes.central', 'updatedAt'], defaultColumns: [
'leadership',
'municipality',
'declaredVotes',
'estimatedVotes.central',
'updatedAt',
],
}, },
access: { access: {
create: canCreateVotePledge, create: canCreateVotePledge,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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