Polish /campanha Field Desk UX and register FD+/FD2 follow-ups.
Align the campaign desk with quieter KPI strips, queues-first dashboard, shared shells, and advanced filters; capture Impeccable design context and the remaining simplify/critique debts on the roadmap.
This commit is contained in:
parent
3b463173da
commit
e80f4030cb
40 changed files with 1830 additions and 538 deletions
11
.cursor/hooks.json
Normal file
11
.cursor/hooks.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"preToolUse": [
|
||||
{
|
||||
"command": "node \"/Users/francisco.solla/.cursor/skills/impeccable/scripts/hook-before-edit.mjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ alwaysApply: false
|
|||
- **Em 2026-07-19, C9 Escala e DRY pós-C8 entregue:** `supporterListFilters.ts` (filtro unificado Payload↔SQL + `contactSearchQuery`), `loadSupportersPageData` (1× lookup de núcleos do coordenador), `apoiadores/[id]/formActions` no mapper compartilhado, `fieldError` nos 8 componentes de form restantes. Plano: [`docs/plans/escala-dry-pos-c8.md`](../../../docs/plans/escala-dry-pos-c8.md).
|
||||
- **Em 2026-07-19, C10 Escala e DRY pós-C9 registrado** (após `/simplify` do C9): dedup de access na lista (`req.context`), query única de núcleos do coordenador, `errorProps` nos forms grandes. Plano: [`docs/plans/escala-dry-pos-c9.md`](../../../docs/plans/escala-dry-pos-c9.md).
|
||||
- **Em 2026-07-19, C11 Escala e DRY pós-C7 registrado** (após `/simplify` do C7): feed O(n) condicional, loaders do detalhe + `loadCampaignUserNamesById`, selects por aba, hot paths mutação/form, utilities de busca/label. Plano: [`docs/plans/escala-dry-pos-c7.md`](../../../docs/plans/escala-dry-pos-c7.md).
|
||||
- **Em 2026-07-19, Field Desk polish (Impeccable) + débitos FD+/FD2:** quieter→polish em `/campanha` (critique 28→32); shells/strips/freshness/filas-first. Débitos `/simplify` → **FD+** ([`escala-dry-pos-field-desk.md`](../../../docs/plans/escala-dry-pos-field-desk.md)); débitos de UX/produto do critique não implementados → **FD2** ([`field-desk-ux-pos-critique.md`](../../../docs/plans/field-desk-ux-pos-critique.md)).
|
||||
|
||||
## Decisões
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
---
|
||||
target: /campanha
|
||||
total_score: 28
|
||||
p0_count: 0
|
||||
p1_count: 3
|
||||
p1_count_note: filters, kpi-grid, side-tab
|
||||
p2_count: 2
|
||||
timestamp: 2026-07-19T10-15-47Z
|
||||
slug: src-app-campaign-campanha
|
||||
---
|
||||
Method: dual-agent (A: 89c6189f-1341-4e15-8d45-e1aa7c6f5167 · B: 3e87667e-e21e-4a29-85b4-db65afcc49c5)
|
||||
|
||||
# Critique: /campanha
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key Issue |
|
||||
|---|-----------|-------|-----------|
|
||||
| 1 | Visibility of System Status | 3 | Pending/aria-live on filters; no freshness/"as of" on KPI aggregates |
|
||||
| 2 | Match System / Real World | 4 | Domain language precise (Núcleo vs Zona TSE disambiguated) |
|
||||
| 3 | User Control and Freedom | 3 | Clear filters/back; limited undo for irreversible paths |
|
||||
| 4 | Consistency and Standards | 3 | Strong component discipline; role dashboards diverge in max-width/chrome |
|
||||
| 5 | Error Prevention | 3 | Constrained selects + inline ZE validation |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 10 | Help and Documentation | 1 | Almost no glossary/tooltips for ZE / engajado / estimativa |
|
||||
| **Total** | | **28/40** | **Good** |
|
||||
|
||||
## Anti-Patterns Verdict
|
||||
|
||||
**LLM assessment:** Not classic AI slop. Theme quarantine (`data-theme='campaign'` + `bg-none`) and semantic status tokens are deliberate. The tell is the repeated **hero-metric Card grid** (dashboard / núcleos overview / apoiadores) — exactly the anti-reference named in PRODUCT.md/DESIGN.md — plus icon-per-button and copy-pasted page shells.
|
||||
|
||||
**Deterministic scan (CLI):** 1 finding — `side-tab` (`border-l-4 border-primary`) in `src/components/campaign/NucleusElectoralBaseline.tsx:79`. Real hit, not a false positive; absolute ban in DESIGN.md.
|
||||
|
||||
**Visual overlays:** Injection succeeded on the live dashboard (session redirected login → `/campanha`). Overlay flagged `overused-font` / `single-font` (Inter-by-design → false positive), `layout-transition` (unverified), and many `nested-cards` (shadcn Card/CardHeader class-name false positives). No reliable additional anti-pattern beyond the CLI `side-tab` and the LLM-noted KPI grid density.
|
||||
|
||||
## Overall Impression
|
||||
|
||||
A serious field desk with strong a11y/touch discipline and honest empty-state copy — undermined by a template dashboard of identical metric cards, an always-open 6-filter wall on núcleos, and a leadership empty state that strands the persona the product calls a peer. Biggest opportunity: make hierarchy and triage decisions for the user instead of presenting equal-weight problem scoreboards.
|
||||
|
||||
## What's Working
|
||||
|
||||
1. **Theme quarantine is real** — campaign tokens + `bg-none` kill public-site gradient kitsch.
|
||||
2. **Systemic touch/a11y** — `min-h-11`, live regions on filters, proper `aria-invalid` on ZE input.
|
||||
3. **Scope-honest empty/error copy** — "você só vê núcleos no seu escopo"; phone-only reset routes to coordinator.
|
||||
|
||||
## Priority Issues
|
||||
|
||||
### [P1] Filter panel decision overload
|
||||
**What:** `NucleusFilters` shows 6 fields at once on desktop (TI, município, ZE, cobertura, estimativa, prioridade) plus search.
|
||||
**Why it matters:** Primary triage tool under time pressure; violates clarity-under-pressure and >4 options rule.
|
||||
**Fix:** Keep territory + search primary; put coverage/estimate/priority behind "Mais filtros" at all breakpoints.
|
||||
**Suggested command:** `/impeccable distill` / `/impeccable layout`
|
||||
|
||||
### [P1] Hero-metric Card grids contradict own anti-references
|
||||
**What:** General dashboard, NucleusListOverview, SupporterListOverview all use Card → description → big number grids.
|
||||
**Why it matters:** PRODUCT/DESIGN ban identical card grids / hero-metric templates; this is the strongest "template AI" tell.
|
||||
**Fix:** Compact secondary metrics into a stat row; reserve Card + priority lift for 1–2 decisive numbers; rank action queues.
|
||||
**Suggested command:** `/impeccable quieter` / `/impeccable layout`
|
||||
|
||||
### [P1] Side-tab accent on baseline row (detector)
|
||||
**What:** `border-l-4 border-primary` on highlighted candidate in NucleusElectoralBaseline.
|
||||
**Why it matters:** Absolute ban (side-stripe >1px); deterministic AI-tell.
|
||||
**Fix:** Full border, background tint only, or leading mark — no thick left stripe.
|
||||
**Suggested command:** `/impeccable quieter`
|
||||
|
||||
### [P2] Liderança empty state is a dead end
|
||||
**What:** "Nenhum núcleo" / "vínculo engajado" + warning icon, no CTA.
|
||||
**Why it matters:** Peer persona stranded; jargon without next step.
|
||||
**Fix:** Mirror forgot-password — name who to contact (coordenador / WhatsApp).
|
||||
**Suggested command:** `/impeccable clarify` / `/impeccable onboard`
|
||||
|
||||
### [P2] No freshness signal on aggregates
|
||||
**What:** KPIs/overviews never show when data was computed.
|
||||
**Why it matters:** Field decisions need trust in recency.
|
||||
**Fix:** Relative "atualizado há…" near overview headers.
|
||||
**Suggested command:** `/impeccable harden`
|
||||
|
||||
### [P3] Split brand chrome (mobile red bar vs desktop stone rail)
|
||||
**What:** Mobile top bar is solid Mandate Red; desktop sidebar is quiet rail.
|
||||
**Why it matters:** Breaks Signal Red Rule for Casey/Lia switching devices unless intentional "field mode."
|
||||
**Fix:** Document as field-mode signal or quiet the mobile chrome.
|
||||
**Suggested command:** `/impeccable quieter`
|
||||
|
||||
## Persona Red Flags
|
||||
|
||||
**Alex (geral/coordenador):** No saved filter views; queues are one-by-one with equal weight — must supply triage the UI should do.
|
||||
|
||||
**Casey (mobile field):** Opened accordion still stacks 6 full-width fields; no ambient stale/offline cue in shell; primary CTAs scroll away.
|
||||
|
||||
**Lia (liderança phone-first):** Reset and unlinked-núcleo paths both require a more-privileged human; dashboard opens on "Sem estimativa confirmada" as the boldest line; jargon (engajado, ZE) unexplained.
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- Leadership empty uses `AlertTriangleIcon` in wrong Empty slot vs `EmptyMedia`.
|
||||
- Three dashboard max-widths (`2xl` / `5xl` / `4xl`) without clear intent.
|
||||
- Dual relative-time helpers.
|
||||
- `Badge secondary` overloaded (empty count vs category).
|
||||
- Icon-on-every-button may be decorative filler vs confident-compact.
|
||||
|
||||
## Cognitive Load
|
||||
|
||||
2–3 checklist failures: 6-filter wall; geral dashboard lacks progressive disclosure; no single primary "do this now." Moderate load — address soon.
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
1. Why do dashboards converge on the banned hero-metric grid three times — exception or unreviewed?
|
||||
2. Is Lia's human-gated recovery acceptable honesty, or quiet demotion?
|
||||
3. Should the UI pick which fire to put out first among the three equal queues?
|
||||
4. Is the mobile red bar deliberate field-mode, or Signal Red Rule drift?
|
||||
|
||||
## Emotional Journey
|
||||
|
||||
Peak: confirmed estimate / successful update. End/valley: leadership dashboard often closes on absence ("Sem estimativa confirmada"); unlinked empty is a hard stop; problem-scoreboard queues create ambient anxiety without priority.
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
target: /campanha
|
||||
total_score: 32
|
||||
p0_count: 0
|
||||
p1_count: 0
|
||||
p2_count: 3
|
||||
p3_count: 2
|
||||
timestamp: 2026-07-19T11-03-23Z
|
||||
slug: src-app-campaign-campanha
|
||||
---
|
||||
Method: dual-agent (A: 43c7ddf0-03c4-40b6-86e4-167ed820fbc3 · B: c685e95f-8884-4bd6-9174-53d1a4613689)
|
||||
|
||||
# Critique: /campanha
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key Issue |
|
||||
|---|-----------|-------|-----------|
|
||||
| 1 | Visibility of System Status | 4 | Freshness on KPI surfaces; filter aria-busy/live region |
|
||||
| 2 | Match System / Real World | 4 | Núcleo vs Zona TSE stated on list; domain language solid |
|
||||
| 3 | User Control and Freedom | 4 | Differentiated AlertDialogs; clear filter clear/back |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 10 | Help and Documentation | 2 | One Núcleo/ZE line; engajado / estimativa vocabulary still unexplained |
|
||||
| **Total** | | **32/40** | **Good** |
|
||||
|
||||
## Anti-Patterns Verdict
|
||||
|
||||
**LLM assessment:** Not AI slop. Field Desk intent lands: CampaignMetricStrip (explicit anti-grid), priority surface reserved for one panel, theme quarantine, semantic badges, side-tab ban verified clean in source.
|
||||
|
||||
**Deterministic scan (CLI):** 0 findings on `src/app/(campaign)/campanha` and `src/components/campaign` (exit 0). Regex engine is blind to DOM/heading order and Tailwind arbitrary transitions — under-covers by design.
|
||||
|
||||
**Visual overlays:** Injection succeeded on live dashboard (session authenticated as geral). Overlay: overused-font/single-font Inter (false positive by design); nested-cards on CardHeader (false positive); nested-cards on CampaignMetricStrip `<dl>` (real — card-in-card styling); skipped-heading h1→h3 (real); layout-transition height on Sidebar/Drawer primitives (real, systemic).
|
||||
|
||||
## Overall Impression
|
||||
|
||||
Prior P1s (filter wall, hero-metric card grids, side-tab) are closed. The surface now reads as a serious ops desk with hierarchy and progressive disclosure. Remaining drag is power-user throughput (bulk triage), first-timer jargon, and a few a11y/composition leftovers the CLI cannot see.
|
||||
|
||||
## What's Working
|
||||
|
||||
1. **CampaignMetricStrip + priority lift** — shared replacement for the banned identical KPI card grid across dashboard, núcleos, apoiadores.
|
||||
2. **NucleusFilters progressive disclosure** — primary territory + search; cobertura/estimativa/prioridade behind Mais filtros with count badge.
|
||||
3. **Side-tab ban + shell convergence** — no `border-l-*` accents in campaign components; list/dashboard share CampaignPageShell.
|
||||
|
||||
## Priority Issues
|
||||
|
||||
### [P2] Domain jargon still opaque beyond one sentence
|
||||
**What:** engajado, estimativa confirmada vs proposta, support statuses lack tooltips/help.
|
||||
**Why it matters:** Lia and first-timers translate under pressure; Help heuristic still weakest.
|
||||
**Fix:** Inline ? / first-visit hint on SupportStatusBadge and estimate labels.
|
||||
**Suggested command:** `/impeccable clarify`
|
||||
|
||||
### [P2] Dashboard queues cannot be triaged in bulk
|
||||
**What:** Sem coordenador / stale updates / pending estimates are one-row links only.
|
||||
**Why it matters:** Alex under time pressure on a growing queue; CSV bulk exists for import but not daily triage.
|
||||
**Fix:** Multi-select + assign on the priority queue only.
|
||||
**Suggested command:** `/impeccable layout`
|
||||
|
||||
### [P2] Dashboard heading outline skips h2
|
||||
**What:** Detector + source: h1 "Visão geral" then QueueSection h3 before Indicadores h2.
|
||||
**Why it matters:** Screen-reader document outline; Sam persona.
|
||||
**Fix:** Promote Filas section title to h2, demote queue titles to h3; or insert sr-only/visible h2.
|
||||
**Suggested command:** `/impeccable audit`
|
||||
|
||||
### [P3] Coordenador empty state has no CTA
|
||||
**What:** Prose-only empty vs leadership empty with Ver meu perfil.
|
||||
**Why it matters:** Last sibling of the old dead-end pattern.
|
||||
**Fix:** Mirror EmptyContent CTA pattern.
|
||||
**Suggested command:** `/impeccable onboard`
|
||||
|
||||
### [P3] MetricStrip double card surface + sidebar height transition
|
||||
**What:** Overlay nested-cards on strip `bg-card` inside `bg-card`; Sidebar `transition-[width,height,padding]`.
|
||||
**Why it matters:** Mild visual nesting / layout jank risk — not task-blocking.
|
||||
**Fix:** Flatten inner metric cells to transparent; prefer transform/opacity on chrome.
|
||||
**Suggested command:** `/impeccable quieter` / `/impeccable optimize`
|
||||
|
||||
## Persona Red Flags
|
||||
|
||||
**Alex (geral/coordenador):** Still one-by-one queues; no saved filter views.
|
||||
|
||||
**Casey (mobile field):** Touch targets OK; no ambient offline/stale cue in shell during normal use (only /campanha/offline hard fallback).
|
||||
|
||||
**Lia (liderança):** Empty state improved with CTA; card still leads with Sem estimativa confirmada; engajado unexplained.
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- AlertTriangle on routine leadership empty is tonally alarmist for a peer first-run.
|
||||
- Detail/form pages still hand-roll max-w-3xl/4xl/6xl outside CampaignPageShell.
|
||||
- Icon-on-every-button reads intentional (confident-compact), not filler.
|
||||
|
||||
## Cognitive Load
|
||||
|
||||
2 checklist failures (no bulk/saved views; jargon). Primary NucleusFilters decision point ≤4 visible options. Moderate → low-moderate.
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
1. Is bulk triage intentionally deferred because "onboarding is bulk, ops is retail"?
|
||||
2. Should CampaignPageShell grow size variants, or stay list/dashboard-only?
|
||||
3. Is coordenador empty prose-only deliberate (escalate verbally) vs leadership self-service?
|
||||
4. How much inline glossary is enough before a help surface?
|
||||
|
||||
## Emotional Journey
|
||||
|
||||
Peak: estimate confirm / archive with consequence-clear dialogs. Valley: liderança card absence line; coordenador empty without next action.
|
||||
382
.impeccable/design.json
Normal file
382
.impeccable/design.json
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
{
|
||||
"schemaVersion": 2,
|
||||
"generatedAt": "2026-07-19T10:03:00.000Z",
|
||||
"title": "Design System: Teqo",
|
||||
"extensions": {
|
||||
"colorMeta": {
|
||||
"primary": {
|
||||
"role": "primary",
|
||||
"displayName": "Mandate Red",
|
||||
"canonical": "#c51414",
|
||||
"tonalRamp": [
|
||||
"#4a0808",
|
||||
"#7a0c0c",
|
||||
"#a01010",
|
||||
"#c51414",
|
||||
"#d64545",
|
||||
"#e57a7a",
|
||||
"#f2b0b0",
|
||||
"#fce8e8"
|
||||
]
|
||||
},
|
||||
"brand-header": {
|
||||
"role": "primary",
|
||||
"displayName": "Header Crimson",
|
||||
"canonical": "#ae1603",
|
||||
"tonalRamp": [
|
||||
"#3d0801",
|
||||
"#6b0e02",
|
||||
"#8c1202",
|
||||
"#ae1603",
|
||||
"#c84a3a",
|
||||
"#e08a7a",
|
||||
"#f0c4bc",
|
||||
"#fbecea"
|
||||
]
|
||||
},
|
||||
"background": {
|
||||
"role": "neutral",
|
||||
"displayName": "Desk White",
|
||||
"canonical": "#ffffff",
|
||||
"tonalRamp": [
|
||||
"#1c1917",
|
||||
"#44403c",
|
||||
"#78716c",
|
||||
"#a8a29e",
|
||||
"#d6d3d1",
|
||||
"#e7e5e4",
|
||||
"#f5f5f4",
|
||||
"#ffffff"
|
||||
]
|
||||
},
|
||||
"foreground": {
|
||||
"role": "neutral",
|
||||
"displayName": "Ink Stone",
|
||||
"canonical": "#1c1917",
|
||||
"tonalRamp": [
|
||||
"#0c0a09",
|
||||
"#1c1917",
|
||||
"#292524",
|
||||
"#44403c",
|
||||
"#78716c",
|
||||
"#a8a29e",
|
||||
"#d6d3d1",
|
||||
"#f5f5f4"
|
||||
]
|
||||
},
|
||||
"muted": {
|
||||
"role": "neutral",
|
||||
"displayName": "Quiet Wash",
|
||||
"canonical": "#f4f4f5",
|
||||
"tonalRamp": [
|
||||
"#27272a",
|
||||
"#3f3f46",
|
||||
"#71717a",
|
||||
"#a1a1aa",
|
||||
"#d4d4d8",
|
||||
"#e4e4e7",
|
||||
"#f4f4f5",
|
||||
"#fafafa"
|
||||
]
|
||||
},
|
||||
"muted-foreground": {
|
||||
"role": "neutral",
|
||||
"displayName": "Quiet Ink",
|
||||
"canonical": "#6b7280",
|
||||
"tonalRamp": [
|
||||
"#1f2937",
|
||||
"#374151",
|
||||
"#4b5563",
|
||||
"#6b7280",
|
||||
"#9ca3af",
|
||||
"#d1d5db",
|
||||
"#e5e7eb",
|
||||
"#f3f4f6"
|
||||
]
|
||||
},
|
||||
"secondary": {
|
||||
"role": "secondary",
|
||||
"displayName": "Stone Wash",
|
||||
"canonical": "#f5f5f4",
|
||||
"tonalRamp": [
|
||||
"#1c1917",
|
||||
"#292524",
|
||||
"#57534e",
|
||||
"#a8a29e",
|
||||
"#d6d3d1",
|
||||
"#e7e5e4",
|
||||
"#f5f5f4",
|
||||
"#fafaf9"
|
||||
]
|
||||
},
|
||||
"destructive": {
|
||||
"role": "primary",
|
||||
"displayName": "Alert Crimson",
|
||||
"canonical": "#b42318",
|
||||
"tonalRamp": [
|
||||
"#4a0f0c",
|
||||
"#7a1710",
|
||||
"#9a1e15",
|
||||
"#b42318",
|
||||
"#d45a52",
|
||||
"#e89a94",
|
||||
"#f5d0cd",
|
||||
"#fceceb"
|
||||
]
|
||||
},
|
||||
"sidebar": {
|
||||
"role": "neutral",
|
||||
"displayName": "Rail Mist",
|
||||
"canonical": "#fafaf9",
|
||||
"tonalRamp": [
|
||||
"#1c1917",
|
||||
"#44403c",
|
||||
"#78716c",
|
||||
"#a8a29e",
|
||||
"#d6d3d1",
|
||||
"#e7e5e4",
|
||||
"#f5f5f4",
|
||||
"#fafaf9"
|
||||
]
|
||||
},
|
||||
"scope": {
|
||||
"role": "tertiary",
|
||||
"displayName": "Territory Peach",
|
||||
"canonical": "#fff5ed",
|
||||
"tonalRamp": [
|
||||
"#3b180c",
|
||||
"#6b2d15",
|
||||
"#9a5230",
|
||||
"#c48a68",
|
||||
"#e8c5ae",
|
||||
"#f5ddcc",
|
||||
"#fff5ed",
|
||||
"#fffaf6"
|
||||
]
|
||||
},
|
||||
"support-engaged": {
|
||||
"role": "secondary",
|
||||
"displayName": "Engaged Green",
|
||||
"canonical": "#dcfce7",
|
||||
"tonalRamp": [
|
||||
"#052e16",
|
||||
"#166534",
|
||||
"#15803d",
|
||||
"#4ade80",
|
||||
"#86efac",
|
||||
"#bbf7d0",
|
||||
"#dcfce7",
|
||||
"#f0fdf4"
|
||||
]
|
||||
},
|
||||
"estimate-pending": {
|
||||
"role": "secondary",
|
||||
"displayName": "Pending Amber",
|
||||
"canonical": "#fef3c7",
|
||||
"tonalRamp": [
|
||||
"#451a03",
|
||||
"#92400e",
|
||||
"#b45309",
|
||||
"#f59e0b",
|
||||
"#fcd34d",
|
||||
"#fde68a",
|
||||
"#fef3c7",
|
||||
"#fffbeb"
|
||||
]
|
||||
},
|
||||
"petition-cta": {
|
||||
"role": "tertiary",
|
||||
"displayName": "Petition Gold",
|
||||
"canonical": "#ffcf2e",
|
||||
"tonalRamp": [
|
||||
"#332300",
|
||||
"#664600",
|
||||
"#997000",
|
||||
"#cc9a00",
|
||||
"#ffcf2e",
|
||||
"#ffdb66",
|
||||
"#ffe9a3",
|
||||
"#fff6d6"
|
||||
]
|
||||
}
|
||||
},
|
||||
"typographyMeta": {
|
||||
"headline": {
|
||||
"displayName": "Headline",
|
||||
"purpose": "Campaign page and section titles; left-aligned, fixed rem."
|
||||
},
|
||||
"title": {
|
||||
"displayName": "Title",
|
||||
"purpose": "Card and dialog titles."
|
||||
},
|
||||
"body": {
|
||||
"displayName": "Body",
|
||||
"purpose": "Default UI copy at text-sm."
|
||||
},
|
||||
"label": {
|
||||
"displayName": "Label",
|
||||
"purpose": "Badges, meta, compact controls."
|
||||
}
|
||||
},
|
||||
"shadows": [
|
||||
{
|
||||
"name": "flat-desk",
|
||||
"value": "none",
|
||||
"purpose": "Default resting state for lists, forms, and most cards."
|
||||
},
|
||||
{
|
||||
"name": "hairline-ring",
|
||||
"value": "0 0 0 1px rgb(28 25 23 / 10%)",
|
||||
"purpose": "Card separation without drop shadow (ring-1 ring-foreground/10)."
|
||||
},
|
||||
{
|
||||
"name": "priority-lift",
|
||||
"value": "0 4px 24px rgb(28 25 23 / 8%)",
|
||||
"purpose": "Soft ambient lift for overview/insight panels only."
|
||||
},
|
||||
{
|
||||
"name": "overlay",
|
||||
"value": "0 16px 48px rgb(28 25 23 / 16%)",
|
||||
"purpose": "Dialogs, sheets, drawers, popovers."
|
||||
}
|
||||
],
|
||||
"motion": [
|
||||
{
|
||||
"name": "ease-state",
|
||||
"value": "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
"purpose": "State transitions 150–250ms; ease-out expo family."
|
||||
},
|
||||
{
|
||||
"name": "duration-fast",
|
||||
"value": "150ms",
|
||||
"purpose": "Hover, focus, press feedback."
|
||||
},
|
||||
{
|
||||
"name": "duration-standard",
|
||||
"value": "200ms",
|
||||
"purpose": "Default UI state changes."
|
||||
}
|
||||
],
|
||||
"breakpoints": [
|
||||
{ "name": "2xs", "value": "23.75rem" },
|
||||
{ "name": "xs", "value": "26.25rem" },
|
||||
{ "name": "sm", "value": "40rem" },
|
||||
{ "name": "md", "value": "48rem" },
|
||||
{ "name": "lg", "value": "64rem" }
|
||||
]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"name": "Primary Button",
|
||||
"kind": "button",
|
||||
"refersTo": "button-primary",
|
||||
"description": "Mandate Red CTA — the one clear next action on a region.",
|
||||
"html": "<button class=\"ds-btn-primary\" type=\"button\">Salvar</button>",
|
||||
"css": ".ds-btn-primary { display: inline-flex; align-items: center; justify-content: center; height: 32px; padding: 0 10px; border: none; border-radius: 8px; background: #c51414; color: #ffffff; font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: background 150ms cubic-bezier(0.16, 1, 0.3, 1), transform 150ms cubic-bezier(0.16, 1, 0.3, 1); } .ds-btn-primary:hover { background: color-mix(in srgb, #c51414 80%, transparent); } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, #c51414 50%, transparent); } .ds-btn-primary:active { transform: translateY(1px); }"
|
||||
},
|
||||
{
|
||||
"name": "Secondary Button",
|
||||
"kind": "button",
|
||||
"refersTo": "button-secondary",
|
||||
"description": "Stone wash secondary action.",
|
||||
"html": "<button class=\"ds-btn-secondary\" type=\"button\">Cancelar</button>",
|
||||
"css": ".ds-btn-secondary { display: inline-flex; align-items: center; justify-content: center; height: 32px; padding: 0 10px; border: none; border-radius: 8px; background: #f5f5f4; color: #292524; font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: background 150ms cubic-bezier(0.16, 1, 0.3, 1); } .ds-btn-secondary:hover { background: color-mix(in srgb, #f5f5f4 80%, #1c1917); } .ds-btn-secondary:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, #c51414 50%, transparent); }"
|
||||
},
|
||||
{
|
||||
"name": "Outline Button",
|
||||
"kind": "button",
|
||||
"refersTo": "button-outline",
|
||||
"description": "Bordered quiet action on the desk.",
|
||||
"html": "<button class=\"ds-btn-outline\" type=\"button\">Filtrar</button>",
|
||||
"css": ".ds-btn-outline { display: inline-flex; align-items: center; justify-content: center; height: 32px; padding: 0 10px; border: 1px solid #e7e5e4; border-radius: 8px; background: #ffffff; color: #1c1917; font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: background 150ms cubic-bezier(0.16, 1, 0.3, 1); } .ds-btn-outline:hover { background: #f4f4f5; } .ds-btn-outline:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, #c51414 50%, transparent); }"
|
||||
},
|
||||
{
|
||||
"name": "Campaign Input",
|
||||
"kind": "input",
|
||||
"refersTo": "input-field",
|
||||
"description": "Compact field with visible stone border; campaign radius-md.",
|
||||
"html": "<input class=\"ds-input\" type=\"text\" placeholder=\"Buscar núcleo\" />",
|
||||
"css": ".ds-input { width: 100%; height: 32px; padding: 0 12px; border: 1px solid #d6d3d1; border-radius: 6px; background: #ffffff; color: #1c1917; font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-size: 0.875rem; outline: none; transition: border-color 150ms, box-shadow 150ms; } .ds-input::placeholder { color: rgb(28 25 23 / 50%); } .ds-input:focus-visible { border-color: #c51414; box-shadow: 0 0 0 3px color-mix(in srgb, #c51414 30%, transparent); } .ds-input:disabled { background: #f5f5f4; color: rgb(28 25 23 / 55%); cursor: not-allowed; }"
|
||||
},
|
||||
{
|
||||
"name": "Status Badge",
|
||||
"kind": "chip",
|
||||
"refersTo": "badge-status",
|
||||
"description": "Pill status chip — engaged / pending / negative via semantic tokens.",
|
||||
"html": "<span class=\"ds-badge ds-badge-engaged\">Engajado</span> <span class=\"ds-badge ds-badge-pending\">Pendente</span>",
|
||||
"css": ".ds-badge { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: 9999px; font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-size: 0.75rem; font-weight: 500; white-space: nowrap; } .ds-badge-engaged { background: #dcfce7; color: #166534; } .ds-badge-pending { background: #fef3c7; color: #92400e; }"
|
||||
},
|
||||
{
|
||||
"name": "Desk Card",
|
||||
"kind": "card",
|
||||
"refersTo": "card-surface",
|
||||
"description": "Flat card with hairline ring — default container on the field desk.",
|
||||
"html": "<div class=\"ds-card\"><div class=\"ds-card-title\">Núcleo Centro</div><p class=\"ds-card-body\">Estimativa confirmada · 3 lideranças</p></div>",
|
||||
"css": ".ds-card { display: flex; flex-direction: column; gap: 4px; padding: 16px; border-radius: 12px; background: #ffffff; color: #1c1917; box-shadow: 0 0 0 1px rgb(28 25 23 / 10%); font-family: Inter, ui-sans-serif, system-ui, sans-serif; } .ds-card-title { font-size: 1rem; font-weight: 500; line-height: 1.375; } .ds-card-body { margin: 0; font-size: 0.875rem; color: #6b7280; line-height: 1.5; }"
|
||||
},
|
||||
{
|
||||
"name": "Campaign Mobile Nav",
|
||||
"kind": "nav",
|
||||
"refersTo": "button-primary",
|
||||
"description": "Mobile top bar in Mandate Red with white wordmark.",
|
||||
"html": "<header class=\"ds-nav-mobile\"><span class=\"ds-nav-title\">Jorge Solla</span><span class=\"ds-nav-sub\">Campanha</span></header>",
|
||||
"css": ".ds-nav-mobile { display: flex; flex-direction: column; justify-content: center; min-height: 56px; padding: 0 16px; background: #c51414; color: #ffffff; font-family: Inter, ui-sans-serif, system-ui, sans-serif; } .ds-nav-title { font-size: 0.875rem; font-weight: 600; } .ds-nav-sub { font-size: 0.75rem; color: rgb(255 255 255 / 80%); }"
|
||||
}
|
||||
],
|
||||
"narrative": {
|
||||
"northStar": "The Field Desk",
|
||||
"overview": "Teqo’s visual system is a sober ops surface for people organizing under pressure. Design defaults to `/campanha`: stone-white surfaces, Inter, and brand red as a signal (primary actions, selection, status urgency) — never as spectacle or brochure chrome. Staff screens can be information-dense; leadership paths stay phone-first with larger touch targets and obvious next actions.\n\nPublic themes (`editorial`, `petition`, and the home brand-red body) share the same token contract and primary red, but they are surface variants. When inventing new UI, start from the campaign desk unless the task is explicitly a public/marketing page.\n\nThe system rejects generic SaaS dashboards (purple gradients, hero-metric templates, identical card grids, AI-startup chrome) and traditional Brazilian campaign kitsch (clipart flags, shouty all-caps, brochure clutter). Sovereignty and clarity beat decoration.",
|
||||
"keyCharacteristics": [
|
||||
"Product register: familiar shadcn affordances, earned consistency over surprise",
|
||||
"Restrained color strategy: neutrals dominate; primary red ≤ ~10% of any screen",
|
||||
"Hybrid elevation: flat lists/forms; light lift only for priority or overlay surfaces",
|
||||
"Confident and compact controls — denser desktop, phone-first leadership paths",
|
||||
"Semantic status colors (support / estimate / cadence) carry meaning, not fashion"
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"name": "The Signal Red Rule",
|
||||
"body": "Mandate Red is for primary action, focus, and urgency. It must not wash backgrounds, decorate cards, or paint inactive chrome.",
|
||||
"section": "colors"
|
||||
},
|
||||
{
|
||||
"name": "The Theme Contract Rule",
|
||||
"body": "New surfaces reuse the shadcn token names (`primary`, `muted`, `field-*`, etc.). Do not introduce one-off hex outside a `data-theme` block.",
|
||||
"section": "colors"
|
||||
},
|
||||
{
|
||||
"name": "The One Family Rule",
|
||||
"body": "Inter everywhere in product UI. No decorative display faces in buttons, labels, or data.",
|
||||
"section": "typography"
|
||||
},
|
||||
{
|
||||
"name": "The Fixed Scale Rule",
|
||||
"body": "Prefer rem/Tailwind steps over fluid clamp() headings inside the campaign shell.",
|
||||
"section": "typography"
|
||||
},
|
||||
{
|
||||
"name": "The Flat-By-Default Rule",
|
||||
"body": "Surfaces are flat at rest. Lift appears for priority or overlay — never as default card decoration across a grid.",
|
||||
"section": "elevation"
|
||||
}
|
||||
],
|
||||
"dos": [
|
||||
"Do treat `/campanha` as the design default: stone desk, Inter, Signal Red for the next action.",
|
||||
"Do keep primary red rare — one clear CTA per region beats a sea of red buttons.",
|
||||
"Do use semantic Badge tokens for support/estimate/cadence state.",
|
||||
"Do keep leadership paths phone-first: obvious actions, larger taps, plain language.",
|
||||
"Do meet WCAG 2.2 AA (contrast, keyboard, focus rings, prefers-reduced-motion).",
|
||||
"Do reuse shadcn primitives (Button, Input, Badge, Card, dialogs/sheets) for consistency."
|
||||
],
|
||||
"donts": [
|
||||
"Don't ship generic SaaS dashboards — purple gradients, hero-metric templates, identical card grids, AI-startup chrome.",
|
||||
"Don't ship traditional Brazilian campaign kitsch — clipart flags, shouty all-caps, brochure clutter.",
|
||||
"Don't use side-stripe borders (border-left/border-right > 1px) as accent decoration.",
|
||||
"Don't use gradient text or glassmorphism as default chrome.",
|
||||
"Don't put tiny uppercase tracked eyebrows above every section.",
|
||||
"Don't use Petition Gold or home body gradients inside the campaign desk.",
|
||||
"Don't invent display fonts for UI labels, buttons, or data.",
|
||||
"Don't lift every card; flat is the desk's resting state."
|
||||
]
|
||||
}
|
||||
}
|
||||
9
.impeccable/live/config.json
Normal file
9
.impeccable/live/config.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"files": [
|
||||
"src/app/(frontend)/layout.tsx",
|
||||
"src/app/(campaign)/layout.tsx"
|
||||
],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "jsx",
|
||||
"cspChecked": true
|
||||
}
|
||||
279
DESIGN.md
Normal file
279
DESIGN.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
---
|
||||
name: Teqo
|
||||
description: Field ops and public voice for Jorge Solla — campaign desk first, owned channel second.
|
||||
colors:
|
||||
primary: '#c51414'
|
||||
primary-foreground: '#ffffff'
|
||||
brand-header: '#ae1603'
|
||||
brand-header-foreground: '#fff8f2'
|
||||
background: '#ffffff'
|
||||
foreground: '#1c1917'
|
||||
card: '#ffffff'
|
||||
muted: '#f4f4f5'
|
||||
muted-foreground: '#6b7280'
|
||||
secondary: '#f5f5f4'
|
||||
secondary-foreground: '#292524'
|
||||
accent: '#f4f4f5'
|
||||
accent-foreground: '#1c1917'
|
||||
border: '#e7e5e4'
|
||||
input: '#d6d3d1'
|
||||
ring: '#c51414'
|
||||
destructive: '#b42318'
|
||||
sidebar: '#fafaf9'
|
||||
sidebar-foreground: '#1c1917'
|
||||
scope: '#fff5ed'
|
||||
scope-foreground: '#6b2d15'
|
||||
support-engaged: '#dcfce7'
|
||||
support-engaged-foreground: '#166534'
|
||||
support-disputed: '#fef3c7'
|
||||
support-disputed-foreground: '#92400e'
|
||||
support-negative: '#fee2e2'
|
||||
support-negative-foreground: '#991b1b'
|
||||
estimate-confirmed: '#dcfce7'
|
||||
estimate-confirmed-foreground: '#166534'
|
||||
estimate-pending: '#fef3c7'
|
||||
estimate-pending-foreground: '#92400e'
|
||||
cadence-overdue: '#b42318'
|
||||
editorial-bg: '#f6f4f3'
|
||||
editorial-fg: '#201b1a'
|
||||
petition-bg: '#fff8f2'
|
||||
petition-cta: '#ffcf2e'
|
||||
petition-cta-foreground: '#332300'
|
||||
typography:
|
||||
headline:
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif'
|
||||
fontSize: '1.5rem'
|
||||
fontWeight: 600
|
||||
lineHeight: 1.25
|
||||
letterSpacing: '-0.025em'
|
||||
title:
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif'
|
||||
fontSize: '1rem'
|
||||
fontWeight: 500
|
||||
lineHeight: 1.375
|
||||
body:
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif'
|
||||
fontSize: '0.875rem'
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
label:
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif'
|
||||
fontSize: '0.75rem'
|
||||
fontWeight: 500
|
||||
lineHeight: 1
|
||||
rounded:
|
||||
sm: '4px'
|
||||
md: '6px'
|
||||
lg: '8px'
|
||||
xl: '12px'
|
||||
full: '9999px'
|
||||
spacing:
|
||||
xs: '4px'
|
||||
sm: '8px'
|
||||
md: '16px'
|
||||
lg: '24px'
|
||||
xl: '32px'
|
||||
components:
|
||||
button-primary:
|
||||
backgroundColor: '{colors.primary}'
|
||||
textColor: '{colors.primary-foreground}'
|
||||
rounded: '{rounded.lg}'
|
||||
padding: '8px 10px'
|
||||
height: '32px'
|
||||
typography: '{typography.label}'
|
||||
button-primary-hover:
|
||||
backgroundColor: '#c51414cc'
|
||||
button-secondary:
|
||||
backgroundColor: '{colors.secondary}'
|
||||
textColor: '{colors.secondary-foreground}'
|
||||
rounded: '{rounded.lg}'
|
||||
padding: '8px 10px'
|
||||
height: '32px'
|
||||
button-outline:
|
||||
backgroundColor: '{colors.background}'
|
||||
textColor: '{colors.foreground}'
|
||||
rounded: '{rounded.lg}'
|
||||
padding: '8px 10px'
|
||||
height: '32px'
|
||||
button-ghost:
|
||||
backgroundColor: 'transparent'
|
||||
textColor: '{colors.foreground}'
|
||||
rounded: '{rounded.lg}'
|
||||
padding: '8px 10px'
|
||||
height: '32px'
|
||||
input-field:
|
||||
backgroundColor: '{colors.background}'
|
||||
textColor: '{colors.foreground}'
|
||||
rounded: '{rounded.md}'
|
||||
padding: '8px 16px'
|
||||
height: '32px'
|
||||
badge-status:
|
||||
backgroundColor: '{colors.muted}'
|
||||
textColor: '{colors.foreground}'
|
||||
rounded: '{rounded.full}'
|
||||
padding: '2px 8px'
|
||||
height: '20px'
|
||||
typography: '{typography.label}'
|
||||
card-surface:
|
||||
backgroundColor: '{colors.card}'
|
||||
textColor: '{colors.foreground}'
|
||||
rounded: '{rounded.xl}'
|
||||
padding: '16px'
|
||||
---
|
||||
|
||||
# Design System: Teqo
|
||||
|
||||
## 1. Overview
|
||||
|
||||
**Creative North Star: "The Field Desk"**
|
||||
|
||||
Teqo’s visual system is a sober ops surface for people organizing under pressure. Design defaults to `/campanha`: stone-white surfaces, Inter, and brand red as a **signal** (primary actions, selection, status urgency) — never as spectacle or brochure chrome. Staff screens can be information-dense; leadership paths stay phone-first with larger touch targets and obvious next actions.
|
||||
|
||||
Public themes (`editorial`, `petition`, and the home brand-red body) share the same token contract and primary red, but they are surface variants. When inventing new UI, start from the campaign desk unless the task is explicitly a public/marketing page.
|
||||
|
||||
The system rejects generic SaaS dashboards (purple gradients, hero-metric templates, identical card grids, AI-startup chrome) and traditional Brazilian campaign kitsch (clipart flags, shouty all-caps, brochure clutter). Sovereignty and clarity beat decoration.
|
||||
|
||||
**Key Characteristics:**
|
||||
|
||||
- Product register: familiar shadcn affordances, earned consistency over surprise
|
||||
- Restrained color strategy: neutrals dominate; primary red ≤ ~10% of any screen
|
||||
- Hybrid elevation: flat lists/forms; light lift only for priority or overlay surfaces
|
||||
- Confident and compact controls — denser desktop, phone-first leadership paths
|
||||
- Semantic status colors (support / estimate / cadence) carry meaning, not fashion
|
||||
|
||||
## 2. Colors
|
||||
|
||||
A stone-neutral desk with one mobilizing red. Semantic pastels mark operational state; they never replace the primary action color.
|
||||
|
||||
### Primary
|
||||
|
||||
- **Mandate Red** (`#c51414`): Primary buttons, focus rings, active nav, brand emphasis on `/campanha` and editorial/petition themes. Use for the one clear next action.
|
||||
- **Header Crimson** (`#ae1603`): Public site header bar (`--site-header`) and home body gradient anchor — inherited across themes so the masthead stays constant.
|
||||
|
||||
### Secondary
|
||||
|
||||
- **Stone Wash** (`#f5f5f4` / `#f4f4f5`): Secondary buttons, muted fills, hover washes. Quiet structure, not a second brand.
|
||||
|
||||
### Tertiary
|
||||
|
||||
- **Petition Gold** (`#ffcf2e`): Petition-theme CTA only (`data-theme='petition'`). Forbidden as a general accent on campaign screens.
|
||||
|
||||
### Neutral
|
||||
|
||||
- **Desk White** (`#ffffff`): Page and card background on campaign.
|
||||
- **Ink Stone** (`#1c1917`): Body text and strong labels.
|
||||
- **Quiet Ink** (`#6b7280`): Muted labels, secondary metadata — keep contrast ≥ 4.5:1 on white.
|
||||
- **Hairline Stone** (`#e7e5e4` / `rgb(28 25 23 / 12%)`): Borders and dividers.
|
||||
- **Field Border** (`#d6d3d1`): Visible input stroke on white cards.
|
||||
- **Rail Mist** (`#fafaf9`): Campaign sidebar rail.
|
||||
|
||||
### Semantic (campaign ops)
|
||||
|
||||
- Scope / territory chips: warm peach (`#fff5ed` / `#6b2d15`)
|
||||
- Support & estimates: green engaged/confirmed, amber disputed/pending, red negative/overdue — use only via Badge variants, never invent parallel hues
|
||||
|
||||
### Named Rules
|
||||
|
||||
**The Signal Red Rule.** Mandate Red is for primary action, focus, and urgency. It must not wash backgrounds, decorate cards, or paint inactive chrome.
|
||||
|
||||
**The Theme Contract Rule.** New surfaces reuse the shadcn token names (`primary`, `muted`, `field-*`, etc.). Do not introduce one-off hex outside a `data-theme` block.
|
||||
|
||||
## 3. Typography
|
||||
|
||||
**Display Font:** Inter (with `ui-sans-serif, system-ui, sans-serif`)
|
||||
**Body Font:** Inter (same stack)
|
||||
**Label/Mono Font:** Inter for UI labels; mono only for code snippets
|
||||
|
||||
**Character:** One tuned sans for the whole product desk — no display serif in app chrome. Public article heroes may use larger extrabold Inter; campaign headings stay left-aligned section titles.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
- **Headline** (600, `1.5rem` / `text-2xl`, tight tracking): Page and section titles on `/campanha`.
|
||||
- **Title** (500, `1rem` / `text-base`): Card titles, dialog titles.
|
||||
- **Body** (400, `0.875rem` / `text-sm`, leading ~1.5): Default UI copy; prose max ~65–75ch when reading length matters.
|
||||
- **Label** (500, `0.75rem` / `text-xs`): Badges, meta, table headers. Never all-caps tracked eyebrows as section scaffolding.
|
||||
|
||||
### Named Rules
|
||||
|
||||
**The One Family Rule.** Inter everywhere in product UI. No decorative display faces in buttons, labels, or data.
|
||||
|
||||
**The Fixed Scale Rule.** Prefer rem/Tailwind steps over fluid `clamp()` headings inside the campaign shell.
|
||||
|
||||
## 4. Elevation
|
||||
|
||||
Hybrid: most of the desk is flat. Depth comes from tonal layers (sidebar rail, muted fills, hairline rings) and from overlays when the user must focus.
|
||||
|
||||
Lists, forms, filters, and table rows stay flat. Cards may use a light ring (`ring-1 ring-foreground/10`) without drop shadow. Soft ambient lift is reserved for priority overview panels and for floating UI (dialogs, sheets, drawers, popovers).
|
||||
|
||||
### Shadow Vocabulary
|
||||
|
||||
- **Flat desk** (no shadow): Default for rows, forms, filters, most cards.
|
||||
- **Priority lift** (soft ambient, low opacity): Overview / insight panels that must read as “above” the list — keep blur soft and shadow light; if it looks like a 2014 app, the shadow is too dark.
|
||||
- **Overlay** (modal/sheet/popover system shadow): Only for temporary focus layers.
|
||||
|
||||
### Named Rules
|
||||
|
||||
**The Flat-By-Default Rule.** Surfaces are flat at rest. Lift appears for priority or overlay — never as default card decoration across a grid.
|
||||
|
||||
## 5. Components
|
||||
|
||||
Controls feel **confident and compact**: short primary buttons, dense toolbars, phone-first tap targets where leaderships work.
|
||||
|
||||
### Buttons
|
||||
|
||||
- **Shape:** Gently rounded (`rounded-lg` ≈ 8px; smaller sizes clamp toward `--radius-md`)
|
||||
- **Primary:** Mandate Red fill, white text, height 32px default; primary submit may use `font-bold`
|
||||
- **Hover / Focus:** Primary hover at ~80% opacity; focus-visible ring `ring-3 ring-ring/50` with border-ring
|
||||
- **Secondary / Outline / Ghost / Destructive:** Standard shadcn variants; destructive is tinted text/fill, not a second brand red for routine actions
|
||||
- **Active:** Slight `translate-y-px` press
|
||||
|
||||
### Chips / Badges
|
||||
|
||||
- **Style:** Pill (`rounded-4xl`), height 20px, `text-xs` medium
|
||||
- **State:** Semantic variants (`support-*`, `estimate-*`, `scope`, `tse`) — status meaning only; do not invent decorative chip colors
|
||||
|
||||
### Cards / Containers
|
||||
|
||||
- **Corner Style:** `rounded-xl` (~12px)
|
||||
- **Background:** Card white on desk white — separation via hairline ring, not heavy shadow
|
||||
- **Shadow Strategy:** Flat by default; priority lift only when the panel is an overview/insight surface
|
||||
- **Internal Padding:** `--card-spacing` default 16px (`spacing(4)`), sm 12px
|
||||
|
||||
### Inputs / Fields
|
||||
|
||||
- **Style:** Campaign forces `--radius-md` (~6px) on input/select triggers (overrides public `rounded-full` input). Visible `--field-border` on white.
|
||||
- **Focus:** Border primary + `ring-3 ring-primary/30`
|
||||
- **Error / Disabled:** Destructive border/ring; disabled uses field-disabled tokens at reduced opacity
|
||||
- **Touch:** Keep leadership-facing controls generous; public Input uses larger py for form comfort
|
||||
|
||||
### Navigation
|
||||
|
||||
- **Campaign shell:** Light stone sidebar rail on desktop; mobile top bar in Mandate Red with white wordmark — intentional field-mode contrast (phone in the street vs desk rail), not Signal Red Rule drift.
|
||||
- **Active:** Primary-tinted or bold text — red signals location, not decoration
|
||||
- **Public header:** Header Crimson bar, constant across themes
|
||||
|
||||
### Status Badge (signature)
|
||||
|
||||
Operational meaning for leadership/support/estimates. Always use `Badge` variants tied to CSS tokens — never hardcode greens/ambers that drift from `--support-*` / `--estimate-*`.
|
||||
|
||||
## 6. Do's and Don'ts
|
||||
|
||||
### Do:
|
||||
|
||||
- **Do** treat `/campanha` as the design default: stone desk, Inter, Signal Red for the next action.
|
||||
- **Do** keep primary red rare — one clear CTA per region beats a sea of red buttons.
|
||||
- **Do** use semantic Badge tokens for support/estimate/cadence state.
|
||||
- **Do** keep leadership paths phone-first: obvious actions, larger taps, plain language.
|
||||
- **Do** meet WCAG 2.2 AA (contrast, keyboard, focus rings, `prefers-reduced-motion`).
|
||||
- **Do** reuse shadcn primitives (`Button`, `Input`, `Badge`, `Card`, dialogs/sheets) for consistency.
|
||||
|
||||
### Don't:
|
||||
|
||||
- **Don't** ship generic SaaS dashboards — purple gradients, hero-metric templates, identical card grids, AI-startup chrome.
|
||||
- **Don't** ship traditional Brazilian campaign kitsch — clipart flags, shouty all-caps, brochure clutter.
|
||||
- **Don't** use side-stripe borders (`border-left`/`border-right` > 1px) as accent decoration.
|
||||
- **Don't** use gradient text or glassmorphism as default chrome.
|
||||
- **Don't** put tiny uppercase tracked eyebrows above every section.
|
||||
- **Don't** use Petition Gold or home body gradients inside the campaign desk.
|
||||
- **Don't** invent display fonts for UI labels, buttons, or data.
|
||||
- **Don't** lift every card; flat is the desk’s resting state.
|
||||
51
PRODUCT.md
Normal file
51
PRODUCT.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
|
||||
Two surfaces, one product.
|
||||
|
||||
**`/campanha` (primary for design defaults):** campaign coordination (`geral`, `coordenador`) and leaderships (`lideranca`) as first-class peers — staff need depth for territory, people, estimates, and agenda; leaderships need a simpler, phone-first path into their nucleus. Used in the field and on the go, often under time pressure through the 2026 calendar.
|
||||
|
||||
**Public site:** citizens and the base in Bahia first (news, petitions, donate, stay close to the mandate); institutional visitors second (press, partners, official voice).
|
||||
|
||||
## Product Purpose
|
||||
|
||||
**`/campanha`:** field operations and electoral intelligence as one job — nuclei, people, estimates, agenda, and share kits, plus a live picture of territory and vote gap so the team can prioritize action. Nuclei, people, and estimates live here as the system of record, even when outreach uses pragmatic channels.
|
||||
|
||||
**Public site:** own the relationship with the base first (engagement channel the campaign controls); project the mandate’s official voice and credibility second.
|
||||
|
||||
**Success near-term:** `/campanha` is the daily ops tool with real nuclei, people, and estimates in use, and the public site is the owned citizen channel — both live, not one or the other.
|
||||
|
||||
## Positioning
|
||||
|
||||
Sovereign political intelligence, organization, and community engagement — the destination. Pragmatic channels (e.g. WhatsApp) are acceptable shortcuts today; the product should not make those the permanent system of record.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Socialist, clear, inspiring.
|
||||
|
||||
Voice and UI should feel like tools built by people who believe a better internet and stronger grassroots organization are possible — functional and respectful (Mastodon), builders of owned channels (Automattic), willing to reimagine workflows when the old ones fail (Hey.com), and rooted in social mobilization practice (Bonde.org) — without pretending the team never uses the platforms people already live on.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Not generic SaaS dashboards (purple gradients, hero-metric templates, identical card grids, AI-startup chrome). Not traditional Brazilian campaign kitsch (clipart flags, shouty all-caps, brochure clutter).
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Sovereignty as the horizon** — own audience, data, and ops over time; Big Tech shortcuts (WhatsApp kits, share links) are fine for now when they help mobilization, but design should keep the campaign’s own records and workflows as the source of truth and leave room to reduce platform dependency later.
|
||||
2. **Clarity under pressure** — every screen earns its keep for field use; prefer plain language and obvious next actions over decoration.
|
||||
3. **Depth and simplicity as peers** — staff workflows can be rich; leadership paths stay light and phone-first without becoming a second-class product.
|
||||
4. **Intelligence serves organization** — electoral baseline and insights exist to decide where to act, not to impress.
|
||||
5. **Inspire without kitsch** — socialist and mobilizing in spirit; never shouty campaign brochure aesthetics.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
WCAG 2.2 AA as the bar (contrast, keyboard, focus, reduced motion). Plus: larger touch targets for leadership / field phone use, and screen-reader priority on forms (labels, errors, and critical actions must be announced clearly).
|
||||
96
docs/plans/escala-dry-pos-field-desk.md
Normal file
96
docs/plans/escala-dry-pos-field-desk.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Escala e DRY pós-Field Desk
|
||||
|
||||
Status: registrado no roadmap (fases pendentes)
|
||||
Atualizado em: 2026-07-19
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (FD+, fill-in de engenharia pós-polish Impeccable `/campanha`)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
Em 2026-07-19 a vertical `/campanha` passou por um ciclo Impeccable (critique 28→32, quieter/layout/distill/clarify/harden/polish) alinhado ao North Star **Field Desk** (`PRODUCT.md` / `DESIGN.md`). Entregas locais incluem `CampaignPageShell`, `CampaignMetricStrip`, `CampaignDataFreshness`, filas-first no dashboard, `NucleusFilters` com “Mais filtros”, empties de liderança/coordenador esclarecidos, freshness request-time, tipografia `font-semibold` e loadings alinhados.
|
||||
|
||||
A passagem `/simplify` (2026-07-19) aplicou cleanup pontual (freshness sem ramo relativo morto, strip sem métricas duplicadas do card de prioridade, `hidden` único nos filtros avançados, wrappers de data inlined, Badge simplificado). Os revisores (quality / reuse / performance) deixaram débitos **maiores que cleanup** — registrados aqui.
|
||||
|
||||
**Já resolvido no simplify (não reabrir):** `formatCampaignDataFreshness` morto removido; `CampaignDataFreshness` só `asOf` + “Atualizado agora”; overview sem Gap/série no strip (ficam no card); MetricStrip sem `bg-card` interno em células quietas; `hidden={!advancedOpen}` sem `className` duplicado; `relativeDate*` inlined em overview/feed; comments de shell/strip.
|
||||
|
||||
**Overlap com VR+:** VR+ Fase 2 ainda cita migrar `lastUpdateLabel` / feed — o feed já usa `formatRelativeAge`; o restante de VR+ F2 (variante floor-dias no dashboard) permanece em [escala-dry-pos-visitados-recentemente.md](escala-dry-pos-visitados-recentemente.md), não aqui.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Um único shell de página (`CampaignPageShell` + variantes de largura) para listas, detalhe e forms de `/campanha`.
|
||||
- Um token de tipografia de página (`campaignPageHeadingClassName`) sem repetir `text-2xl font-semibold tracking-tight` em ~15 h1s.
|
||||
- Datas absolutas/relativas de superfície de campanha passam pelos helpers canônicos (`formatRelativeAge` / `formatBahiaDateTimeLabel`), não por `Intl` local.
|
||||
- `NucleusVoteGoals` reutiliza `CampaignMetricStrip` onde o layout for equivalente.
|
||||
- `NucleusFilters` deixa de carregar bag `handlers` + lista `primaryFilterNames` só para concatenar nomes.
|
||||
- Guardrails: sem migration, sem collection, sem Consent; só DRY/composição UI.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Um plano FD+, fases ordenadas.** Precedente VR+/RS+/O0+: um registro no roadmap, PRs por fase.
|
||||
- **Dependência suave do polish Field Desk** (branch local / merge pendente). Não bloqueia uso da vertical.
|
||||
- **Cortável:** Fases 3–5 (VoteGoals strip, filtros bag, datas em listagens fora do core polish) se o prazo apertar; Fases 1–2 (shell size + heading class) são as de maior ROI de consistência.
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`campaignPageHeadingClassName`, `size: 'form' | 'detail' | 'list'`), strings visíveis em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **`size` no shell vs `className` ad-hoc?** Forms usam `max-w-3xl`/`4xl`, detalhe `max-w-6xl`, listas `max-w-screen-2xl`. **Recomendação:** prop `size: 'list' | 'form' | 'detail'` mapeando para classes já usadas; permitir `className` só para exceções (ex. perfil `max-w-2xl`).
|
||||
- **Heading class no shell ou export solto?** **Recomendação:** exportar `campaignPageHeadingClassName` ao lado de `campaignPageShellClassName` em `CampaignPageShell.tsx` — mesmo padrão de discovery.
|
||||
- **VoteGoals → MetricStrip agora?** Só 3 métricas + Progress separado. **Recomendação:** Fase 3 só se o Progress “vs meta regular” couber no strip (`progress` na meta regular) sem perder o card de contexto do detalhe.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
FD["Field Desk polish ✓ (local)"] --> F1
|
||||
F1["Fase 1 — CampaignPageShell size<br/>list / form / detail"]
|
||||
F1 --> F2["Fase 2 — campaignPageHeadingClassName"]
|
||||
F2 --> F3["Fase 3 — NucleusVoteGoals → MetricStrip"]
|
||||
F3 --> F4["Fase 4 — Datas canônicas<br/>NucleusList / detalhe / ActionPlanUpdateFeed"]
|
||||
F4 --> F5["Fase 5 — NucleusFilters handlers<br/>+ filterNames flat"]
|
||||
```
|
||||
|
||||
### Fase 1 — Shell com `size`
|
||||
|
||||
- Estender **`CampaignPageShell`** (`src/components/campaign/CampaignPageShell.tsx`) com `size?: 'list' | 'form' | 'detail'` (default `'list'` = `campaignPageShellClassName` atual).
|
||||
- Migrar páginas que ainda usam `mx-auto flex w-full max-w-{3xl,4xl,6xl} flex-col gap-6`: `nucleos/novo`, `nucleos/[slug]`, `nucleos/[slug]/editar`, `apoiadores/novo|importar|[id]`, `planos/novo|[slug]|editar`, e alinhar `CampaignProfileSettings` (`max-w-2xl` via `className` ou size `form` + override).
|
||||
- Loadings já no shell — sem mudança.
|
||||
|
||||
### Fase 2 — Heading class compartilhada
|
||||
|
||||
- Exportar **`campaignPageHeadingClassName = 'text-2xl font-semibold tracking-tight'`**.
|
||||
- Aplicar nos h1 de páginas de campanha + títulos de dashboard (`CampaignDashboard`) + perfil.
|
||||
|
||||
### Fase 3 — `NucleusVoteGoals` → `CampaignMetricStrip`
|
||||
|
||||
- Refatorar **`NucleusVoteGoals`** para emitir as três metas via `CampaignMetricStrip`; manter Progress de “vs meta regular” abaixo ou como `progress` na métrica regular.
|
||||
- Não alterar domínio/loaders de `voteGoals`.
|
||||
|
||||
### Fase 4 — Datas canônicas fora do core polish
|
||||
|
||||
- **`NucleusList.tsx`**: `lastUpdateAt` → `formatRelativeAge` (paridade com dashboard) ou `formatBahiaDateTimeLabel` se produto quiser absoluto — **Recomendação:** relativo, como dashboard.
|
||||
- **`nucleos/[slug]/page.tsx`**: timestamps de estimativa → `formatBahiaDateTimeLabel`.
|
||||
- **`ActionPlanUpdateFeed.tsx`**: alinhar a `formatRelativeAge` + `now` do servidor (paridade com `NucleusUpdateFeed`).
|
||||
|
||||
### Fase 5 — `NucleusFilters` bag
|
||||
|
||||
- Remover objeto `handlers: SharedFilterHandlers` e espalhar props; colapsar `primaryFilterNames` + `advancedFilterNames` numa única lista `filterNames` com marcação advanced se ainda precisar do count.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Soft: polish Field Desk mergeado (shell/strip/freshness já no branch).
|
||||
- Soft overlap: VR+ F2 (relative age no dashboard cards) — coordenar para não duplicar PRs.
|
||||
- Sem migration, sem Consent.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Triagem em lote das filas, glossário/tooltips, outline h1→h2, empty de coordenador com CTA, motion do Sidebar — item **FD2** ([field-desk-ux-pos-critique.md](field-desk-ux-pos-critique.md)).
|
||||
- Reabrir quieter/layout/distill já entregues (KPI strips, Mais filtros, side-tab).
|
||||
- Saved filter views / bulk além da fila de prioridade — produto em FD2 Fase 1; escopo além disso fica para item futuro.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Fill-ins FD+)
|
||||
- `.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md` — score 32; minor “detail/form max-w”
|
||||
- `src/components/campaign/CampaignPageShell.tsx`, `CampaignMetricStrip.tsx`, `NucleusFilters.tsx`, `NucleusVoteGoals.tsx`, `NucleusList.tsx`, `ActionPlanUpdateFeed.tsx`
|
||||
- [escala-dry-pos-visitados-recentemente.md](escala-dry-pos-visitados-recentemente.md) — VR+ (overlap tempo relativo)
|
||||
- AGENTS.md — naming inglês; tema `campaign`
|
||||
97
docs/plans/field-desk-ux-pos-critique.md
Normal file
97
docs/plans/field-desk-ux-pos-critique.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# UX Field Desk pós-critique
|
||||
|
||||
Status: registrado no roadmap (fases pendentes)
|
||||
Atualizado em: 2026-07-19
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (FD2, fill-in de design/produto pós-critique Impeccable `/campanha`)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
O re-critique de `/campanha` (2026-07-19, dual-agent) subiu o score de **28 → 32/40** após o ciclo quieter → polish. Os P1 do baseline (parede de filtros, grade de KPI cards, side-tab) estão fechados. Permanecem débitos de **design/produto** (não só DRY) que o time não implementou naquele ciclo — registrados aqui para não se perderem no chat.
|
||||
|
||||
Fonte: [`.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md`](../../.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md) e síntese em sessão (heurísticas 7=2 flexibilidade, 10=2 ajuda).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Alex (geral/coordenador) consegue tratar a fila **Prioridade · Sem coordenador** com multi-select + ação em lote (atribuir coordenador), sem N navegações.
|
||||
- Lia e first-timers encontram definição inline de jargão recorrente (`engajado`, estimativa confirmada vs proposta, status de apoio) sem sair da tela.
|
||||
- Outline do dashboard `geral` não pula de `h1` para `h3` (detector `skipped-heading`; Sam/a11y).
|
||||
- Empty do coordenador sem núcleos espelha o padrão de liderança (CTA + próximo passo).
|
||||
- Guardrails: sem migration obrigatória na v1 da triagem (reusa designação de coordenador existente); sem Consent novo; touch `min-h-11` preservado.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Um plano FD2, fases por severidade do critique.** P2 antes de P3. Ordem: triagem em lote (impacto Alex) → glossário inline (Help) → outline a11y → empty coordenador → chrome motion (cortável).
|
||||
- **Bulk só na fila de prioridade na v1.** As filas “sem atualização” e “estimativas pendentes” continuam one-by-one até haver evidência de volume — evita escopo de “saved views” genérico.
|
||||
- **Glossário = affordance inline, não página de docs.** Tooltip/`Popover`/`?` em `SupportStatusBadge` e labels de estimativa; uma frase Núcleo/ZE já existe na lista.
|
||||
- **P3 mobile red bar vs rail** permanece documentado em `DESIGN.md` como field-mode intencional — **fora deste plano** (escolha explícita no ciclo Impeccable).
|
||||
- **i18n e naming:** identificadores em inglês (`bulkAssignCoordinators`, `CampaignGlossaryHint`), copy em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Bulk assign: mesmo sheet de designação ou fluxo novo?** **Recomendação:** reusar o shell/lazy de `assignCoordinators` por núcleo em sequência sob uma txn ou N actions com progresso — não inventar multi-núcleo no Payload sem necessidade; UX é checklist + um coordenador aplicado a N slugs.
|
||||
- **Quem pode bulk?** **Recomendação:** só `geral` (já é quem designa coordenadores em massa conceitualmente); `coordenador` não aparece na fila “Sem coordenador”.
|
||||
- **Primeiro glossário: tooltip Radix ou texto expandido?** **Recomendação:** `Popover`/`HoverCard` com `min-h-11` no gatilho `?` — funciona em touch melhor que hover-only.
|
||||
- **Ícone AlertTriangle no empty de liderança?** Critique minor: tom alarmista. **Recomendação:** trocar para `UsersIcon` / `InboxIcon` na mesma passagem do empty do coordenador (Fase 4).
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
CR["Critique 32/40"] --> F1
|
||||
F1["Fase 1 — Triagem em lote<br/>fila Sem coordenador"]
|
||||
F1 --> F2["Fase 2 — Glossário inline<br/>engajado / estimativa / apoio"]
|
||||
F2 --> F3["Fase 3 — Outline h2 Filas<br/>dashboard geral"]
|
||||
F3 --> F4["Fase 4 — Empty coordenador + CTA<br/>(+ ícone liderança)"]
|
||||
F4 --> F5["Fase 5 — Sidebar layout-transition<br/>(cortável)"]
|
||||
```
|
||||
|
||||
### Fase 1 — Triagem em lote (P2)
|
||||
|
||||
- Em **`CampaignDashboard`** / `QueueSection` com `openCoordinatorAssignment`: modo multi-select (checkbox por linha) + barra “Atribuir coordenador (N)”.
|
||||
- Server action (ou extensão da existente de assign) recebe `nucleusSlugs[]` + `coordinatorIds[]`; access `geral`; `overrideAccess: false`; txn se multi-write.
|
||||
- Reusa padrões de `CoordinatorAssignment` / shell lazy do detalhe do núcleo — extrair só o necessário para não puxar o editor completo N vezes no client.
|
||||
|
||||
### Fase 2 — Glossário inline (P2)
|
||||
|
||||
- **`CampaignGlossaryHint`** (novo, pequeno): props `termKey`, children trigger.
|
||||
- Ligar em **`SupportStatusBadge`**, cartão de estimativa (`VoteEstimateCard` / labels no dashboard de liderança), e opcionalmente chip de ZE se ainda houver dúvida após a frase da lista.
|
||||
- Copy curta aprovável por produto (sem inventar doutrina eleitoral).
|
||||
|
||||
### Fase 3 — Outline do dashboard (P2 a11y)
|
||||
|
||||
- Em **`CampaignDashboard`** (`GeneralDashboard`): promover “Filas de ação” a `h2` visível (hoje só `CardTitle` em `div`); manter títulos de cada fila como `h3`.
|
||||
- Verificar ordem DOM: h1 → h2 Filas → h3s → h2 Indicadores.
|
||||
|
||||
### Fase 4 — Empty coordenador (P3)
|
||||
|
||||
- Espelhar **`LeadershipDashboard` empty**: `EmptyMedia` + CTA (`Ver meu perfil` e/ou texto “peça à coordenação geral…”).
|
||||
- Suavizar ícone do empty de liderança (AlertTriangle → ícone não-alarme).
|
||||
|
||||
### Fase 5 — Motion do Sidebar (P3, cortável)
|
||||
|
||||
- Em **`Sidebar.tsx`** (ou wrapper campaign): evitar `transition-[width,height,padding]` em favor de `transform`/`opacity` onde o detector marcou `layout-transition`.
|
||||
- Só se profiling/UX mostrar jank; caso contrário cortar.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Soft: polish Field Desk (filas, empties base) mergeado.
|
||||
- Soft: FD+ (shell/heading) melhora consistência mas **não bloqueia** FD2.
|
||||
- Reusa `CampaignDashboard.tsx`, access de designação de coordenador, `SupportStatusBadge`, tema campaign.
|
||||
- Sem migration na v1; sem Consent.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- DRY de shell/heading/datas/VoteGoals/filters bag — **FD+** ([escala-dry-pos-field-desk.md](escala-dry-pos-field-desk.md)).
|
||||
- Saved views genéricas de filtros de lista (além do bulk da fila).
|
||||
- Quiet da barra vermelha mobile (field-mode documentado).
|
||||
- Bulk nas filas secundárias do dashboard.
|
||||
- Push/offline ambient cue no shell (Casey) — candidato a D1/D2 follow-up, não este plano.
|
||||
|
||||
## Referências
|
||||
|
||||
- [`.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md`](../../.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md)
|
||||
- `docs/roadmap.md` (Fill-ins FD2)
|
||||
- `PRODUCT.md` / `DESIGN.md` — Field Desk, Signal Red, personas Alex/Casey/Lia
|
||||
- `src/components/campaign/CampaignDashboard.tsx`, `SupportStatusBadge.tsx`, shells de assign de coordenador
|
||||
- AGENTS.md — campaign auth, `overrideAccess: false`, naming
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md))
|
||||
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md); **Field Desk polish** (Impeccable quieter→polish, critique 28→32) em branch local — [PRODUCT.md](../PRODUCT.md) / [DESIGN.md](../DESIGN.md); **FD+ registrado** — escala/DRY pós-Field Desk do `/simplify` — [plano](plans/escala-dry-pos-field-desk.md); **FD2 registrado** — UX pós-critique (bulk fila, glossário, outline, empty coordenador) — [plano](plans/field-desk-ux-pos-critique.md))
|
||||
|
||||
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
|
||||
|
||||
|
|
@ -121,6 +121,8 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
|
|||
| O0+ Escala e DRY pós-Onda 0 | — (sem design novo; reusa `/privacidade` + padrão revalidate `posts`) | [escala-dry-pos-onda0.md](plans/escala-dry-pos-onda0.md) |
|
||||
| VR+ Escala e DRY pós-visitados | — (sem design novo; reusa dashboard + padrões de lista de núcleos) | [escala-dry-pos-visitados-recentemente.md](plans/escala-dry-pos-visitados-recentemente.md) |
|
||||
| RS+ Escala e DRY pós-reset senha/perfil | — (sem design novo; reusa login + perfil + padrões de form de campanha) | [escala-dry-pos-reset-senha-perfil.md](plans/escala-dry-pos-reset-senha-perfil.md) |
|
||||
| FD+ Escala e DRY pós-Field Desk | — (sem design novo; reusa shells/strips Field Desk) | [escala-dry-pos-field-desk.md](plans/escala-dry-pos-field-desk.md) |
|
||||
| FD2 UX Field Desk pós-critique | — (critique Impeccable; reusa dashboard + badges) | [field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md) |
|
||||
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
|
||||
| E6 Escala e DRY pós-E1+E3 | — (sem design novo; reusa Metas 2026 + overview B1) | [escala-dry-pos-e1.md](plans/escala-dry-pos-e1.md) |
|
||||
| E7 Escala e DRY pós-E2 | — (sem design novo; reusa Baseline + Insights + overview tendência) | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) |
|
||||
|
|
@ -242,7 +244,7 @@ flowchart TD
|
|||
E6 -.geo cache.-> E7
|
||||
```
|
||||
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓), **RS+** (após Reset senha + perfil ✓) e fill-ins (listas globais, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. Fill-ins entregues em branch local (2026-07-19): Visitados recentemente, Reset senha + foto de perfil. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), **A8** (após A1 ✓; suave B2 ✓ para `codarea` — paralelizável agora), E2 (após A4 ✓), **E7 (após E2 merge; Fase 2 int preferível antes de E4)**, **E6 (após merge E1+E3 em `main`)**, B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import; suave: E6 F1 e E7 F2 reduzem custo/risco do overview antes do import em massa).
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓), **RS+** (após Reset senha + perfil ✓), **FD+** / **FD2** (após polish Field Desk em branch) e fill-ins (listas globais, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. Fill-ins entregues em branch local (2026-07-19): Visitados recentemente, Reset senha + foto de perfil. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), **A8** (após A1 ✓; suave B2 ✓ para `codarea` — paralelizável agora), E2 (após A4 ✓), **E7 (após E2 merge; Fase 2 int preferível antes de E4)**, **E6 (após merge E1+E3 em `main`)**, B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import; suave: E6 F1 e E7 F2 reduzem custo/risco do overview antes do import em massa).
|
||||
|
||||
### Sequência de execução por janela do calendário
|
||||
|
||||
|
|
@ -305,10 +307,13 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
|
|||
- **VR+ Escala e DRY pós-visitados recentemente** (refresh bfcache/storage, DRY tempo relativo, `CampaignLinkListRow`, shell do dashboard) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-visitados-recentemente.md)
|
||||
- **Reset de senha self-service + foto de perfil** ✓ (Resend + `/campanha/perfil`; MVP entregue 2026-07-19). → [detalhes](plans/reset-senha-foto-perfil.md)
|
||||
- **RS+ Escala e DRY pós-reset senha/perfil** (auth read `depth: 0`, avatar fora da TX, `CampaignAuthPageShell`, schemas de senha unificados, login em `CampaignFormActionState`) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-reset-senha-perfil.md)
|
||||
- **Field Desk polish** (Impeccable quieter→polish; critique 28→32) — shells/strips/freshness/filas-first/Mais filtros/empties; em branch local 2026-07-19. → `PRODUCT.md` / `DESIGN.md`
|
||||
- **FD+ Escala e DRY pós-Field Desk** (`CampaignPageShell` size, heading class, VoteGoals→MetricStrip, datas canônicas, NucleusFilters bag) — engenharia pós-polish; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-field-desk.md)
|
||||
- **FD2 UX Field Desk pós-critique** (bulk na fila Sem coordenador, glossário inline, outline h2 Filas, empty coordenador+CTA, Sidebar motion cortável) — produto/UX pós-critique; _(validar prioridades com produto)_. → [detalhes](plans/field-desk-ux-pos-critique.md)
|
||||
- **Listas globais** (lideranças, atualizações, territórios no nível raiz). _(plano MVP; sem plano detalhado ainda)_
|
||||
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
|
||||
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1 e F3** (dedup visual/API — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), **RS+ Fases 3–5** (shell auth / campos de senha compartilhados / avatar intent único — Fase 1 auth read é a mais valiosa se `/campanha` ficar lento), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **RS+ Fase 1** (auth read sem join de avatar em rotas operacionais), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1 e F3** (dedup visual/API — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), **RS+ Fases 3–5** (shell auth / campos de senha compartilhados / avatar intent único — Fase 1 auth read é a mais valiosa se `/campanha` ficar lento), **FD+ Fases 3–5** (VoteGoals strip / datas / filtros bag — Fases 1–2 shell size + heading são as de maior ROI de consistência), **FD2 Fase 5** (Sidebar motion — cortável; preferir Fases 1–2 bulk+glossário se Alex reclamar da fila), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **RS+ Fase 1** (auth read sem join de avatar em rotas operacionais), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
|
||||
|
||||
### Itens consolidados/removidos nesta revisão (2026-07-17)
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
|
|||
<CampaignScopeBadge>
|
||||
{isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'}
|
||||
</CampaignScopeBadge>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{supporter.name}</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{supporter.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{supporter.city ?? 'Município não informado'}
|
||||
{supporter.territory ? ` · ${supporter.territory}` : ''}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default async function ImportSupportersPage() {
|
|||
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
|
||||
</Button>
|
||||
<CampaignScopeBadge>Importação · Coordenação geral</CampaignScopeBadge>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Importar apoiadores via CSV</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Importar apoiadores via CSV</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Envie uma planilha, confira a prévia e confirme a importação em lote.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default async function NewSupporterPage() {
|
|||
<CampaignScopeBadge>
|
||||
{isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'}
|
||||
</CampaignScopeBadge>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Novo apoiador</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Novo apoiador</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Cadastre um apoiador com telefone obrigatório e consentimento LGPD.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import config from '@payload-config'
|
||||
import { FileUpIcon, PlusIcon, SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
|
||||
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { SupporterFilters } from '@/components/campaign/SupporterFilters'
|
||||
import { SupporterList } from '@/components/campaign/SupporterList'
|
||||
|
|
@ -21,13 +22,13 @@ import {
|
|||
import { isCampaignGeneral } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { loadSupportersPageData } from '@/utilities/supporterPageData'
|
||||
import { toSupporterListItemViewModel } from '@/utilities/supporterViewModels'
|
||||
import {
|
||||
buildSupporterFiltersKey,
|
||||
buildSupporterListHref,
|
||||
canAccessSupporterArea,
|
||||
getSupporterScopeLabel,
|
||||
} from '@/utilities/supporterUi'
|
||||
import { toSupporterListItemViewModel } from '@/utilities/supporterViewModels'
|
||||
|
||||
type SupportersPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
|
|
@ -40,6 +41,7 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
if (!canAccessSupporterArea(user.role)) redirect('/campanha')
|
||||
|
||||
const rawSearchParams = await searchParams
|
||||
const now = new Date()
|
||||
const { result, state, redirectHref, nucleusOptions, overview } = await loadSupportersPageData(
|
||||
payload,
|
||||
user,
|
||||
|
|
@ -48,10 +50,10 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
if (redirectHref) redirect(redirectHref)
|
||||
|
||||
return (
|
||||
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Apoiadores</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Apoiadores</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Base nominal de apoio com intenção de voto e vínculo opcional a núcleos.
|
||||
</p>
|
||||
|
|
@ -87,7 +89,7 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
|
||||
{result.docs.length ? (
|
||||
<>
|
||||
{overview ? <SupporterListOverview view={overview} /> : null}
|
||||
{overview ? <SupporterListOverview view={overview} now={now} /> : null}
|
||||
<SupporterList
|
||||
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
|
||||
/>
|
||||
|
|
@ -121,6 +123,6 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
|
|||
</header>
|
||||
<div
|
||||
data-slot="campaign-content-scroll"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-24 md:p-6"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-24 md:p-6 md:pb-6"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,19 @@
|
|||
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function CampaignDashboardLoading() {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-screen-2xl flex-col gap-6"
|
||||
aria-label="Carregando painel da campanha"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<CampaignPageShell aria-label="Carregando painel da campanha" aria-busy="true">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-6 w-80 max-w-full" />
|
||||
<Skeleton className="h-5 w-80 max-w-full" />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-32" />
|
||||
))}
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
</div>
|
||||
<Skeleton className="h-28" />
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-64" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default async function EditNucleusPage({ params }: EditNucleusPageProps)
|
|||
: 'Edição · Núcleo sob sua coordenação'}
|
||||
</CampaignScopeBadge>
|
||||
<p className="text-sm font-medium text-primary">Núcleos Eleitorais</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Editar {view.name}</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Editar {view.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Atualize o território sem alterar o nome ou as estimativas confirmadas.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export default async function NucleusDetailPage({ params, searchParams }: Nucleu
|
|||
<h1
|
||||
id={nucleusDetailFocusFallbackId}
|
||||
tabIndex={-1}
|
||||
className="text-2xl font-bold tracking-tight focus-visible:outline-none"
|
||||
className="text-2xl font-semibold tracking-tight focus-visible:outline-none"
|
||||
>
|
||||
{view.name}
|
||||
</h1>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function NucleiLoading() {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-screen-2xl flex-col gap-6"
|
||||
aria-busy="true"
|
||||
aria-label="Carregando núcleos"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<CampaignPageShell aria-busy="true" aria-label="Carregando núcleos">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-6 w-80 max-w-full" />
|
||||
<Skeleton className="h-5 w-80 max-w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-11 w-full rounded-lg" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-72 w-full rounded-xl" />
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default async function NewNucleusPage() {
|
|||
<header className="flex flex-col gap-2">
|
||||
<CampaignScopeBadge>Criação · Coordenação geral</CampaignScopeBadge>
|
||||
<p className="text-sm font-medium text-primary">Núcleos Eleitorais</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Novo núcleo</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Novo núcleo</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Defina a operação, o território obrigatório e quem responde por ele.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import Link from 'next/link'
|
|||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
|
||||
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { NucleusFilters } from '@/components/campaign/NucleusFilters'
|
||||
import { NucleusList } from '@/components/campaign/NucleusList'
|
||||
import { NucleusListOverview } from '@/components/campaign/NucleusListOverview'
|
||||
|
|
@ -58,10 +59,10 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) {
|
|||
const listVisitLabel = buildNucleusListVisitLabel(state)
|
||||
|
||||
return (
|
||||
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Núcleos Eleitorais</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Núcleos Eleitorais</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Núcleo é a operação da campanha; Zona TSE é a circunscrição oficial.
|
||||
</p>
|
||||
|
|
@ -89,7 +90,7 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) {
|
|||
toNucleusListViewModel(nucleus as ElectoralNucleus),
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.totalDocs}{' '}
|
||||
{result.totalDocs === 1 ? 'núcleo encontrado' : 'núcleos encontrados'}
|
||||
|
|
@ -128,6 +129,6 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) {
|
|||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP
|
|||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-primary">Planos de ação</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Editar {view.title}</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Editar {view.title}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Atualize os detalhes do plano sem alterar o título original.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default async function ActionPlanDetailPage({
|
|||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{view.title}</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{view.title}</h1>
|
||||
<ActionPlanStatusBadge status={view.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default async function NewActionPlanPage() {
|
|||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-primary">Planos de ação</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Novo plano</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Novo plano</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Defina a ação, quando e onde ela acontece e quem responde por ela.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getPayload } from 'payload'
|
|||
import { ActionPlanFilters } from '@/components/campaign/ActionPlanFilters'
|
||||
import { ActionPlanList } from '@/components/campaign/ActionPlanList'
|
||||
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
|
||||
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
|
|
@ -17,10 +18,14 @@ import {
|
|||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import type { ActionPlan } from '@/payload-types'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { loadActionPlanListPageData } from '@/utilities/actionPlanPageData'
|
||||
import { buildActionPlanFiltersKey, buildActionPlanListHref, resolveActionPlanListUrl } from '@/utilities/actionPlanUi'
|
||||
import {
|
||||
buildActionPlanFiltersKey,
|
||||
buildActionPlanListHref,
|
||||
resolveActionPlanListUrl,
|
||||
} from '@/utilities/actionPlanUi'
|
||||
import { toActionPlanListViewModel } from '@/utilities/actionPlanViewModels'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
|
||||
type ActionPlanListPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
|
|
@ -42,10 +47,10 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
|
|||
const canCreate = user.role === 'geral' || user.role === 'coordenador'
|
||||
|
||||
return (
|
||||
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Planos de ação</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Planos de ação</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Organize caminhadas, comícios, panfletagens e demais ações de campanha.
|
||||
</p>
|
||||
|
|
@ -69,7 +74,8 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
|
|||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.totalDocs} {result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
|
||||
{result.totalDocs}{' '}
|
||||
{result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
|
|
@ -96,6 +102,6 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
|
|||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ import {
|
|||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { CampaignDataFreshness } from '@/components/campaign/CampaignDataFreshness'
|
||||
import { CampaignMetricStrip } from '@/components/campaign/CampaignMetricStrip'
|
||||
import {
|
||||
CampaignPageShell,
|
||||
campaignPrioritySurfaceClassName,
|
||||
} from '@/components/campaign/CampaignPageShell'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { NucleusCard } from '@/components/campaign/NucleusCard'
|
||||
import { RecentlyVisited } from '@/components/campaign/RecentlyVisited'
|
||||
|
|
@ -28,29 +34,28 @@ import {
|
|||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { Progress } from '@/components/ui/Progress'
|
||||
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
|
||||
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
|
||||
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
|
||||
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
|
||||
import type {
|
||||
DashboardQueueItem,
|
||||
GeneralDashboardViewModel,
|
||||
ScopedDashboardViewModel,
|
||||
} from '@/utilities/campaignDashboardViewModels'
|
||||
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
|
||||
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
|
||||
import { buildLeadershipPanelHref } from '@/utilities/leadershipUi'
|
||||
import { getCampaignScopeLabel } from '@/utilities/nucleusUi'
|
||||
import { buildWhatsAppUrl } from '@/utilities/phone'
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('pt-BR')
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' })
|
||||
|
||||
const lastUpdateLabel = (value: string | null, now: Date): string => {
|
||||
if (!value) return 'Nenhuma atualização'
|
||||
const days = Math.floor((new Date(value).getTime() - now.getTime()) / (24 * 60 * 60 * 1000))
|
||||
return `Última atualização ${relativeFormatter.format(days, 'day')}`
|
||||
return `Última atualização ${formatRelativeAge(new Date(value).getTime(), now.getTime())}`
|
||||
}
|
||||
|
||||
export const buildDashboardQueueItemHref = (
|
||||
|
|
@ -58,68 +63,52 @@ export const buildDashboardQueueItemHref = (
|
|||
openCoordinatorAssignment: boolean,
|
||||
): string => `/campanha/nucleos/${slug}${openCoordinatorAssignment ? '?assignCoordinators=1' : ''}`
|
||||
|
||||
const KpiCard = ({
|
||||
label,
|
||||
value,
|
||||
progress,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
progress?: number
|
||||
}) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
|
||||
</CardHeader>
|
||||
{progress !== undefined ? (
|
||||
<CardContent>
|
||||
<Progress value={progress} aria-label={`${label}: ${progress}%`} />
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
|
||||
const QueueList = ({
|
||||
const QueueSection = ({
|
||||
title,
|
||||
items,
|
||||
empty,
|
||||
priority = false,
|
||||
openCoordinatorAssignment = false,
|
||||
}: {
|
||||
title: string
|
||||
items: DashboardQueueItem[]
|
||||
empty: string
|
||||
priority?: boolean
|
||||
openCoordinatorAssignment?: boolean
|
||||
}) => (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<Badge variant={items.length ? 'estimate-pending' : 'secondary'}>{items.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Button asChild variant="ghost" className="h-auto min-h-11 w-full justify-between">
|
||||
<Link href={buildDashboardQueueItemHref(item.slug, openCoordinatorAssignment)}>
|
||||
<span className="min-w-0 text-left">
|
||||
<strong className="block truncate">{item.name}</strong>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{item.territory}
|
||||
</span>
|
||||
<div className={priority ? 'rounded-lg bg-muted/40 p-2' : undefined}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h3
|
||||
className={priority ? 'text-sm font-semibold' : 'text-sm font-medium text-muted-foreground'}
|
||||
>
|
||||
{priority ? `Prioridade · ${title}` : title}
|
||||
</h3>
|
||||
<Badge variant={items.length && priority ? 'estimate-pending' : 'secondary'}>
|
||||
{items.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{items.length ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Button asChild variant="ghost" className="h-auto min-h-11 w-full justify-between">
|
||||
<Link href={buildDashboardQueueItemHref(item.slug, openCoordinatorAssignment)}>
|
||||
<span className="min-w-0 text-left">
|
||||
<strong className="block truncate font-medium">{item.name}</strong>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{item.territory}
|
||||
</span>
|
||||
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{empty}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</span>
|
||||
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{empty}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const UpcomingActionPlansCard = ({ plans }: { plans: ActionPlanUpcomingPreviewRecord[] }) => (
|
||||
|
|
@ -135,9 +124,12 @@ const UpcomingActionPlansCard = ({ plans }: { plans: ActionPlanUpcomingPreviewRe
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
{plans.length ? (
|
||||
<ul className="flex flex-col gap-3">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{plans.map((plan) => (
|
||||
<li key={plan.id} className="flex flex-col gap-1 border-b pb-3 last:border-b-0 last:pb-0">
|
||||
<li
|
||||
key={plan.id}
|
||||
className="flex flex-col gap-1 border-b pb-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
|
|
@ -162,10 +154,10 @@ const UpcomingActionPlansCard = ({ plans }: { plans: ActionPlanUpcomingPreviewRe
|
|||
)
|
||||
|
||||
const GeneralDashboard = ({ view, now }: { view: GeneralDashboardViewModel; now: Date }) => (
|
||||
<div className="mr-auto flex w-full max-w-screen-2xl flex-col gap-6">
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Visão geral da campanha</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Visão geral da campanha</h1>
|
||||
<p className="text-muted-foreground">Cobertura, mobilização e pendências dos núcleos.</p>
|
||||
<CampaignScopeBadge>
|
||||
{getCampaignScopeLabel('geral', view.kpis.activeNuclei)}
|
||||
|
|
@ -181,79 +173,93 @@ const GeneralDashboard = ({ view, now }: { view: GeneralDashboardViewModel; now:
|
|||
|
||||
<RecentlyVisited now={now} />
|
||||
|
||||
<section aria-labelledby="campaign-kpis">
|
||||
<h2 id="campaign-kpis" className="sr-only">
|
||||
Indicadores da campanha
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
|
||||
<KpiCard label="Núcleos ativos" value={numberFormatter.format(view.kpis.activeNuclei)} />
|
||||
<KpiCard
|
||||
label="% com coordenador"
|
||||
value={`${view.kpis.coordinatorCoveragePercent}%`}
|
||||
progress={view.kpis.coordinatorCoveragePercent}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Meta regular (soma)"
|
||||
value={`${numberFormatter.format(view.kpis.regularVoteGoalTotal)} votos`}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Estimativas confirmadas"
|
||||
value={`${numberFormatter.format(view.kpis.confirmedVoteEstimateTotal)} votos`}
|
||||
/>
|
||||
<KpiCard
|
||||
label="% com estimativa confirmada"
|
||||
value={`${view.kpis.confirmedEstimatePercent}%`}
|
||||
progress={view.kpis.confirmedEstimatePercent}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Atualizações esta semana"
|
||||
value={numberFormatter.format(view.kpis.updatesThisWeek)}
|
||||
/>
|
||||
</div>
|
||||
<section aria-labelledby="action-queues" className="flex flex-col gap-2">
|
||||
<Card className={campaignPrioritySurfaceClassName}>
|
||||
<CardHeader>
|
||||
<CardTitle id="action-queues">Filas de ação</CardTitle>
|
||||
<CardDescription>
|
||||
Trate a prioridade primeiro; as demais filas vêm em seguida.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<QueueSection
|
||||
title="Sem coordenador"
|
||||
items={view.queues.withoutCoordinator}
|
||||
empty="Todos os núcleos ativos têm coordenação."
|
||||
openCoordinatorAssignment
|
||||
priority
|
||||
/>
|
||||
<QueueSection
|
||||
title="Sem atualização há mais de 7 dias"
|
||||
items={view.queues.withoutRecentUpdate}
|
||||
empty="Todos os núcleos estão com a cadência em dia."
|
||||
/>
|
||||
<QueueSection
|
||||
title="Estimativas pendentes"
|
||||
items={view.queues.pendingEstimate}
|
||||
empty="Nenhuma estimativa aguarda confirmação."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lideranças por status de apoio</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
<section aria-labelledby="campaign-kpis" className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<h2 id="campaign-kpis" className="text-sm font-medium text-muted-foreground">
|
||||
Indicadores
|
||||
</h2>
|
||||
<CampaignDataFreshness asOf={now} />
|
||||
</div>
|
||||
<CampaignMetricStrip
|
||||
metrics={[
|
||||
{
|
||||
label: 'Estimativas confirmadas',
|
||||
value: `${numberFormatter.format(view.kpis.confirmedVoteEstimateTotal)} votos`,
|
||||
emphasize: true,
|
||||
},
|
||||
{
|
||||
label: '% com estimativa',
|
||||
value: `${view.kpis.confirmedEstimatePercent}%`,
|
||||
progress: view.kpis.confirmedEstimatePercent,
|
||||
},
|
||||
{
|
||||
label: '% com coordenador',
|
||||
value: `${view.kpis.coordinatorCoveragePercent}%`,
|
||||
progress: view.kpis.coordinatorCoveragePercent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<CampaignMetricStrip
|
||||
metrics={[
|
||||
{
|
||||
label: 'Núcleos ativos',
|
||||
value: numberFormatter.format(view.kpis.activeNuclei),
|
||||
},
|
||||
{
|
||||
label: 'Meta regular (soma)',
|
||||
value: `${numberFormatter.format(view.kpis.regularVoteGoalTotal)} votos`,
|
||||
},
|
||||
{
|
||||
label: 'Atualizações esta semana',
|
||||
value: numberFormatter.format(view.kpis.updatesThisWeek),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||
<span className="font-medium text-muted-foreground">Lideranças</span>
|
||||
{leadershipSupportStatuses.map((status) => (
|
||||
<div key={status} className="flex items-center gap-2">
|
||||
<SupportStatusBadge status={status} />
|
||||
<strong className="tabular-nums">
|
||||
<span className="font-medium tabular-nums">
|
||||
{numberFormatter.format(view.supportCounts[status])}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section aria-labelledby="action-queues" className="flex flex-col gap-3">
|
||||
<h2 id="action-queues" className="text-lg font-semibold">
|
||||
Filas de ação
|
||||
</h2>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<QueueList
|
||||
title="Sem coordenador"
|
||||
items={view.queues.withoutCoordinator}
|
||||
empty="Todos os núcleos ativos têm coordenação."
|
||||
openCoordinatorAssignment
|
||||
/>
|
||||
<QueueList
|
||||
title="Sem atualização há mais de 7 dias"
|
||||
items={view.queues.withoutRecentUpdate}
|
||||
empty="Todos os núcleos estão com a cadência em dia."
|
||||
/>
|
||||
<QueueList
|
||||
title="Estimativas pendentes"
|
||||
items={view.queues.pendingEstimate}
|
||||
empty="Nenhuma estimativa aguarda confirmação."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UpcomingActionPlansCard plans={view.upcomingActionPlans} />
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
|
||||
const CoordinatorDashboard = ({
|
||||
|
|
@ -263,10 +269,10 @@ const CoordinatorDashboard = ({
|
|||
view: Extract<ScopedDashboardViewModel, { role: 'coordenador' }>
|
||||
now: Date
|
||||
}) => (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<CampaignScopeBadge>{getCampaignScopeLabel(view.role, view.cards.length)}</CampaignScopeBadge>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Sua região</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sua região</h1>
|
||||
<p className="text-muted-foreground">O que precisa de atenção nos seus núcleos?</p>
|
||||
</header>
|
||||
|
||||
|
|
@ -310,14 +316,18 @@ const CoordinatorDashboard = ({
|
|||
) : (
|
||||
<Empty className="border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<AlertTriangleIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum núcleo sob sua coordenação</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
A coordenação geral ainda não atribuiu um núcleo a você.
|
||||
A coordenação geral ainda não atribuiu um núcleo a você. Peça que te incluam como
|
||||
coordenador no núcleo — aí a região aparece aqui.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
|
||||
const LeadershipDashboard = ({
|
||||
|
|
@ -327,10 +337,10 @@ const LeadershipDashboard = ({
|
|||
view: Extract<ScopedDashboardViewModel, { role: 'lideranca' }>
|
||||
now: Date
|
||||
}) => (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-2">
|
||||
<CampaignScopeBadge>{getCampaignScopeLabel(view.role, view.cards.length)}</CampaignScopeBadge>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Meus núcleos</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Meus núcleos</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Envie seu reporte e acompanhe a estimativa confirmada.
|
||||
</p>
|
||||
|
|
@ -350,19 +360,28 @@ const LeadershipDashboard = ({
|
|||
{[card.organization, card.territory].filter(Boolean).join(' · ')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{card.tseZones.map((zoneNumber) => (
|
||||
<TseZoneBadge key={zoneNumber} zoneNumber={zoneNumber} />
|
||||
))}
|
||||
</div>
|
||||
<p>
|
||||
<strong>
|
||||
{card.confirmedVoteEstimate === null
|
||||
? 'Sem estimativa confirmada'
|
||||
: `${numberFormatter.format(card.confirmedVoteEstimate)} votos`}
|
||||
</strong>
|
||||
<p
|
||||
className={
|
||||
card.confirmedVoteEstimate === null
|
||||
? 'text-sm text-muted-foreground'
|
||||
: 'text-base font-medium tabular-nums'
|
||||
}
|
||||
>
|
||||
{card.confirmedVoteEstimate === null
|
||||
? 'Sem estimativa confirmada'
|
||||
: `${numberFormatter.format(card.confirmedVoteEstimate)} votos`}
|
||||
</p>
|
||||
{card.confirmedVoteEstimate === null ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sugira na página do núcleo; a coordenação confirma o número.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">Coordenação</p>
|
||||
{card.coordinators.length ? (
|
||||
|
|
@ -406,17 +425,27 @@ const LeadershipDashboard = ({
|
|||
) : (
|
||||
<Empty className="border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Nenhum núcleo disponível</EmptyTitle>
|
||||
<EmptyMedia variant="icon">
|
||||
<AlertTriangleIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Ainda sem núcleo por aqui</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Seu acesso depende de um vínculo engajado com o núcleo.
|
||||
Seu acesso a um núcleo só aparece depois que a coordenação te cadastra como liderança e
|
||||
marca o apoio como engajado (participando de verdade). Peça isso ao coordenador — o
|
||||
mesmo caminho de quem entra só com o celular.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<AlertTriangleIcon aria-hidden="true" />
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/perfil">Ver meu perfil</Link>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No perfil você confere seus dados de acesso enquanto a coordenação libera o núcleo.
|
||||
</p>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
|
||||
export const CampaignDashboard = ({
|
||||
|
|
|
|||
15
src/components/campaign/CampaignDataFreshness.tsx
Normal file
15
src/components/campaign/CampaignDataFreshness.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
|
||||
|
||||
/** Request-time aggregate stamp — absolute Bahia time on hover. */
|
||||
export const CampaignDataFreshness = ({ asOf, className }: { asOf: Date; className?: string }) => {
|
||||
const iso = asOf.toISOString()
|
||||
|
||||
return (
|
||||
<p className={cn('text-xs text-muted-foreground', className)}>
|
||||
<time dateTime={iso} title={formatBahiaDateTimeLabel(iso)}>
|
||||
Atualizado agora
|
||||
</time>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
53
src/components/campaign/CampaignMetricStrip.tsx
Normal file
53
src/components/campaign/CampaignMetricStrip.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Progress } from '@/components/ui/Progress'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type CampaignMetric = {
|
||||
label: string
|
||||
value: string
|
||||
emphasize?: boolean
|
||||
progress?: number
|
||||
}
|
||||
|
||||
export const CampaignMetricStrip = ({
|
||||
metrics,
|
||||
className,
|
||||
}: {
|
||||
metrics: CampaignMetric[]
|
||||
className?: string
|
||||
}) => (
|
||||
<dl
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-0 divide-y overflow-hidden rounded-xl bg-card ring-1 ring-foreground/10 sm:grid-cols-3 sm:divide-x sm:divide-y-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{metrics.map((metric) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-1 px-4 py-2',
|
||||
metric.emphasize ? 'bg-muted/40' : undefined,
|
||||
)}
|
||||
>
|
||||
<dt className="text-xs font-medium text-muted-foreground">{metric.label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'tabular-nums tracking-tight',
|
||||
metric.emphasize
|
||||
? 'text-2xl font-semibold text-foreground'
|
||||
: 'text-lg font-medium text-foreground',
|
||||
)}
|
||||
>
|
||||
{metric.value}
|
||||
</dd>
|
||||
{metric.progress !== undefined ? (
|
||||
<Progress
|
||||
value={metric.progress}
|
||||
className="mt-1"
|
||||
aria-label={`${metric.label}: ${metric.progress}%`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
21
src/components/campaign/CampaignPageShell.tsx
Normal file
21
src/components/campaign/CampaignPageShell.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const campaignPageShellClassName = 'mr-auto flex w-full max-w-screen-2xl flex-col gap-8'
|
||||
|
||||
export const campaignPrioritySurfaceClassName =
|
||||
'shadow-[0_4px_24px_rgb(28_25_23/0.08)] ring-1 ring-foreground/10'
|
||||
|
||||
export const CampaignPageShell = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
} & Omit<ComponentProps<'div'>, 'children' | 'className'>) => (
|
||||
<div className={cn(campaignPageShellClassName, className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -13,7 +13,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
import { campaignRoleLabels, type CampaignUserShellView } from '@/utilities/campaignUserProfile'
|
||||
|
|
@ -137,7 +136,12 @@ export const CampaignProfileSettings = ({
|
|||
<FormActionStatus state={removeAvatarState} />
|
||||
{user.avatarUrl ? (
|
||||
<form action={removeAvatarAction} className="mt-2">
|
||||
<Button type="submit" variant="outline" className="min-h-11" disabled={removeAvatarPending}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
disabled={removeAvatarPending}
|
||||
>
|
||||
Remover foto
|
||||
</Button>
|
||||
</form>
|
||||
|
|
@ -151,7 +155,7 @@ export const CampaignProfileSettings = ({
|
|||
<CardDescription>
|
||||
{user.email
|
||||
? 'Use sua senha atual para definir uma nova.'
|
||||
: 'Se você esqueceu a senha, peça um novo convite de acesso ao coordenador.'}
|
||||
: 'Se você esqueceu a senha e acessa só com celular, peça um novo convite de acesso ao coordenador.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -200,7 +204,9 @@ export const CampaignProfileSettings = ({
|
|||
<FieldError>{fieldError(passwordState.fieldErrors, 'password')}</FieldError>
|
||||
) : null}
|
||||
{fieldError(passwordState.fieldErrors, 'passwordConfirmation') ? (
|
||||
<FieldError>{fieldError(passwordState.fieldErrors, 'passwordConfirmation')}</FieldError>
|
||||
<FieldError>
|
||||
{fieldError(passwordState.fieldErrors, 'passwordConfirmation')}
|
||||
</FieldError>
|
||||
) : null}
|
||||
<Button type="submit" className="min-h-11" disabled={passwordPending}>
|
||||
{passwordPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
|
|
|
|||
|
|
@ -76,23 +76,23 @@ export const NucleusElectoralBaseline = ({
|
|||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
<div className="border-l-4 border-primary bg-primary/5 px-4 py-3.5">
|
||||
<div className="bg-muted/50 px-4 py-3.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-bold">{ticket.candidate.name}</p>
|
||||
<p className="text-xs font-semibold text-primary">
|
||||
<p className="text-sm font-semibold">{ticket.candidate.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ticket.candidate.party} · {ticket.candidate.officeLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-extrabold tabular-nums text-primary">
|
||||
<p className="text-lg font-semibold tabular-nums text-foreground">
|
||||
{formatElectionNumber(baseline.candidate.votes)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">votos · 2022</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 h-1.5 overflow-hidden rounded-full bg-primary/20"
|
||||
className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-valuenow={candidateBar}
|
||||
aria-valuemin={0}
|
||||
|
|
@ -107,9 +107,7 @@ export const NucleusElectoralBaseline = ({
|
|||
</div>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Série histórica
|
||||
</p>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">Série histórica</p>
|
||||
<p className="text-sm tabular-nums">{formatVoteTrendSeriesCompact(baseline.series)}</p>
|
||||
{trend.status !== 'noBaseline' ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{trend.message}</p>
|
||||
|
|
@ -147,9 +145,7 @@ export const NucleusElectoralBaseline = ({
|
|||
) : null}
|
||||
|
||||
<div className="bg-muted/40 px-4 py-3">
|
||||
<p className="mb-2.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Eleitorado 2022
|
||||
</p>
|
||||
<p className="mb-2.5 text-xs font-medium text-muted-foreground">Eleitorado 2022</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">Votos válidos</span>
|
||||
|
|
@ -183,7 +179,7 @@ export const NucleusElectoralBaseline = ({
|
|||
<div className="flex items-start gap-2">
|
||||
<TrophyIcon
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-4 shrink-0 text-[color:var(--estimate-pending-foreground)]"
|
||||
className="mt-0.5 size-4 shrink-0 text-estimate-pending-foreground"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Mais votado aqui em 2022</p>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
'use client'
|
||||
|
||||
import { type FormEvent, useRef, useState, useTransition } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useRef, useState, useTransition, type FormEvent } from 'react'
|
||||
|
||||
import { CampaignSearchInput } from '@/components/campaign/CampaignSearchInput'
|
||||
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
|
||||
|
|
@ -44,9 +44,14 @@ const valuesFromState = (state: NucleusListState): FilterValues => ({
|
|||
priority: state.priority ?? '',
|
||||
})
|
||||
|
||||
const filterNames = ['region', 'city', 'tseZone', 'coverage', 'estimate', 'priority'] as const
|
||||
const primaryFilterNames = ['region', 'city', 'tseZone'] as const
|
||||
const advancedFilterNames = ['coverage', 'estimate', 'priority'] as const
|
||||
const filterNames = [...primaryFilterNames, ...advancedFilterNames] as const
|
||||
|
||||
type FilterFieldsProps = {
|
||||
const countActive = (values: FilterValues, names: readonly (keyof FilterValues)[]) =>
|
||||
names.filter((name) => Boolean(values[name])).length
|
||||
|
||||
type SharedFilterHandlers = {
|
||||
values: FilterValues
|
||||
zoneError?: string
|
||||
updateFilter: (name: (typeof filterNames)[number], value: string) => void
|
||||
|
|
@ -54,17 +59,17 @@ type FilterFieldsProps = {
|
|||
updateCity: (city: string) => void
|
||||
}
|
||||
|
||||
const FilterFields = ({
|
||||
const PrimaryFilterFields = ({
|
||||
values,
|
||||
zoneError,
|
||||
updateFilter,
|
||||
updateTerritory,
|
||||
updateCity,
|
||||
}: FilterFieldsProps) => (
|
||||
}: SharedFilterHandlers) => (
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
className="grid gap-4 lg:grid-cols-3 xl:grid-cols-6"
|
||||
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="nucleus-lookup-a">Território de identidade</FieldLabel>
|
||||
|
|
@ -84,7 +89,7 @@ const FilterFields = ({
|
|||
onValueChange={updateCity}
|
||||
/>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(zoneError)}>
|
||||
<Field data-invalid={Boolean(zoneError)} className="sm:col-span-2 lg:col-span-1">
|
||||
<FieldLabel htmlFor="nucleus-tseZone">Nº da ZE</FieldLabel>
|
||||
<Input
|
||||
id="nucleus-tseZone"
|
||||
|
|
@ -99,10 +104,20 @@ const FilterFields = ({
|
|||
aria-describedby={zoneError ? 'nucleus-tseZone-error' : undefined}
|
||||
className="min-h-11 rounded-[6px]"
|
||||
/>
|
||||
{zoneError ? (
|
||||
<FieldError id="nucleus-tseZone-error">{zoneError}</FieldError>
|
||||
) : null}
|
||||
{zoneError ? <FieldError id="nucleus-tseZone-error">{zoneError}</FieldError> : null}
|
||||
</Field>
|
||||
</form>
|
||||
)
|
||||
|
||||
const AdvancedFilterFields = ({
|
||||
values,
|
||||
updateFilter,
|
||||
}: Pick<SharedFilterHandlers, 'values' | 'updateFilter'>) => (
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
className="grid gap-4 sm:grid-cols-3"
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="nucleus-coverage">Cobertura</FieldLabel>
|
||||
<NativeSelect
|
||||
|
|
@ -162,7 +177,9 @@ export const NucleusFilters = ({ state }: { state: NucleusListState }) => {
|
|||
const valuesRef = useRef(initialValues)
|
||||
const [values, setValues] = useState(initialValues)
|
||||
const [zoneError, setZoneError] = useState<string>()
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(
|
||||
() => countActive(initialValues, advancedFilterNames) > 0,
|
||||
)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const replaceValues = (nextValues: FilterValues) => {
|
||||
|
|
@ -206,7 +223,8 @@ export const NucleusFilters = ({ state }: { state: NucleusListState }) => {
|
|||
|
||||
const updateTerritory = (region: string) => {
|
||||
const current = valuesRef.current
|
||||
const city = current.city && region && territoryForCity(current.city) !== region ? '' : current.city
|
||||
const city =
|
||||
current.city && region && territoryForCity(current.city) !== region ? '' : current.city
|
||||
replaceValues({ ...current, region, city })
|
||||
}
|
||||
|
||||
|
|
@ -217,7 +235,9 @@ export const NucleusFilters = ({ state }: { state: NucleusListState }) => {
|
|||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
const cleared = Object.fromEntries(filterNames.map((name) => [name, ''])) as Partial<FilterValues>
|
||||
const cleared = Object.fromEntries(
|
||||
filterNames.map((name) => [name, '']),
|
||||
) as Partial<FilterValues>
|
||||
setZoneError(undefined)
|
||||
replaceValues({ ...valuesRef.current, ...cleared })
|
||||
}
|
||||
|
|
@ -228,84 +248,86 @@ export const NucleusFilters = ({ state }: { state: NucleusListState }) => {
|
|||
}
|
||||
|
||||
const hasFilters = filterNames.some((name) => Boolean(values[name]))
|
||||
const activeAdvancedCount = countActive(values, advancedFilterNames)
|
||||
const advancedToggleLabel =
|
||||
activeAdvancedCount > 0 ? `Mais filtros (${activeAdvancedCount})` : 'Mais filtros'
|
||||
|
||||
const handlers: SharedFilterHandlers = {
|
||||
values,
|
||||
zoneError,
|
||||
updateFilter,
|
||||
updateTerritory,
|
||||
updateCity,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
|
||||
className="flex flex-col gap-4 transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending}
|
||||
aria-busy={isPending}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando resultados…' : ''}
|
||||
</p>
|
||||
<div>
|
||||
<form onSubmit={submitSearch} className="flex gap-2">
|
||||
<CampaignSearchInput
|
||||
id="nucleus-search"
|
||||
label="Buscar núcleo ou número da Zona TSE"
|
||||
name="q"
|
||||
value={values.q}
|
||||
onChange={(event) => {
|
||||
const nextValues = { ...valuesRef.current, q: event.target.value }
|
||||
valuesRef.current = nextValues
|
||||
setValues(nextValues)
|
||||
}}
|
||||
placeholder="Buscar núcleo ou nº de zona"
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
<Button type="submit" className="min-h-11 rounded-[6px]" disabled={isPending}>
|
||||
Buscar
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[6px] border bg-card">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 w-full justify-start rounded-[6px] px-3 lg:hidden"
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
aria-controls="nucleus-filter-controls"
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<FilterIcon data-icon="inline-start" aria-hidden="true" />
|
||||
<span>Filtros</span>
|
||||
<ChevronDownIcon
|
||||
data-icon="inline-end"
|
||||
className="ml-auto transition-transform group-aria-expanded/button:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<form onSubmit={submitSearch} className="flex gap-2">
|
||||
<CampaignSearchInput
|
||||
id="nucleus-search"
|
||||
label="Buscar núcleo ou número da Zona TSE"
|
||||
name="q"
|
||||
value={values.q}
|
||||
onChange={(event) => {
|
||||
const nextValues = { ...valuesRef.current, q: event.target.value }
|
||||
valuesRef.current = nextValues
|
||||
setValues(nextValues)
|
||||
}}
|
||||
placeholder="Buscar núcleo ou nº de zona"
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
<Button type="submit" className="min-h-11 rounded-[6px]" disabled={isPending}>
|
||||
Buscar
|
||||
</Button>
|
||||
<div
|
||||
id="nucleus-filter-controls"
|
||||
className={cn(
|
||||
mobileFiltersOpen ? 'block' : 'hidden',
|
||||
'border-t p-4 lg:block lg:border-t-0',
|
||||
)}
|
||||
>
|
||||
<p className="mb-4 text-sm text-muted-foreground lg:hidden">
|
||||
Cada escolha atualiza os resultados e fica na URL para compartilhamento.
|
||||
</p>
|
||||
<FilterFields
|
||||
values={values}
|
||||
zoneError={zoneError}
|
||||
updateFilter={updateFilter}
|
||||
updateTerritory={updateTerritory}
|
||||
updateCity={updateCity}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-[6px] border bg-card p-4">
|
||||
<PrimaryFilterFields {...handlers} />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 rounded-[6px] px-3"
|
||||
aria-expanded={advancedOpen}
|
||||
aria-controls="nucleus-advanced-filters"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
>
|
||||
<FilterIcon data-icon="inline-start" aria-hidden="true" />
|
||||
<span>{advancedToggleLabel}</span>
|
||||
<ChevronDownIcon
|
||||
data-icon="inline-end"
|
||||
className={cn('ml-1 transition-transform', advancedOpen && 'rotate-180')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
{hasFilters ? (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 rounded-[6px]"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<XIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Limpar filtros
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 rounded-[6px]"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<XIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Limpar filtros
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div id="nucleus-advanced-filters" hidden={!advancedOpen}>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Cobertura, estimativa e prioridade. Cada escolha atualiza os resultados e fica na URL.
|
||||
</p>
|
||||
<AdvancedFilterFields values={values} updateFilter={updateFilter} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,19 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { CampaignDataFreshness } from '@/components/campaign/CampaignDataFreshness'
|
||||
import { CampaignMetricStrip, type CampaignMetric } from '@/components/campaign/CampaignMetricStrip'
|
||||
import { campaignPrioritySurfaceClassName } from '@/components/campaign/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/Progress'
|
||||
import { comparableTrendCount, formatElectionNumber } from '@/lib/electionInsights'
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
|
||||
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
|
||||
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
|
||||
import type { NucleusListOverviewViewModel } from '@/utilities/nucleusListOverviewViewModels'
|
||||
import { nucleusUpdateKindLabels } from '@/utilities/nucleusUpdateUi'
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('pt-BR')
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' })
|
||||
|
||||
const relativeDateLabel = (value: string, now: Date): string => {
|
||||
const days = Math.floor((new Date(value).getTime() - now.getTime()) / (24 * 60 * 60 * 1000))
|
||||
return relativeFormatter.format(days, 'day')
|
||||
}
|
||||
|
||||
export const NucleusListOverview = ({
|
||||
view,
|
||||
|
|
@ -23,30 +21,43 @@ export const NucleusListOverview = ({
|
|||
}: {
|
||||
view: NucleusListOverviewViewModel
|
||||
now: Date
|
||||
}) => (
|
||||
<section aria-labelledby="nucleus-list-overview" className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 id="nucleus-list-overview" className="sr-only">
|
||||
Visão geral dos núcleos filtrados
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mostrando agregados de{' '}
|
||||
<strong className="font-medium text-foreground tabular-nums">
|
||||
{numberFormatter.format(view.totalFiltered)}
|
||||
</strong>{' '}
|
||||
{view.totalFiltered === 1 ? 'núcleo filtrado' : 'núcleos filtrados'}
|
||||
</p>
|
||||
</div>
|
||||
}) => {
|
||||
const secondaryMetrics: CampaignMetric[] = [
|
||||
{
|
||||
label: 'Cobertura com coordenador',
|
||||
value: `${view.coverage.percent}%`,
|
||||
progress: view.coverage.percent,
|
||||
},
|
||||
{
|
||||
label: 'Meta regular 2026',
|
||||
value: numberFormatter.format(view.voteGoals.regular),
|
||||
},
|
||||
]
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<Card>
|
||||
return (
|
||||
<section aria-labelledby="nucleus-list-overview" className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:flex-wrap sm:items-baseline sm:justify-between sm:gap-x-4">
|
||||
<h2 id="nucleus-list-overview" className="sr-only">
|
||||
Visão geral dos núcleos filtrados
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mostrando agregados de{' '}
|
||||
<strong className="font-medium text-foreground tabular-nums">
|
||||
{numberFormatter.format(view.totalFiltered)}
|
||||
</strong>{' '}
|
||||
{view.totalFiltered === 1 ? 'núcleo filtrado' : 'núcleos filtrados'}
|
||||
</p>
|
||||
<CampaignDataFreshness asOf={now} />
|
||||
</div>
|
||||
|
||||
<Card className={campaignPrioritySurfaceClassName}>
|
||||
<CardHeader>
|
||||
<CardDescription>Estimativa de votos</CardDescription>
|
||||
<CardTitle className="text-3xl tabular-nums">
|
||||
<CardDescription>Estimativa de votos (prioridade)</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums tracking-tight">
|
||||
{numberFormatter.format(view.estimate.confirmedTotal)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<span>{view.estimate.confirmedPercent}% com estimativa confirmada</span>
|
||||
|
|
@ -68,182 +79,125 @@ export const NucleusListOverview = ({
|
|||
: 'sugestões pendentes'}
|
||||
</Badge>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Cobertura</CardDescription>
|
||||
<CardTitle className="text-3xl tabular-nums">{view.coverage.percent}%</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Progress
|
||||
value={view.coverage.percent}
|
||||
aria-label={`Cobertura com coordenador: ${view.coverage.percent}%`}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{numberFormatter.format(view.coverage.coordinatedCount)} de{' '}
|
||||
{numberFormatter.format(view.totalFiltered)} com coordenador
|
||||
{view.highPriorityCount > 0
|
||||
? ` · ${numberFormatter.format(view.highPriorityCount)} prioritários`
|
||||
: ''}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Metas 2026</CardDescription>
|
||||
<CardTitle className="text-3xl tabular-nums">
|
||||
{numberFormatter.format(view.voteGoals.regular)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Soma do cenário regular no conjunto filtrado
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Bom: {numberFormatter.format(view.voteGoals.good)} · Mínimo:{' '}
|
||||
{numberFormatter.format(view.voteGoals.minimum)}
|
||||
</p>
|
||||
<p className="text-sm font-medium">
|
||||
{numberFormatter.format(view.highPriorityCount)}{' '}
|
||||
{view.highPriorityCount === 1 ? 'núcleo prioritário' : 'núcleos prioritários'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{view.baseline2022 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Baseline 2022</CardDescription>
|
||||
<CardTitle className="text-3xl tabular-nums">
|
||||
{view.baseline2022.gapTotal === null
|
||||
? '—'
|
||||
: `${view.baseline2022.gapTotal >= 0 ? '+' : ''}${numberFormatter.format(view.baseline2022.gapTotal)}`}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{view.baseline2022 && view.baseline2022.gapTotal !== null ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{view.baseline2022.gapTotal === null
|
||||
? 'Sem estimativas comparáveis ao patamar de 2022'
|
||||
: view.baseline2022.gapTotal >= 0
|
||||
? 'votos acima do patamar'
|
||||
: 'votos abaixo do patamar'}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold text-[color:var(--estimate-confirmed-foreground)]">
|
||||
Baseline 2022:{' '}
|
||||
<span className="font-medium text-estimate-confirmed-foreground">
|
||||
{numberFormatter.format(view.baseline2022.above)} acima
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="font-semibold text-[color:var(--estimate-pending-foreground)]">
|
||||
<span className="font-medium text-estimate-pending-foreground">
|
||||
{numberFormatter.format(view.baseline2022.below)} abaixo
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="tabular-nums">
|
||||
{view.baseline2022.gapTotal >= 0 ? '+' : ''}
|
||||
{numberFormatter.format(view.baseline2022.gapTotal)} gap
|
||||
</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{view.trend ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Tendência histórica</CardDescription>
|
||||
<CardTitle className="text-3xl tabular-nums">
|
||||
{formatElectionNumber(comparableTrendCount(view.trend))}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
) : null}
|
||||
{view.trend ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Núcleos com série comparável (2014/2018/2022)
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold text-[color:var(--estimate-confirmed-foreground)]">
|
||||
Tendência:{' '}
|
||||
<span className="font-medium text-estimate-confirmed-foreground">
|
||||
{formatElectionNumber(view.trend.increase)} aumento
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="font-semibold text-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatElectionNumber(view.trend.stable)} mantém
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="font-semibold text-[color:var(--estimate-pending-foreground)]">
|
||||
<span className="font-medium text-estimate-pending-foreground">
|
||||
{formatElectionNumber(view.trend.decline)} queda
|
||||
</span>
|
||||
</p>
|
||||
{view.trend.noBaseline > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatElectionNumber(view.trend.noBaseline)} sem série suficiente
|
||||
</p>
|
||||
) : null}
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Demais indicadores</h3>
|
||||
<CampaignMetricStrip className="sm:grid-cols-2" metrics={secondaryMetrics} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2 lg:gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Últimas atualizações</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{view.recentUpdates.length ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{view.recentUpdates.map((update) => (
|
||||
<li
|
||||
key={update.id}
|
||||
className="flex flex-col gap-1 border-b pb-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{update.authorName}</span>
|
||||
<Badge variant={update.kind === 'urgente' ? 'destructive' : 'secondary'}>
|
||||
{nucleusUpdateKindLabels[update.kind]}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatRelativeAge(new Date(update.createdAt).getTime(), now.getTime())}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/campanha/nucleos/${update.nucleusSlug}?tab=updates`}
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{update.nucleusName}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nenhuma atualização recente</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Últimas atualizações</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{view.recentUpdates.length ? (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{view.recentUpdates.map((update) => (
|
||||
<li
|
||||
key={update.id}
|
||||
className="flex flex-col gap-1 border-b pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{update.authorName}</span>
|
||||
<Badge variant={update.kind === 'urgente' ? 'destructive' : 'secondary'}>
|
||||
{nucleusUpdateKindLabels[update.kind]}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{relativeDateLabel(update.createdAt, now)}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/campanha/nucleos/${update.nucleusSlug}?tab=updates`}
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{update.nucleusName}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nenhuma atualização recente</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Próximos eventos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{view.upcomingActionPlans.length ? (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{view.upcomingActionPlans.map((plan) => (
|
||||
<li
|
||||
key={plan.id}
|
||||
className="flex flex-col gap-1 border-b pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatBahiaDateTimeLabel(plan.startAt)}
|
||||
{plan.city ? ` · ${plan.city}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/campanha/planos/${plan.slug}`}
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{plan.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nenhum evento agendado</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Próximos eventos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{view.upcomingActionPlans.length ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{view.upcomingActionPlans.map((plan) => (
|
||||
<li
|
||||
key={plan.id}
|
||||
className="flex flex-col gap-1 border-b pb-2 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{actionPlanKindLabels[plan.kind]}</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatBahiaDateTimeLabel(plan.startAt)}
|
||||
{plan.city ? ` · ${plan.city}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/campanha/planos/${plan.slug}`}
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{plan.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nenhum evento agendado</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import Link from 'next/link'
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MessageSquareTextIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/Empty'
|
||||
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
|
||||
import type { NucleusUpdatesPageData } from '@/utilities/nucleusUpdatePageData'
|
||||
import {
|
||||
buildNucleusUpdateHref,
|
||||
|
|
@ -27,13 +28,6 @@ const updateFilters: Array<{ label: string; state: NucleusUpdateListState }> = [
|
|||
})),
|
||||
]
|
||||
|
||||
const relativeDate = (value: string, now: string): string => {
|
||||
const elapsedDays = Math.round(
|
||||
(new Date(value).getTime() - new Date(now).getTime()) / (24 * 60 * 60 * 1000),
|
||||
)
|
||||
return new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' }).format(elapsedDays, 'day')
|
||||
}
|
||||
|
||||
const UpdateCard = ({ update, now }: { update: NucleusUpdateViewModel; now: string }) => (
|
||||
<article>
|
||||
<Card>
|
||||
|
|
@ -43,7 +37,7 @@ const UpdateCard = ({ update, now }: { update: NucleusUpdateViewModel; now: stri
|
|||
{nucleusUpdateKindLabels[update.kind]}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{relativeDate(update.createdAt, now)}
|
||||
{formatRelativeAge(new Date(update.createdAt).getTime(), new Date(now).getTime())}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-base">{update.authorName}</CardTitle>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,39 @@
|
|||
import { CampaignDataFreshness } from '@/components/campaign/CampaignDataFreshness'
|
||||
import { CampaignMetricStrip } from '@/components/campaign/CampaignMetricStrip'
|
||||
import type { SupporterListOverviewViewModel } from '@/utilities/supporterViewModels'
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('pt-BR')
|
||||
|
||||
export const SupporterListOverview = ({
|
||||
view,
|
||||
now,
|
||||
}: {
|
||||
view: SupporterListOverviewViewModel
|
||||
now: Date
|
||||
}) => (
|
||||
<section aria-labelledby="supporter-list-overview" className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<h2 id="supporter-list-overview" className="sr-only">
|
||||
Indicadores dos apoiadores filtrados
|
||||
</h2>
|
||||
<div className="rounded-[6px] border bg-card p-4 text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">{numberFormatter.format(view.total)}</p>
|
||||
<p className="text-sm text-muted-foreground">Total</p>
|
||||
</div>
|
||||
<div className="rounded-[6px] border bg-card p-4 text-center">
|
||||
<p className="text-2xl font-bold tabular-nums text-estimate-confirmed-foreground">
|
||||
{numberFormatter.format(view.certoAndTende)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Certo + Tende</p>
|
||||
</div>
|
||||
<div className="rounded-[6px] border bg-card p-4 text-center">
|
||||
<p className="text-2xl font-bold tabular-nums text-estimate-pending-foreground">
|
||||
{numberFormatter.format(view.indeciso)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Indecisos</p>
|
||||
<section aria-labelledby="supporter-list-overview" className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<h2 id="supporter-list-overview" className="sr-only">
|
||||
Indicadores dos apoiadores filtrados
|
||||
</h2>
|
||||
<CampaignDataFreshness asOf={now} />
|
||||
</div>
|
||||
<CampaignMetricStrip
|
||||
metrics={[
|
||||
{
|
||||
label: 'Total',
|
||||
value: numberFormatter.format(view.total),
|
||||
},
|
||||
{
|
||||
label: 'Certo + Tende',
|
||||
value: numberFormatter.format(view.certoAndTende),
|
||||
emphasize: true,
|
||||
},
|
||||
{
|
||||
label: 'Indecisos',
|
||||
value: numberFormatter.format(view.indeciso),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable check-file/filename-naming-convention */
|
||||
import { test, expect } from './fixtures/campaignE2EFixtures'
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures'
|
||||
|
||||
test.describe('Campaign composition and accessibility', () => {
|
||||
test.describe.configure({ mode: 'parallel' })
|
||||
|
|
@ -82,18 +82,26 @@ test.describe('Campaign composition and accessibility', () => {
|
|||
await campaign.login(page, email, password)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto(`${campaign.baseURL}/campanha/nucleos`)
|
||||
const toggle = page.getByRole('button', { name: 'Filtros' })
|
||||
const controls = page.locator('#nucleus-filter-controls')
|
||||
|
||||
await expect(page.getByLabel('Território de identidade')).toBeVisible()
|
||||
await expect(page.getByLabel('Município')).toBeVisible()
|
||||
|
||||
const toggle = page.getByRole('button', { name: 'Mais filtros' })
|
||||
const controls = page.locator('#nucleus-advanced-filters')
|
||||
await expect(controls).toBeHidden()
|
||||
await toggle.click()
|
||||
await expect(controls).toBeVisible()
|
||||
await expect(page.getByLabel('Cobertura')).toBeVisible()
|
||||
await expect(page.getByLabel('Território de identidade')).toHaveCount(1)
|
||||
await toggle.click()
|
||||
await expect(controls).toBeHidden()
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(toggle).toBeHidden()
|
||||
await expect(controls).toBeVisible()
|
||||
await expect(page.getByLabel('Território de identidade')).toBeVisible()
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(controls).toBeHidden()
|
||||
await toggle.click()
|
||||
await expect(controls).toBeVisible()
|
||||
await expect(page.getByLabel('Cobertura')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createElement } from 'react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const replace = vi.fn()
|
||||
|
|
@ -105,29 +105,42 @@ describe('campaign nucleus automatic filters', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('uses a 44px semantic mobile disclosure button controlling the single filters tree', () => {
|
||||
render(createElement(NucleusFilters, { state: initialState }))
|
||||
it('keeps territory filters visible and discloses advanced filters behind Mais filtros', () => {
|
||||
render(createElement(NucleusFilters, { state: { page: 1 } }))
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Filtros' })
|
||||
expect(screen.getByLabelText('Território de identidade')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Município')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Nº da ZE')).toBeTruthy()
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Mais filtros' })
|
||||
const controlsID = button.getAttribute('aria-controls')
|
||||
const controls = controlsID ? document.getElementById(controlsID) : null
|
||||
|
||||
expect(document.querySelector('details')).toBeNull()
|
||||
expect(button.className).toContain('min-h-11')
|
||||
expect(button.className).toContain('lg:hidden')
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(controls).not.toBeNull()
|
||||
expect(controls?.className).toContain('hidden')
|
||||
expect(controls?.className).toContain('lg:block')
|
||||
expect(controls?.hasAttribute('hidden')).toBe(true)
|
||||
expect(controls?.getAttribute('hidden')).not.toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(controls?.className).toContain('block')
|
||||
expect(controls?.hasAttribute('hidden')).toBe(false)
|
||||
expect(screen.getByLabelText('Cobertura')).toBeTruthy()
|
||||
expect(screen.getAllByLabelText('Território de identidade')).toHaveLength(1)
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(controls?.className).toContain('hidden')
|
||||
expect(controls?.hasAttribute('hidden')).toBe(true)
|
||||
})
|
||||
|
||||
it('opens advanced filters by default when coverage/estimate/priority are already in the URL', () => {
|
||||
render(createElement(NucleusFilters, { state: initialState }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Mais filtros \(2\)/ }).getAttribute('aria-expanded'),
|
||||
).toBe('true')
|
||||
expect(screen.getByLabelText('Cobertura')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('applies selects and valid ZE immediately while preserving the other URL filters', async () => {
|
||||
|
|
@ -150,13 +163,13 @@ describe('campaign nucleus automatic filters', () => {
|
|||
it.each(['01', '5e1', '58.0', '+58', '-58', ' 58', '58 ', '1000'])(
|
||||
'rejects client ZE value %j without replacing the server query',
|
||||
(value) => {
|
||||
render(createElement(NucleusFilters, { state: initialState }))
|
||||
render(createElement(NucleusFilters, { state: initialState }))
|
||||
|
||||
const zone = screen.getByLabelText('Nº da ZE')
|
||||
const zone = screen.getByLabelText('Nº da ZE')
|
||||
fireEvent.change(zone, { target: { value } })
|
||||
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Informe uma Zona Eleitoral de 1 a 999.')).toBeTruthy()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Informe uma Zona Eleitoral de 1 a 999.')).toBeTruthy()
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -198,12 +211,11 @@ describe('campaign nucleus automatic filters', () => {
|
|||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
(screen.getByLabelText('Território de identidade') as HTMLInputElement).value,
|
||||
).toBe('Itaparica')
|
||||
expect((screen.getByLabelText('Município') as HTMLInputElement).value).toBe(
|
||||
'Paulo Afonso',
|
||||
expect((screen.getByLabelText('Território de identidade') as HTMLInputElement).value).toBe(
|
||||
'Itaparica',
|
||||
)
|
||||
expect((screen.getByLabelText('Município') as HTMLInputElement).value).toBe('Paulo Afonso')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Mais filtros' }))
|
||||
expect((screen.getByLabelText('Cobertura') as HTMLSelectElement).value).toBe('')
|
||||
expect(
|
||||
(screen.getByLabelText('Buscar núcleo ou número da Zona TSE') as HTMLInputElement).value,
|
||||
|
|
@ -225,15 +237,15 @@ describe('campaign nucleus automatic filters', () => {
|
|||
it('removes non-canonical state before emitting a client update', async () => {
|
||||
render(createElement(NucleusFilters, { state: { page: 1 } }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Mais filtros' }))
|
||||
fireEvent.change(screen.getByLabelText('Cobertura'), {
|
||||
target: { value: 'com_coordenador' },
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(replace).toHaveBeenLastCalledWith(
|
||||
'/campanha/nucleos?coverage=com_coordenador',
|
||||
{ scroll: false },
|
||||
),
|
||||
expect(replace).toHaveBeenLastCalledWith('/campanha/nucleos?coverage=com_coordenador', {
|
||||
scroll: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -387,9 +399,11 @@ describe('campaign nucleus server filter parsing', () => {
|
|||
const resolved = resolveNucleusListUrl(params)
|
||||
|
||||
expect(resolved.redirectHref).toBeUndefined()
|
||||
expect(resolveNucleusListUrl(Object.fromEntries(new URL(resolved.href, 'https://teqo.test').searchParams))).toEqual(
|
||||
resolved,
|
||||
)
|
||||
expect(
|
||||
resolveNucleusListUrl(
|
||||
Object.fromEntries(new URL(resolved.href, 'https://teqo.test').searchParams),
|
||||
),
|
||||
).toEqual(resolved)
|
||||
})
|
||||
|
||||
it('redirects valid parameters supplied out of canonical order', () => {
|
||||
|
|
@ -400,9 +414,7 @@ describe('campaign nucleus server filter parsing', () => {
|
|||
region: 'Chapada Diamantina',
|
||||
q: 'Chapada',
|
||||
}).redirectHref,
|
||||
).toBe(
|
||||
'/campanha/nucleos?q=Chapada®ion=Chapada+Diamantina&city=Seabra&page=2',
|
||||
)
|
||||
).toBe('/campanha/nucleos?q=Chapada®ion=Chapada+Diamantina&city=Seabra&page=2')
|
||||
})
|
||||
|
||||
it('uses the same canonical resolver for out-of-range pagination', () => {
|
||||
|
|
@ -484,9 +496,7 @@ describe('campaign nucleus server filter parsing', () => {
|
|||
},
|
||||
2,
|
||||
),
|
||||
).toBe(
|
||||
'/campanha/nucleos?region=Chapada+Diamantina&city=Mucug%C3%AA&tseZone=50&page=2',
|
||||
)
|
||||
).toBe('/campanha/nucleos?region=Chapada+Diamantina&city=Mucug%C3%AA&tseZone=50&page=2')
|
||||
|
||||
expect(
|
||||
buildNucleusListHref(
|
||||
|
|
|
|||
|
|
@ -140,10 +140,11 @@ describe('NucleusListOverview', () => {
|
|||
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
|
||||
|
||||
expect(html).toContain('Mostrando agregados de')
|
||||
expect(html).toContain('Atualizado agora')
|
||||
expect(html).toContain('1.200')
|
||||
expect(html).toContain('50% com estimativa confirmada')
|
||||
expect(html).toContain('1 de 2 com coordenador')
|
||||
expect(html).toContain('Metas 2026')
|
||||
expect(html).toContain('Meta regular 2026')
|
||||
expect(html).toContain('Últimas atualizações')
|
||||
expect(html).toContain('Maria')
|
||||
expect(html).toContain('Semanal')
|
||||
|
|
@ -184,6 +185,8 @@ describe('NucleusListOverview', () => {
|
|||
expect(html).toContain('+2.140')
|
||||
expect(html).toContain('8 acima')
|
||||
expect(html).toContain('6 abaixo')
|
||||
expect(html).toContain('+2.140 gap')
|
||||
expect(html).not.toContain('Gap vs 2022')
|
||||
})
|
||||
|
||||
it('hides the Baseline 2022 card when aggregate is null', () => {
|
||||
|
|
@ -211,9 +214,10 @@ describe('NucleusListOverview', () => {
|
|||
|
||||
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
|
||||
|
||||
expect(html).toContain('Tendência histórica')
|
||||
expect(html).toContain('Tendência:')
|
||||
expect(html).toContain('2 aumento')
|
||||
expect(html).toContain('5 mantém')
|
||||
expect(html).toContain('3 queda')
|
||||
expect(html).not.toContain('Com série histórica')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
18
tests/unit/formatRelativeAge.unit.spec.ts
Normal file
18
tests/unit/formatRelativeAge.unit.spec.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
|
||||
|
||||
const now = Date.parse('2026-07-19T12:00:00.000Z')
|
||||
|
||||
describe('formatRelativeAge', () => {
|
||||
it('formats minutes and hours with numeric cues', () => {
|
||||
expect(formatRelativeAge(now - 5 * 60_000, now)).toContain('5')
|
||||
expect(formatRelativeAge(now - 3 * 60 * 60_000, now)).toContain('3')
|
||||
})
|
||||
|
||||
it('formats multi-day spans via Intl auto rules', () => {
|
||||
expect(formatRelativeAge(now - 2 * 24 * 60 * 60_000, now)).toBe(
|
||||
new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' }).format(-2, 'day'),
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue