Merge origin/main into C117 (C116/C119/OPS40) — sortable heads sobre células editáveis
This commit is contained in:
commit
a8414a49f1
167 changed files with 35146 additions and 675 deletions
|
|
@ -124,7 +124,10 @@ Mostre ao usuário **antes** de editar docs:
|
|||
|
||||
Inclua baldes **já_resolvido**, **descartar** e **defer** com uma linha de racional/gatilho cada (transparência > silêncio).
|
||||
|
||||
**Pare e confirme** — exceto em **modo autônomo** (`agent-work-issue` / pool): aí aplique a seção Modo autônomo abaixo e pule o AskQuestion. Só avance ao Passo 6 com aprovação explícita (humano) ou com as regras do modo autônomo. Sem isso = não cria Issue nem toca `docs/plans/`.
|
||||
**Pare e confirme** — exceto em **modo autônomo** (`work-issue` pós-plano /
|
||||
`agent-work-issue` / pool): aí aplique a seção Modo autônomo abaixo e pule o
|
||||
AskQuestion. Só avance ao Passo 6 com aprovação explícita (humano) ou com as
|
||||
regras do modo autônomo. Sem isso = não cria Issue nem toca `docs/plans/`.
|
||||
|
||||
## Passo 6 — Registrar via `agent:register` / `agent:file-miss`
|
||||
|
||||
|
|
@ -143,14 +146,21 @@ Não implemente as fases aqui. Execução é via `work-issue` / `agent-work-issu
|
|||
|
||||
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR `--base main` com `Closes #N` → auto-merge → CI).
|
||||
|
||||
## Modo autônomo (`agent-work-issue` / pool)
|
||||
## Modo autônomo (`work-issue` pós-plano / `agent-work-issue` / pool)
|
||||
|
||||
Quando não há humano no gate (Passo 5):
|
||||
|
||||
1. Faça colheita, dedup, score e tipo como de costume.
|
||||
2. **Registre** só itens `expensive_lock` com score ≥4 (Issue nova + `depends` no pai se preciso; plano curto se score ≥3 eng).
|
||||
3. Todo o resto → **defer** (gatilho no `*-impl.md` / Explicitamente fora) ou **descartar** — não abra AskQuestion.
|
||||
4. Resuma no comentário/fechamento o que registrou vs deferiu.
|
||||
2. **`work-issue`** (humano presente só no plano): decida o destino pela
|
||||
triage completa (Passos 3–4) — registre o que a triage manda (score ≥3
|
||||
registrar, expensive_lock com piso 4–5), absorva em plano existente,
|
||||
defira com gatilho ou descarte o resto — **sem abrir AskQuestion**.
|
||||
3. **Pool (`agent-work-issue`):** registre só itens `expensive_lock` com
|
||||
score ≥4 (Issue nova + `depends` no pai se preciso; plano curto se
|
||||
score ≥3 eng).
|
||||
4. Todo o resto → **defer** (gatilho no `*-impl.md` / Explicitamente fora)
|
||||
ou **descartar** — não abra AskQuestion.
|
||||
5. Resuma no comentário/fechamento o que registrou vs deferiu vs descartou.
|
||||
|
||||
## Anti-padrões (baseline)
|
||||
|
||||
|
|
@ -162,7 +172,7 @@ Quando não há humano no gate (Passo 5):
|
|||
| "Junto DRY e UX num FD+" | Quebra o precedente FD+/FD2; tipos separados |
|
||||
| "DRY com 1 call site vira escala-dry" | defer_trigger + gatilho; não registrar epic YAGNI |
|
||||
| "Não li o plano existente" | Grep primeiro; absorver > duplicar |
|
||||
| "Registro sem perguntar" | Passo 5 é gate; docs de produto não são scratchpad |
|
||||
| "Registro sem perguntar" | Passo 5 é gate (humano-gated); em modo autônomo o agente decide e registra |
|
||||
| "Corto access/LGPD do lote por tempo" | expensive_lock nunca é cortável por appetite |
|
||||
|
||||
## Resumo ao usuário
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
---
|
||||
name: work-issue
|
||||
description: >-
|
||||
Executa uma Issue já claimada de ponta a ponta com supervisão humana:
|
||||
o contexto da sessão identifica a Issue (sem claim), Plan mode com plano
|
||||
de implementação, pausa para confirmação humana, depois execução →
|
||||
/simplify → capture-review-debts com gate → PR Ready em main com
|
||||
auto-merge. Use quando o usuário pedir /work-issue, quiser supervisionar
|
||||
a abordagem de engenharia, ou trabalhar uma Issue com gate humano antes
|
||||
do código.
|
||||
Executa uma Issue já claimada de ponta a ponta com supervisão humana no
|
||||
plano: o contexto da sessão identifica a Issue (sem claim), Plan mode com
|
||||
plano de implementação, pausa para confirmação humana, depois execução →
|
||||
/simplify → capture-review-debts autônomo (o agente decide o que registrar
|
||||
e o que descartar) → PR Ready em main com auto-merge. Use quando o usuário
|
||||
pedir /work-issue, quiser supervisionar a abordagem de engenharia, ou
|
||||
trabalhar uma Issue com gate humano antes do código.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ pular a pausa do impl plan; Draft / sem auto-merge.
|
|||
- [ ] 1. Contexto: Issue do prompt/`$ARGUMENTS` da sessão (ausente → UMA pergunta com validação; nunca claim)
|
||||
- [ ] 2. Abrir o plano de intenção do body da Issue (`Plano: docs/plans/...`; sem link → body é spec)
|
||||
- [ ] 3. Plan mode → escrever `docs/plans/<slug>-impl.md` → **PARAR e confirmar com o humano**
|
||||
- [ ] 4. Após “ok”: Passo 4 (execução → /simplify → débitos com gate → PR, via `execution-pipeline.md`)
|
||||
- [ ] 4. Após “ok”: Passo 4 (execução → /simplify → débitos autônomos → PR, via `execution-pipeline.md`)
|
||||
```
|
||||
|
||||
## Passo 1 — Contexto da sessão
|
||||
|
|
@ -96,9 +96,11 @@ fechar em main), com os deltas do ator humano:
|
|||
|
||||
- **Branch:** `<Code>-<slug>` do worktree — nunca crie branch nova na sessão.
|
||||
- **UI:** shape → craft → critique → polish.
|
||||
- **`capture-review-debts`:** **com gate humano** — colha e pontue como a
|
||||
skill manda, apresente ao humano antes de registrar (Issues novas com
|
||||
`depends` no pai se necessário).
|
||||
- **`capture-review-debts`:** **autônomo** — colha, dedupe, pontue e **decida
|
||||
você mesmo o destino** (registrar/absorver/deferir/descartar) pela triage
|
||||
da própria skill, **sem pausa para o humano**; registre via
|
||||
`agent:register` / `agent:file-miss` (Issues novas com `depends` no pai se
|
||||
necessário) e resuma o que registrou vs descartou no fechamento.
|
||||
|
||||
## Resumo final
|
||||
|
||||
|
|
|
|||
|
|
@ -40,5 +40,5 @@ entrega.
|
|||
|
||||
| Ator | Branch | UI | `capture-review-debts` | Cloud |
|
||||
| ---- | ------ | --- | ---------------------- | ----- |
|
||||
| **Humano** (`work-issue`) | `<Code>-<slug>` (worktree; nunca crie branch nova na sessão) | shape → craft → critique → polish | **com gate humano** — colha, pontue e apresente antes de registrar | n/a (máquina do humano) |
|
||||
| **Humano** (`work-issue`) | `<Code>-<slug>` (worktree; nunca crie branch nova na sessão) | shape → craft → critique → polish | **autônomo** — decide o destino dos achados (registrar/absorver/deferir/descartar) pela triage da skill; sem pausa para o humano | n/a (máquina do humano) |
|
||||
| **Pool** (`agent-work-issue`) | `agent/<id>-<slug>` (worktrees Cursor podem já ter criado) | shape → craft → critique → polish (harden/optimize só sob gatilho) | **autônomo** — só `expensive_lock` com score ≥4 (Issues novas com `depends`); score ≤3 / cheap_polish / defer_trigger → defer no `*-impl.md` ou descarte | `ManagePullRequest` com `draft: false`; Prep Cloud no Passo 0 |
|
||||
|
|
|
|||
|
|
@ -44,3 +44,11 @@ DEEPSEEK_API_KEY=YOUR_DEEPSEEK_API_KEY_HERE
|
|||
# Deep Infra API key for the Sollinha voice transcription (STT, Whisper large-v3)
|
||||
# — cheapest speech-to-text around ($0.00045/audio min). Get one at https://deepinfra.com
|
||||
DEEPINFRA_API_KEY=YOUR_DEEPINFRA_API_KEY_HERE
|
||||
|
||||
# C114 — Google Calendar mirror (Agenda da Campanha): base64 of the Google Cloud
|
||||
# service-account JSON key (cat key.json | base64 -w0). The service account must
|
||||
# be granted "make changes to events" on the campaign's public calendar; the
|
||||
# calendar ID itself is configured by an admin in the `googleCalendarSync`
|
||||
# collection. Absent/malformed → the agenda shows "não configurado" and the
|
||||
# mirror stays off — the Teqo agenda never depends on this.
|
||||
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY=
|
||||
|
|
|
|||
|
|
@ -4,13 +4,22 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-11):** **C116** (Issue #655) — `/campanha/pessoas` desktop vira **edit-where-you-see**: toda célula (exceto Ações) é um input permanente sem destaque — salva no blur/Enter, Esc descarta, vazio mostra "—" apagado. **Nome** mantém o valor como link para a ficha (`/campanha/liderancas/[id]`) com o mecanismo travado no gate: input invisível (`text-transparent`, z-0) sobre o link (z-10, `pointer-events-auto`, `w-auto` — cobre só o texto; o resto da célula é a área clicável do input), draft renderizado **no texto do link** (o valor real do input nunca é o display), clique no link navega, teclado/área fora do texto edita. **Células de municípios** (Assessora, Lidera, Aliada em) e **Assessorado** usam chips internos com: grupo novo **"Salvador (19)"** (`buildMunicipalityPortfolioChips` ganha kind `city`, checado antes do território — senão as 19 ZEs colapsariam como "Metropolitano de Salvador"), **chip batch clicável expande in loco** (estado de apresentação do wrapper `PeopleMunicipalityCell` — clique expande, mouse-leave colapsa se todos os membros seguem atribuídos; X num membro remove e impede o recolapso), **"+X"** no toggle de overflow do B170, `quiet` (sem tint de hover/foco; indicador de foco discreto só no input), commit por **match exato** no Enter/espaço (digitar "salvador-ze-2"+Enter adiciona a ZE certa, não a pre-selecionada — regra da intenção). Escrita: `updatePersonContactRecord` (escopo "edita o que vê" — assessor só com entidade da pessoa na carteira; `contactFieldUpdateSchema` ganhou variante `city`, convivendo com `phones` do C112), `setPersonAssessoraMembershipRecord` (unrestricted-only, single-account — multi-conta nega com mensagem segura e célula read-only; toggle extraído para `toggleMunicipalityAdvisorMembership` em `municipality.ts`, reusado pelo record B27) e `setPersonAdvisorMembershipRecord` (union person-centric: delta de assessor nas DUAS entidades quando ambas existem). Matriz por papel na página: assessor edita Lidera/Aliada com `addableIds`=carteira e **read-only** em Assessora/Assessorado (regras das collections donas); tooltips de label (WhatsApp/Convidar/Apagar) via `CampaignHoverTooltip`; `CampaignListSheetProvider` na tabela (guard miss #52 — wrapper de composição registrado no `sharedSheetHostConventions`). Mobile intacto. Sem migration. Testes: unit (grupo Salvador + expand + match exato; guard do sheet host), int novo `campaignPersonCellEditing` (11 — escopo por papel, assessora single/multi/sem-conta, union nas duas entidades), e2e `campaignPeople` 4/4. Intenção [`pessoas-edicao-inplace-lista.md`](plans/pessoas-edicao-inplace-lista.md); impl [`pessoas-edicao-inplace-lista-impl.md`](plans/pessoas-edicao-inplace-lista-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **B198** (Issue #660) — o drawer mobile do Sollinha fecha ao navegar por link interno: tocar num link `/campanha…` da resposta fecha o drawer **no toque** (antes de a página carregar) e a navegação segue no mesmo tab; desktop inalterado (painel fica aberto); link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre (o close persiste `open: false`). **Engrenagem:** a decisão pura mora no owner `src/lib/ai/markdownLinks.ts` — regex `^\/campanha(?:\/|$)` promovido a `isCampaignInternalLink` (era `APP_INTERNAL_LINK` no renderer) + `shouldCloseDrawerOnLinkClick(href, isMobile, event)` (fecha ⇔ interno && mobile && clique simples — ignora ctrl/meta/shift/alt, middle-click e `defaultPrevented`, pois abrem nova aba e a origem mantém o chat); o `markdownComponents` estático do `CampaignAIChat` virou `useMemo` no corpo (acesso ao `ctx.isMobile`/`setOpen`; identidade estável, recomputa só no crossing de breakpoint). Sessão/restauração intactas (fechar com `requestOpen(false)` marca `openBy:'user'` com `open:false` — inerte, o restore só reabre com `open:true`). Testes: unit (12 — interno/externo, viewport, modifiers, middle, prevented) + e2e no `campaignSollinhaContext` (interno fecha+navega sem `load`+reload não reabre; externo mantém drawer e abre popup interceptado). Nota: dev-mode local flakeou no prewarm/compile cold — e2e rodado no modo CI (`E2E_PROD=1` com `NEXT_DIST_DIR=.next/e2e`, 7/7). **Débito registrado no gate (B199/#672, `depends` #660):** race pré-existente do persist effect (só grava com `status==='ready'`) — fechar o drawer mid-stream e recarregar antes do settle reabre o drawer "sozinho" (classe OPS22); o tap-no-link do B198 amplia a superfície. Intenção [`sollinha-drawer-fecha-link-mobile.md`](plans/sollinha-drawer-fecha-link-mobile.md); impl [`sollinha-drawer-fecha-link-mobile-impl.md`](plans/sollinha-drawer-fecha-link-mobile-impl.md).
|
||||
**Recently resolved (2026-08-11):** **C114** (Issue #635) — Agenda da Campanha espelhada no Google Calendar com push Teqo→Google (API oficial, service account). **Motor:** reconciliação completa por passada (`runCampaignCalendarSync`) com **id determinístico de evento** (`teqo` + base32hex do id — charset do Google) → upsert idempotente sem estado por evento; janela 90d passado/365d futuro (precedente do feed iCal), espelho cheio do escopo do staff (sem intersecção de advisor — agenda oficial), cancelado/deletado no Teqo **apaga o evento no Google** (sem estado cancelado visível a quem segue); conteúdo comparado por instante absoluto (imune a fuso do calendário) → passadas repetidas não tocam o Google; eventos estrangeiros do calendário nunca são tocados (decode completo — um `teqo3` manual não é nosso). **Triggers (sem cron — Hobby):** hook `afterChange`/`afterDelete` na `activity` (só campos espelhados, com normalização de shapes populados vs depth-0; tarefas não disparam; **nunca lança** — Teqo independe do Google; timeout 15s por request), auto-retry na view da agenda quando `paused` (uma vez por carregamento), botão "Sincronizar agora", e **auto-resync por config** (D7): trocar `calendarId`/reativar na collection `googleCalendarSync` re-conciliar sozinho (guard por comparação de campo quebra o loop de re-entrada); scan de delete com lookback de 730d (cancelado antigo não vira fantasma; atividade viva fora da janela mantém o evento). **Estado:** collection `googleCalendarSync` (uma linha; `calendarId` admin-only, `disabledAt` staff, timestamps system-write; **status derivado** `not-configured | disabled | synced | paused` em lib pura) + **chave da service account só em env** (`GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`, base64 do JSON do GCP — sem credencial no DB); sem env → `not-configured` com runbook de ops (criar calendário na conta da campanha, público no link, SA editor, calendarId no Painel). **Transporte:** `jose` (novo dep) + fetch → Calendar API v3 REST (token JWT assertion, cache em módulo, 401 re-minta uma vez, 409 de insert idempotente; `end` obrigatório → atividade sem `endAt` vira 1h). **UI (Impeccable B, padrão C94):** pill no header da agenda (sincronizado/pausado/não configurado/desativado) + FAB mobile + diálogo com os 4 estados do canvas (link público de adição `calendar/r?cid=webcal…` + instruções + sync agora + desativar; falha de action visível). **Sem participantes no v1** (decisão de produto do gate: aviso segue as configs de quem segue; service account exigiria domain-wide delegation — segue para o C115/Google→Teqo). DRY com o feed: janela/where/nomes de município/description compartilhados (`calendarFeed.ts` como dono da janela). Migration hand-written (o gerador re-emitia no-ops pré-existentes de `campaign_user.contact_id`). Testes: unit (mapping/derivação/guards com normalização/client com fetch fake/hook wiring via `vi.mock`), int (engine com stub in-memory: cria/converge/atualiza só drift/cancela deleta/delete limpa fantasma/falha→pausado→recupera/troca de calendarId), e2e (pill não-configurado + runbook, sem dependência externa). Débito de cobertura (estados disabled/paused/synced + ação) registrado como #666 (depends #635). Intenção [`c114-google-calendar-push-agenda-oficial.md`](plans/c114-google-calendar-push-agenda-oficial.md); impl [`c114-google-calendar-push-agenda-oficial-impl.md`](plans/c114-google-calendar-push-agenda-oficial-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **D10** (Issue #617) — auto-cura de leftovers de consent/supporter + fim do shared-write latente no lease de consent (pós-D9). **F1 (self-heal):** `purgeOrphanedConsentRenames` (3 nomes explícitos de renames temporários — `temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) roda sob lease **exclusivo**, em **autocommit** (delete na transação do lease seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada — observado na primeira implementação), nos 3 pontos que tocam a key canônica (`withMutableConsentFixture` antes do snapshot, setup do `withMissingInviteConsentFixture`, `ensureLeasedConsent`) — uma run abortada no meio de um rename de teste não envenena mais o run seguinte (UNIQUE `consent_key_idx` auto-sustentado). **F1b:** `purgeMunicipalityResidue` também deleta os **contatos** que supporters residue órfãos deixaram (reference-check em leadership/signature/subscription/supporter pós-delete — mesmo critério do `removeSupporterData`, reimplementado no helper). **F2 (refutação por medição + design final):** a hipótese "shared-writer = ABBA latente" caiu sob carga — exclusivo-durante-a-operation → fila de 17 waiters e cascata de 15s (6 workers); operation sem lease → FK `consent_id` (cleanup de fixture apagava linha canônica adotada); o ABBA não é construtível hoje (escritas disjuntas por teste; Onda0 em ordem fixa de 3 keys). As flakes reais do baseline (3/4 runs) eram: consumidores observando a ausência commitada da janela missing-consent (janela D9 era **shared**), down-test da Onda0 deletando as 4 keys **não-atomicamente**, e provision re-textando no meio de asserções de preview. **Fix:** janela do `withMissingInviteConsentFixture` → **exclusiva** (bloqueia consumidores shared durante a ausência; fila limitada ~200–500ms — a medição D9 do "15s tight" era o regime ONDA0-exclusivo, inexistente hoje); down-test da Onda0 **atômico** (delete+count+restore numa transação — FK insert espera o commit via speculative insertion); provision da Onda0 com leases **exclusivos** aninhados (ordem fixa CI→SR→VI); consumidores continuam shared. **Review /simplify (2 reviewers):** P1-1 — os renames vivos de `campaignInvite`/`campaignFixtures` migraram para chaves privadas (`consent-vacated-<id>`/`owned-consent-vacated-<id>`) — nenhum teste vivo produz os 3 nomes do purge; comentários "Onda0 shared" corrigidos; `withExclusiveTestDatabaseLease` colapsado (era cópia do privado); rollback no down-test quando a sessão falta; cleanup no finally nos testes F1 (auto-curáveis — um run falho do próprio teste não polui). Testes novos: contrato da janela exclusiva (waiter SHARED bloqueado), purge em ensure/setup, self-heal do canônico (repro do run abortado, 2 fixtures), F1b. **Contraprova:** 7 arquivos da key × 6 workers → **12/12 verdes** (baseline 1/4; designs intermediários: cascata 15s / FK); suíte int completa 83 arquivos/701 testes; unit 1938; e2e paridade com baseline (falhas pré-existentes de chat IA/webauthn/mic reproduzem no código original). Sem migration/access/Consent/`src/`. Intenção [`escala-dry-pos-d9.md`](plans/escala-dry-pos-d9.md); impl [`escala-dry-pos-d9-impl.md`](plans/escala-dry-pos-d9-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **C113** (Issue #631) — feed iCal da agenda **vivo**: eventos criados depois da criação do link aparecem no mesmo link. **Spike diagnóstico primeiro** (e2e novo com o aceite de dois `GET`s via fixture — roda em dev E no build de produção): o teste **passou no modo prod ANTES do fix**, provando que o Next 15 **não** congela route handlers GET (docs: "not cached by default" desde 15.0; build compila a rota como `route.js` dinâmica, sem `.body`) — **o freeze é o contrato de resposta**: `Cache-Control: public, max-age=3600` sem validadores permitia a caches compartilhados (CDN da Vercel, infra do Google, proxies) reter o snapshot do primeiro fetch. **Fix (mínimo):** `export const dynamic = 'force-dynamic'` na rota `ical/[secret]` (explícito, convenção do repo) + novo `buildICalFeedResponse` em `src/utilities/calendarFeed.ts` (helper puro): `Cache-Control: public, no-cache` (armazenar só com revalidação obrigatória — fecha também a janela de 1h servindo feed revogado), `ETag` forte (sha256 do corpo, hash sempre muda com conteúdo), `Last-Modified` informacional, `X-PUBLISHED-TTL: PT1H` (RFC 7986) e **304 só por `If-None-Match`** — nunca por `If-Modified-Since`, porque exclusão de atividade não move `Last-Modified` (linha some) e um 304 por data esconderia "evento fantasma". **Testes:** unit do helper (12/12 — headers, ETag estável/mutável, 304 sem corpo, `*`/lista/weak, ETag velho → 200, IMS nunca 304); int na rota real com payload (`route.dynamic === 'force-dynamic'` + A → GET → B → GET ambos → edição de horário → DTSTART novo → cancelamento → some → GET condicional → 304, 18/18); e2e do aceite completo no spec existente `campaignAgendaFeed` (dev 5/5, prod build 1/1). Aceite: edição/cancelamento refletem, revogação/escopo do criador fail-closed intactos, sem migration/schema/access/UI/Consent. Verificação pós-deploy pendente no elo CDN externo (link real de produção). Intenção [`c113-feed-ical-agenda-nao-atualiza.md`](plans/c113-feed-ical-agenda-nao-atualiza.md); impl [`c113-feed-ical-agenda-nao-atualiza-impl.md`](plans/c113-feed-ical-agenda-nao-atualiza-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-10):** **OPS36** (Issue #601) — journeys com **sessão compartilhada** nos 3 arquivos e2e pesados (`campaignHomeActions` 17, `campaignMunicipalities` 20, `campaignBottomNav` 9 testes): o overhead repetido por teste **amortizado de verdade**, não o que a intenção supunha. **Premissa da intenção corrigida por medição:** `getPayload` NÃO é pago por teste — Payload 3.82 memoiza a instância em `global._payload` keyed por `options.key` (medido: 364 ms na 1ª chamada do worker, 0 ms nas seguintes). O custo real era **o login de browser + a criação de usuário por teste** (bcrypt): login isolado medido em **3.6 s (quente) / 30.3 s (1º hit)** vs **112–134 ms** do `payload.login` de seed — ~32×. **Mecanismo:** `beforeAll` por arquivo cria UM coordinator + UM leader (instância `CampaignE2EOwnership` própria, cleanup em `afterAll`) e minta o token via `payload.login` (a MESMA via do login real — session row + JWT que o app valida); `beforeEach` injeta o cookie `campaign-token` (path `/campanha`, httpOnly, sameSite lax) no context fresh de cada teste — helpers `mintCampaignSession`/`seedCampaignSession`/`createCampaignOwnership` no fixture. **Divergências declaradas da intenção:** (1) **sem `mode: 'serial'`** — serial existia para reusar o contexto do browser; com seed por cookie o reuso é desnecessário e o serial skipa N+1..k em falha (perde diagnóstico — anti-goal "mega-teste" da própria intenção); (2) storageState rejeitado (file race entre workers + `test.use` em tempo de definição); (3) **login UI sai dos 46 testes** — caminho permanece coberto por `campaignAuth.e2e.spec.ts` + journey mista t9 (advisor→leader, mantém browser login); advisors **nunca** compartilhados (OPS29 exige portfólio vazio; B176 atribui portfólio). **Flake classe exposta e endurecida:** sem o warm-up incidental do login, clicks de estado client-side (drawer/sheet/overlay) passaram a correr contra a hidratação — a classe B13/B17 da casa; 7 interações ganharam o padrão `toPass` (B70 add-liderança, Mais drawer, B42 trend/C109, B193 scenario, FAB×3) e 7 lookups do strip de ações/omnibox ganharam `.first()` (duplicado transitório RSC do shell — classe `.nth(0)` já documentada), sem enfraquecer asserção. **Medição antes/depois:** baseline 29/49 passou em 10.0 m a load ~55 (20 falhas = classe de carga/restart de Postgres por outro worktree — OPS37; A/B no código original: B97 falha identicamente na mesma carga, e o conjunto falhante casa o do baseline); com o seed, runs limpas: homeActions 18/19 (2.6 m), interações endurecidas 8/10 (falhas residuais = render lento sob load, classe pré-existente comprovada). Sem migration/access/Consent/UI. Intenção [`e2e-journeys-seriais-arquivos-pesados.md`](plans/e2e-journeys-seriais-arquivos-pesados.md); impl [`e2e-journeys-seriais-arquivos-pesados-impl.md`](plans/e2e-journeys-seriais-arquivos-pesados-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-10):** **B197** (Issue #627) — coluna de e-mail oculta por padrão nas cinco listas de pessoas do `/campanha` (lideranças, dobradinhas, pessoas, apoiadores, assessores), religável pelo seletor de colunas B17 (cookie `campaign_columns`, default vale só para quem nunca tocou no seletor — escolha salva vence). Defaults novos em `DEFAULT_HIDDEN_COLUMN_IDS` (`email` nas 5) e `assessores` entrou em `CAMPAIGN_LIST_IDS` (primeira lista sem `CampaignTable` no mecanismo). **Pessoas (C100 reaberto):** o e-mail saiu da célula "Contato" (fica só telefone, `—` sem contato) e virou coluna própria oculta — cards mobile intocados (anti-goal). **Apoiadores:** coluna "E-mail" nova no `SupporterList` (dado já existia via `contact`; `SupporterListItemViewModel` ganhou `email`), nascendo oculta. **Assessores:** o encaixe — picker B17 na lista (trailing no omnibox, `advisorPickerColumns` espelhando os `<th>`) e `AdvisorsTable` (client) passou a receber `columnVisibility` com `<th>`/`<td>` condicionais; **exceção única: a linha-draft de criação mantém o input de e-mail sempre** (campo obrigatório no create). Sem migration/access/Consent/cache. Testes: unit do mecanismo (parser passa a guardar `assessores`; defaults + stored-wins) e e2e novo no `campaignColumnPicker` (default oculto, religar, persistência no reload, draft com input). Intenção [`ocultar-email-por-padrao-listas.md`](plans/ocultar-email-por-padrao-listas.md); impl [`ocultar-email-por-padrao-listas-impl.md`](plans/ocultar-email-por-padrao-listas-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-10):** **OPS35+** (Issue #616) — restaurado o pin de browser que o OPS35 documentou como perdido: a **visibilidade CSS real** das colunas de rede (Assessor / Liderança / Dobradinha) e dos sortables de `/campanha/territorios` a 2200px. Spec novo mínimo `tests/e2e/campaignTerritoriesColumns.e2e.spec.ts` (projeto `campaign`, corpo exato do teste 3 do spec deletado — `getByRole('columnheader')` visível por rung container-query `@min-[Nrem]/territory-list`, Cobertura `count 0`); divisão de trabalho com o HTTP preservada (presença + classes de rung continuam no `campaignTerritoriesHttp`). Manifesto `e2e-affected`: entrada territórios agora mapeia os 2 specs **e** o arquivo dono das rungs `TerritoryListColumns.tsx` (uma mudança de rung acorda o pin; o seletor faz união, specs de municípios continuam acordando). Prova de não-vazio por mutação (rung `advisor` → `@min-[200rem]` fez o spec falhar em "Assessor"; revertido). Sem migration/access/Consent/UI/CSS. Intenção [`restaurar-pin-visibilidade-colunas-territorios.md`](plans/restaurar-pin-visibilidade-colunas-territorios.md); impl [`restaurar-pin-visibilidade-colunas-territorios-impl.md`](plans/restaurar-pin-visibilidade-colunas-territorios-impl.md).
|
||||
**Recently resolved (2026-08-10):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`.
|
||||
|
||||
**Recently resolved (2026-08-10):** **B197** (Issue #627) — coluna de e-mail oculta por padrão nas cinco listas de pessoas do `/campanha` (lideranças, dobradinhas, pessoas, apoiadores, assessores), religável pelo seletor de colunas B17 (cookie `campaign_columns`, default vale só para quem nunca tocou no seletor — escolha salva vence). Defaults novos em `DEFAULT_HIDDEN_COLUMN_IDS` (`email` nas 5) e `assessores` entrou em `CAMPAIGN_LIST_IDS` (primeira lista sem `CampaignTable` no mecanismo). **Pessoas (C100 reaberto):** o e-mail saiu da célula "Contato" (fica só telefone, `—` sem contato) e virou coluna própria oculta — cards mobile intocados (anti-goal). **Apoiadores:** coluna "E-mail" nova no `SupporterList` (dado já existia via `contact`; `SupporterListItemViewModel` ganhou `email`), nascendo oculta. **Assessores:** o encaixe — picker B17 na lista (trailing no omnibox, `advisorPickerColumns` espelhando os `<th>`) e `AdvisorsTable` (client) passou a receber `columnVisibility` com `<th>`/`<td>` condicionais; **exceção única: a linha-draft de criação mantém o input de e-mail sempre** (campo obrigatório no create). Sem migration/access/Consent/cache. Testes: unit do mecanismo (parser passa a guardar `assessores`; defaults + stored-wins) e e2e novo no `campaignColumnPicker` (default oculto, religar, persistência no reload, draft com input). Intenção [`ocultar-email-por-padrao-listas.md`](plans/ocultar-email-por-padrao-listas.md); impl [`ocultar-email-por-padrao-listas-impl.md`](plans/ocultar-email-por-padrao-listas-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-10):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`.
|
||||
|
||||
|
|
|
|||
83
docs/plans/agenda-criacao-modal-central.md
Normal file
83
docs/plans/agenda-criacao-modal-central.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# C123 — Agenda: modal central como única superfície de criação/edição de atividade
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #667
|
||||
Priority: P1
|
||||
Model: composer-2.5
|
||||
Impeccable: C — fluxo de criação/edição inline redesenhado na agenda (popover → modal central; formulário completo some)
|
||||
Canvas UI: /home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c123-ui-draft.canvas.tsx
|
||||
Appetite: ~1–1,5 dia eng; um outcome verificável
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
A criação/edição de compromisso hoje pede **dois passos**: o popover de criação inline (estilo Google Calendar, C91) e depois o link "Mais detalhes" para preencher o resto no formulário completo — que vive em páginas próprias (`/campanha/atividades/nova` e `/campanha/atividades/[slug]/editar`). A mesa quer **uma** superfície só: o overlay da agenda (modal central no desktop, drawer no mobile) com **todas as configurações** — criação E edição ali mesmo, sem páginas de formulário. O popover atual também acumulou defeitos visíveis: o campo "Local" aparece **duplicado** na mesma tela (mesmo input renderizado duas vezes), e o seletor de dia/hora (calendário + seletores de hora/minuto empilhados) exige **rolar** dentro do popover para encontrar o seletor de horário — que fica escondido abaixo do calendário e ninguém descobre.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** staff na mesa (coordenador/assessor) no `/campanha/agenda`, criando ou ajustando um compromisso no slot certo do calendário — à pressa, entre uma ligação e outra.
|
||||
- **Job principal:** lançar o compromisso completo no calendário **sem sair da agenda e sem navegar** para outra página.
|
||||
- **Fluxo desejado (criar):** clica num slot → overlay abre já com o dia/hora do slot → preenche **todas** as configurações ali mesmo → Salvar → overlay fecha, evento aparece na agenda, sem reload.
|
||||
- **Fluxo desejado (editar):** clica num evento da agenda → overlay abre com os valores do compromisso → ajusta → Salvar → evento atualizado no calendário. O detalhe `/campanha/atividades/[slug]` continua existindo, alcançável por link no próprio overlay (e o botão "Editar" do detalhe abre o mesmo overlay).
|
||||
- **Anti-goals de produto:** não vira planilha nem redesenho do calendário em si; a página de detalhe `/campanha/atividades/[slug]` permanece como leitura/registro de resultado (não é formulário completo); recorrência não entra.
|
||||
|
||||
### Esboço de fluxo (C/D)
|
||||
|
||||
```text
|
||||
[criar: clique no slot | editar: clique no evento]
|
||||
→ [modal central (desktop) / drawer (mobile) com TODAS as configurações, dados pré-preenchidos]
|
||||
→ [Início: data + hora separados | Término: data + hora separados]
|
||||
→ [Salvar] → [overlay fecha, refetch da janela, calendário atualizado]
|
||||
```
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Clicar num slot abre um **modal central** (desktop), não mais um popover ancorado ao ponto de clique.
|
||||
- O overlay expõe **todas as configurações** do compromisso — todas as seções do formulário completo de hoje (básicas, data e horário, onde, pessoas e organizações, tarefas, demandas).
|
||||
- Clicar num evento abre o **overlay de edição** (mesmo modal/drawer, valores preenchidos) — o clique não navega mais para o detalhe; o detalhe fica acessível por link no overlay.
|
||||
- **As páginas `/campanha/atividades/nova` e `/campanha/atividades/[slug]/editar` saem sem redirecionamento** — o overlay vira a única superfície de criação/edição (o "Mais detalhes" morre junto); links antigos para essas rotas dão 404.
|
||||
- O campo **"Local" aparece uma única vez** no overlay (remover o bloco duplicado).
|
||||
- Início e Término têm **dois seletores separados**: um só de **data** e um só de **horário**, ambos visíveis **sem rolar** — nada de horário escondido abaixo do calendário.
|
||||
- **Mobile:** continua o bottom sheet (C103) — mesma superfície única de criação/edição — com **duas linhas empilhadas** (Início em cima, Término embaixo), cada uma com o seletor de **dia** e o seletor de **hora lado a lado na mesma linha**: `[dia de início][hora de início]` / `[dia de término][hora de término]`.
|
||||
- Salvar não navega; refetch da janela visível.
|
||||
- Guardrails existentes intactos: contrato civil `YYYY-MM-DDTHH:mm` de parse/validação, validação início ≤ término, modo "Todo o dia" (só data), prefill do slot, permissões staff, título imutável na edição.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — são campos de entrada de data/hora, sem decisão de apresentação.
|
||||
- **Decisões desbloqueadas:** nenhuma de dados; forma fica para o plano de implementação.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** `src/components/campaign/activity/ActivityInlineCreate.tsx` (vira o overlay único de criar/editar — popover vira modal; remover o bloco duplicado de Local — hoje dois `<Field>` idênticos; "Mais detalhes"/`buildActivityCreateHref` morrem), `src/components/campaign/activity/ActivityDateTimeField.tsx` (dividir o picker em seletor de data + seletor de hora — provavelmente dois controles lado a lado mantendo o contrato civil), `ActivityForm.tsx` (conteúdo do overlay — a forma em página morre), páginas `/campanha/atividades/nova` e `/[slug]/editar` (remover junto com `formActions`/prefill correspondentes; a página de detalhe segue e seu botão de editar passa a abrir o overlay), `src/utilities/activityUi.ts` (href de prefill), `src/app/(campaign)/campanha/actions/activity.ts` (ações de criar/editar se unificam no overlay).
|
||||
- **Precedente a olhar:** plans C91 (`agenda-criar-evento-inline.md`), C103 (`agenda-mobile-form-criacao-usavel.md`), C97 (seletor de dia/hora shadcn), C104 (todo o dia); testes `tests/unit/activityInlineCreate.unit.spec.tsx` e `tests/e2e/campaignActivity.e2e.spec.ts` usam "Mais detalhes" e as páginas de formulário — serão atualizados pelo executor.
|
||||
- **Risco de acoplamento:** leader lockdown não toca criação/edição staff (o toggle de tarefa no detalhe segue como está); o contrato de valor civil `YYYY-MM-DDTHH:mm` é compartilhado com a validação do submit e com o modo todo-o-dia — não quebrar; o detalhe perde o botão de editar em página — ele passa a abrir o overlay; e2e das rotas removidas morrem junto (sem redirecionamento — 404).
|
||||
|
||||
## Dependências
|
||||
|
||||
- **C124** (tarefas sem prazo): **dura** — o overlay inclui a seção de tarefas e deve nascer já com o modelo simplificado.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Redesenho da página de detalhe `/campanha/atividades/[slug]` (leitura/registro de resultado permanece como está).
|
||||
- Recorrência; mudanças no FullCalendar ou nos filtros/feed iCal; drag-and-drop de remarcação (intocado).
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **"Overlay vira mini-CRM".** Se alguém "só completar" o desejo de ter tudo no overlay, ele vira o formulário completo empilhado sem hierarquia. **Corte:** seções agrupadas como no formulário atual, overlay rolável internamente com rodapé fixo (Cancelar/Salvar).
|
||||
- **"Unificar com o sheet mobile".** O bottom sheet (C103) é o padrão mobile aceito; não redesenhar o mobile além dos seletores divididos. **Corte:** mobile = mesmo conteúdo, mesma anatomia de sheet.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- **Como a edição é acessada?** **Opções:** A) clique no evento abre o overlay de edição (estilo Google Calendar), e o botão "Editar" do detalhe abre o mesmo overlay; B) só o botão "Editar" do detalhe abre o overlay; C) clique no evento abre o overlay e o detalhe vira acessível por link no próprio overlay. **Recomendação:** A. _(decidido no gate — clique no evento abre o overlay de edição; o detalhe continua alcançável por link no próprio overlay; o botão "Editar" do detalhe abre o mesmo overlay)_
|
||||
- **Rotas `/campanha/atividades/nova` e `/[slug]/editar`:** redirecionar ou 404? **Recomendação:** redirecionar. _(decidido no gate — remover sem redirecionar, 404 para links antigos)_
|
||||
|
||||
## Referências
|
||||
|
||||
- GitHub Issue #667 (C123)
|
||||
- Plans C91 (`agenda-criar-evento-inline.md`), C103, C104, C105, C124 (dependência)
|
||||
- Canvas UI (gate): `/home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c123-ui-draft.canvas.tsx`
|
||||
- `src/components/campaign/activity/ActivityInlineCreate.tsx` · `ActivityDateTimeField.tsx` · `ActivityForm.tsx` · `ActivityTaskFields.tsx`
|
||||
- `src/app/(campaign)/campanha/atividades/nova/page.tsx` · `[slug]/editar/page.tsx` · `[slug]/page.tsx`
|
||||
- `tests/unit/activityInlineCreate.unit.spec.tsx` · `tests/e2e/campaignActivity.e2e.spec.ts`
|
||||
69
docs/plans/atividades-tarefas-sem-prazo.md
Normal file
69
docs/plans/atividades-tarefas-sem-prazo.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# C124 — Atividades: tarefa só com texto, responsável e concluída (sem data/hora)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #668
|
||||
Priority: P2
|
||||
Model: composer-2.5
|
||||
Impeccable: B — encaixe nas superfícies existentes (campos de tarefa do formulário + checklist do detalhe)
|
||||
Canvas UI: /home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c124-ui-draft.canvas.tsx
|
||||
Appetite: ~0,5–1 dia eng; um outcome verificável
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
O modelo de tarefas da atividade carrega um **prazo** (data/hora) que a mesa não usa. No dia a dia, tarefa é "o que fazer, com quem, e já foi feito?" — o prazo só polui: um input `datetime-local` apertado na linha da tarefa no formulário e um "Prazo: …" no checklist do detalhe, sem desbloquear nenhuma decisão (não há alerta nem filtro por prazo). O usuário pediu para tirar data/hora das tarefas: fica só **texto, responsável e concluída**.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** staff criando/editando atividade no formulário completo (`/nova` e `/editar`); liderança marcando tarefas como concluídas no detalhe da atividade (toggle, como hoje).
|
||||
- **Job principal:** cadastrar e acompanhar tarefas da atividade sem atrito — digitar o que fazer, apontar o responsável, marcar feito.
|
||||
- **Fluxo desejado:** no formulário, cada tarefa = título + responsável (pessoa) + checkbox "concluída"; no detalhe, o checklist mostra título + "Resp: …" e o toggle de concluída.
|
||||
- **Anti-goals de produto:** prazo não migra para outro campo da atividade nem vira notificação/relatório; nada de segunda ficha de pessoa.
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Criar/editar tarefa tem **exatamente** três controles: título (texto), responsável (pessoa) e "concluída" (checkbox) — o campo de data/hora sai da UI.
|
||||
- O checklist do detalhe mostra só título + responsável (sem "Prazo: …").
|
||||
- O **schema** da tarefa não expõe mais prazo: dados existentes com prazo são **descartados** pela migração (verificar no impl que nenhum consumidor usa o campo antes de dropar).
|
||||
- Guardrail de liderança intacto: liderança só pode marcar "concluída" (não altera título/responsável, não adiciona/remove tarefa) — a comparação de campos do guardrail perde a referência ao prazo.
|
||||
- `doneAt` (derivado, invisível, "concluída em") permanece — auditoria de quando foi marcada.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — simplificação de um modelo de entrada; o contador de tarefas concluídas (taskDoneCount) segue como está.
|
||||
- **Decisões desbloqueadas:** nenhuma de apresentação.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** `src/collections/Activity.ts` (campo `due` do array `tasks` sai do schema; `doneAt` permanece; guard de liderança em `beforeValidate` perde a comparação de `due`), `src/components/campaign/activity/ActivityTaskFields.tsx` (remove o input de prazo e o serializador), `ActivityTaskChecklist.tsx` (remove a linha de prazo), `src/utilities/activityFormData.ts`, `src/lib/schemas/activity.ts`, `src/utilities/activityViewModels.ts` (parsing/validação/view model), migration nova (drop da coluna de prazo do array de tarefas — história de migrations intacta), testes unit/e2e que exercitam tarefas.
|
||||
- **Precedente a olhar:** migration de adição do campo (`20260718_222832_add_action_plan`) e remodel de schema recente de atividade (`20260808_184113_remodel_activity_responsible`, `20260810_010844_add_activity_all_day`).
|
||||
- **Risco de acoplamento:** o guardrail de liderança (Activity.ts, `beforeValidate`) compara tarefa a tarefa — mudar o shape exige tocar essa comparação no mesmo item, senão liderança quebra ou perde o fail-closed.
|
||||
|
||||
## Dependências
|
||||
|
||||
- Nenhuma dura para si. **Ela é dependida pela C123** (modal central da agenda): o overlay único de criação/edição inclui a seção de tarefas e nasce já com o modelo simplificado — a C123 roda depois desta.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Prazo em demandas/atividades (não criar campo equivalente em outro lugar).
|
||||
- Notificações/alertas por prazo de tarefa.
|
||||
- Editar `doneAt` ou o contador `taskDoneCount`.
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **"Prazo volta na listagem".** Alguém pode querer mostrar o prazo na lista de atividades. **Corte:** o campo sai do modelo; se a mesa pedir prazo de novo, é um item novo com intenção própria.
|
||||
- **"Migrar prazo para outro lugar".** **Corte:** nada consome o prazo hoje (a verificação no impl fecha isso); migração de conteúdo é trabalho morto.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- **`doneAt` ("concluída em") permanece no modelo?** **Opções:** A) sim, como campo derivado invisível (auditoria sem custo de UI); B) remove junto. **Recomendação:** A — custo zero, mantém rastreabilidade de quando cada tarefa foi marcada. _(assumido — validar com produto)_
|
||||
- **Prazo com valor preenchido em produção: descartar ou preservar?** **Opções:** A) descartar (nenhum consumidor; dados de campanha internos); B) preservar em coluna morta. **Recomendação:** A — limpeza real, sem coluna fantasma. _(decidido no gate — descartar e remover)_
|
||||
|
||||
## Referências
|
||||
|
||||
- GitHub Issue #668 (C124)
|
||||
- Plans C91/C103 (formulário de criação — o overlay da C123 inclui tarefas), C14 (modelo de atividades)
|
||||
- Canvas UI (gate): `/home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c124-ui-draft.canvas.tsx`
|
||||
- `src/collections/Activity.ts` · `src/components/campaign/activity/ActivityTaskFields.tsx` · `ActivityTaskChecklist.tsx`
|
||||
- `src/utilities/activityFormData.ts` · `src/lib/schemas/activity.ts` · `src/utilities/activityViewModels.ts`
|
||||
135
docs/plans/c114-google-calendar-push-agenda-oficial-impl.md
Normal file
135
docs/plans/c114-google-calendar-push-agenda-oficial-impl.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Impl: C114 — Google Calendar: agenda da campanha espelhada com notificação (push Teqo → Google)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #635
|
||||
Intenção: docs/plans/c114-google-calendar-push-agenda-oficial.md
|
||||
Appetite restante: ~2 dias eng (herdado; corte explícito: sem participantes no v1 — ver decisão aberta P1)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** com um comando em `/campanha/agenda`, a campanha ativa um calendário Google compartilhado ("Agenda da Campanha"); criar/remarcar/cancelar atividades em Teqo reflete no Google em minutos (push via API oficial); quem segue recebe aviso conforme as próprias configurações; Teqo segue SoT e funciona sem Google (estado do sync visível, re-tenta sem ação manual); leader lockdown intocado.
|
||||
- **O que NÃO negociar:** direção única Teqo → Google (C115 é o bidirecional); modelo = calendário compartilhado (nunca OAuth por pessoa); Teqo SoT — credencial inválida/API fora não pode quebrar o Teqo; espelho cheio do escopo do staff (recortes continuam no iCal); sem PII desnecessária no evento; líder não vê nem ativa.
|
||||
- **O que reavaliar:** a "hipótese" aponta `src/utilities/calendarFeed.ts` como precedente — confirmado (janela de eventos, formatação de summary/description, nomes de município reusáveis). O que a intenção NÃO diz e a engenharia precisa decidir: **como disparar o push** (não há cron no Vercel — verificação: `vercel.json` não tem `crons`; Hobby limita cron), **onde viver o estado do sync** e **como mapear atividade→evento sem estado duplicado**.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[afterChange/afterDelete hook em activity<br/>campos relevantes mudaram?] -->|sim| S[runCampaignCalendarSync<br/>utilities/googleCalendarSync.ts]
|
||||
P[agenda page view<br/>estado pausado → auto-retry] --> S
|
||||
B["botão 'Sincronizar agora'"<br/>actions/googleCalendarSync.ts] --> S
|
||||
S --> L[load activities janela 90d+365d<br/>espelho cheio do staff]
|
||||
L --> G[Google Calendar API v3<br/>client com service account JWT<br/>jose + fetch]
|
||||
G --> D[diff por id determinístico teqo_activity_<id><br/>cria/atualiza/remove]
|
||||
D --> W[grava collection googleCalendarSync<br/>lastSyncedAt/lastSuccessAt/lastError*]
|
||||
W --> U[UI: pill sincronizado/pausado/não configurado]
|
||||
```
|
||||
|
||||
**Opções consideradas:** ver decisões D1–D6 abaixo.
|
||||
**Recomendação:** push **orientado a evento** (hook no `activity`) com **reconciliação completa por passada** e **id determinístico de evento** — sem cron, sem fila, sem collection join e sem campo novo em `activity`. Configuração e estado do sync numa **collection `googleCalendarSync`** (credencial em env; **auto-resync quando a configuração muda**). UI no padrão do link de import (C94): pill no header desktop + quick action mobile + dialog.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Transporte para a API do Google.**
|
||||
Opções: A) `googleapis` + `google-auth-library` (SDK oficial) | B) `jose` (assina JWT RS256) + `fetch` nativo → Calendar API v3 REST | C) JWT com `node:crypto` na mão.
|
||||
Recomendação: **B** — só precisamos de token (JWT service account) + 4 endpoints REST (`events.list/insert/update/delete`); SDK oficial é ~40MB de máquina para 1 chamada; C reimplementa crypto de JWT (risco de segurança). `jose` é dependência pequena e padrão (Payload usa a mesma família).
|
||||
Rejeitadas: A (pesado demais para a superfície real); C (implementar JWT à mão viola "nunca implemente crypto").
|
||||
|
||||
**D2 — Mapeamento atividade → evento Google.**
|
||||
Opções: A) collection join `activityCalendarEvent` (activityId→googleEventId) | B) campos `googleEventId`/hash no `activity` | C) **id determinístico** `teqo_activity_<id>` (charset do Google permite alfanumérico + `_`).
|
||||
Recomendação: **C** — upsert idempotente sem estado persistido por evento: `events.get`/`list` → 404 cria, 200 compara conteúdo e atualiza só se mudou, cancelado/deletado remove. Elimina collection nova, migration de tabela e o loop de re-entrada do hook (escrever campo no doc re-dispara afterChange). "Edit the owner": o dono do evento no Google É a atividade; o id determinístico é só a identidade externa.
|
||||
Rejeitadas: A (estado duplicado desnecessário; mais uma collection/access para administrar); B (re-entrada do hook — toda escrita de `googleEventId` re-dispara afterChange; precisa guard frágil; e leader update guard já compara snapshots de campos de staff).
|
||||
|
||||
**D3 — Motor de sync.**
|
||||
Opções: A) upsert incremental por evento (só o evento tocado) no hook | B) **reconciliação completa por passada**: lista janela no Google + diff contra atividades do Teqo + cria/atualiza/remove | C) dois caminhos (incremental no hook + reconciliação periódica).
|
||||
Recomendação: **B** — um único caminho de código (uma superfície de teste), auto-curativo (remove fantasmas de eventos deletados no Teqo, reconstrói evento apagado à mão no Google — Teqo é SoT), e a comparação de conteúdo (summary/description/start/end) faz passadas repetidas convergirem sem tocar no Google quando nada mudou. Custo: 1 chamada `list` por passada (~100ms) — irrelevante na escala (centenas de eventos).
|
||||
Rejeitadas: A (não limpa fantasmas; edição manual no Google não é corrigida; dois estados de falha distintos); C (dois caminhos para manter/testar sem ganho real).
|
||||
|
||||
**D4 — Trigger do push ("em minutos" sem cron).**
|
||||
Opções: A) cron Vercel (`vercel.json` crons) | B) **hook afterChange/afterDelete em `activity` (campos relevantes) + auto-retry na view da agenda quando pausado + botão "Sincronizar agora"** | C) Google push channels (webhook reverso).
|
||||
Recomendação: **B** — o hook dá latência de segundos (o aceite pede "minutos"); a view da agenda + o botão cobrem o "re-tenta sem ação manual" (a própria equipe abre a agenda quando opera); escrever em Teqo continua 100% local. Sem cron (Hobby limita a 2/dia), sem fila, sem endpoint público novo.
|
||||
Rejeitadas: A (limite de Hobby; cadência de horas não entrega o aceite); C (endpoint público + desafios de renovação de channel — infra que este item não precisa).
|
||||
Nota: corridas convergem — a comparação de conteúdo torna uma segunda passada simultânea um no-op; sem advisory lock distribuído para estado cosmético (o `pg_advisory_xact_lock` existente é transacional e soltaria no commit, inútil para chamadas HTTP pós-commit).
|
||||
|
||||
**D5 — Configuração e estado do sync (collection, não global).**
|
||||
Opções: A) **collection `googleCalendarSync`** (uma linha de fato; precedente de infra `supporterImportBatch`/`calendarFeed`) | B) global `googleCalendarSync` (precedente `campaignGoals`) | C) tudo em env.
|
||||
Recomendação: **A** — pedido do gate e casa com o precedente de recurso de sync com credencial revogável (`calendarFeed`); o `afterChange` da própria collection dispara o **auto-resync** quando a configuração muda (D7); access por collection explícito (read staff, update staff campo a campo: `calendarId` admin-only, `disabledAt` staff, estado system-write/staff-read). Singleton de fato garantido pelo fluxo (engine faz find/create na primeira ativação) — sem unicidade DB (a linha é única por construção da ação de ativação).
|
||||
Rejeitadas: B (auto-resync também funcionaria num global, mas o precedente de recurso de sync é collection e o access fica mais legível; `campaignGoals` é configuração pura sem estado rastreado); C (calendarId em env exigiria redeploy para rotacionar; sem rastreabilidade de `lastError` na UI).
|
||||
|
||||
**D6 — Credencial.**
|
||||
Opções: A) **chave privada da service account em env** (`GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`, base64 do JSON do GCP) + `calendarId` na collection (admin-set) | B) JSON inteiro da service account na collection (campo admin) | C) OAuth por pessoa.
|
||||
Recomendação: **A** — segredo em env (precedente `REVALIDATE_SECRET`/Blob), identidade operacional (calendarId) na collection: staff desativa/reativa sem tocar em env; admin ajusta calendarId sem redeploy; key nunca vaza em log/DB. O runbook de ops (criar calendário na conta da campanha, compartilhar "público no link", dar permissão de escrita à service account, pôr calendarId) é o caminho da questão aberta de produto Q1 (dono da conta).
|
||||
Rejeitadas: B (segredo em DB — precedente do repo é env; revogação via env é mais segura); C (anti-goal explícito da intenção).
|
||||
|
||||
**D7 — Auto-resync ao mudar a configuração.**
|
||||
Opções: A) **afterChange na collection `googleCalendarSync`**: quando `calendarId` (ou `disabledAt`) mudou de fato (comparação `originalDoc` vs `doc`), dispara `runCampaignCalendarSync` — reconciliação completa contra o novo calendário | B) só manual ("Sincronizar agora" / view da agenda) | C) migração completa entre calendários (deletar no antigo + criar no novo).
|
||||
Recomendação: **A** — o id determinístico + diff por conteúdo tornam a passada idempotente: trocar o `calendarId` recria o espelho inteiro no novo calendário na hora, sem ação manual. Guard de re-entrada: as escritas de estado (`lastSyncedAt`/`lastSuccessAt`/`lastError*`) não alteram `calendarId`/`disabledAt`, então não re-disparam o afterChange. O calendário antigo fica com o último espelho congelado (sem limpeza retroativa — documentado no runbook).
|
||||
Rejeitadas: B (o aceite "re-tenta sem ação manual" se estende à reconfiguração); C (varrer e deletar N eventos de um calendário que a campanha abandonou é valor marginal — o calendarId troca só em migração de conta).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/googleCalendarEventMapping.ts`** (novo, puro, unit-testável): `googleEventIdForActivity` (`teqo_activity_<id>`), `buildGoogleEventPayload(activity, municipalityName)` (summary `[Município] Título`; description sem PII — Município/Local/Tags/Deputado presente, mesmo espírito do iCal; `start`/`end` timed com offset America/Bahia −03:00 ou `date` all-day via `allDayCivilDateOf`/`allDayExclusiveEndDate` de `src/lib/activityAllDay.ts`; **end ausente → start + 1h** — pendente de verificação no doc oficial da API), `googleEventContentEquals(remote, payload)`.
|
||||
- **`src/utilities/googleCalendarClient.ts`** (novo, `server-only`): JWT service account com `jose` (`SignJWT` RS256, scope `https://www.googleapis.com/auth/calendar`, aud `https://oauth2.googleapis.com/token`), POST token (cache em módulo, invalida em 401 e re-tenta uma vez), `listEvents` (paginação), `insertEvent`, `updateEvent`, `deleteEvent`; erros tipados (`GoogleCalendarApiError`) sem credencial em log.
|
||||
- **`src/utilities/googleCalendarSync.ts`** (novo, `server-only`): `readGoogleSyncConfig`, `deriveGoogleSyncStatus` (matriz pura `not-configured | disabled | synced | paused` — key presente? calendarId? disabledAt? lastErrorAt > lastSuccessAt?), `runCampaignCalendarSync(payload, client)` — reconciliação completa (carrega atividades da janela 90d passado/365d futuro com `overrideAccess` — espelho cheio do staff, sem intersecção de escopo de advisor: é a agenda oficial da campanha; reusa o padrão de `loadFeedActivities`/`loadMunicipalityNames` de `calendarFeed.ts`), grava `lastSyncedAt` + `lastSuccessAt` ou `lastErrorAt`/`lastError`.
|
||||
- **`src/collections/GoogleCalendarSync.ts`** (novo): slug `googleCalendarSync`, group `Campanha` (precedente de infra `supporterImportBatch`); campos `calendarId` (admin-write), `disabledAt` (staff-settable via action), `lastSyncedAt`/`lastSuccessAt`/`lastErrorAt`/`lastError` (system-write, staff-read para a pill); access: read staff, update staff campo a campo (field access); **afterChange com auto-resync (D7)**: `calendarId`/`disabledAt` mudaram de fato → dispara `runCampaignCalendarSync`; escritas de estado não re-disparam (guard por comparação de campo).
|
||||
- **`src/collections/Activity.ts`** (editar): afterChange ganha `syncActivityToGoogleCalendar` (dynamic import — mesmo padrão do `notifyActivityNeedsAttention`; guard: só dispara quando campos relevantes mudaram — `title, status, startAt, endAt, allDay, locality, municipality, tags, deputyPresent, description` — compara `originalDoc` vs `doc`, tarefas não disparam; try/catch **nunca lança** — Teqo nunca depende do Google) e afterDelete idem (reconciliação remove o fantasma).
|
||||
- **Server actions `src/app/(campaign)/campanha/actions/googleCalendarSync.ts`** (novo): `getGoogleCalendarSyncState` (status + link público quando configurado), `runGoogleCalendarSyncNow` (retry manual/automático, retorna estado fresco), `setGoogleCalendarSyncDisabled` (toggle staff); padrão `getCampaignActionContext` + `reloadStaffActor` de `actions/calendarFeed.ts`; líder nunca chega (layout `(app)` + gate staff da página).
|
||||
- **`src/lib/googleCalendarLink.ts`** (novo, puro): link público de adição `https://calendar.google.com/calendar/r?cid=<encodeURIComponent(calendarId)>` + URL iCal pública `webcal://calendar.google.com/calendar/ical/<id>/public/basic.ics` (Apple/Outlook) — formato a verificar no doc oficial no impl.
|
||||
- **UI** (Impeccable B — shape→craft→critique→polish; reusa shell `Dialog`/`Drawer`/`useIsMobile`/`SetCampaignHeaderAction`/`useCampaignQuickActionContext` de C94): `AgendaGoogleSyncChrome.tsx` (pill no header desktop junto do link de import + quick action mobile; recebe estado inicial do servidor; auto-retry quando `paused` e atualiza via action + `revalidatePath`) e `GoogleCalendarSyncDialog.tsx` (estados do canvas: não configurado → instruções de ops; sincronizado → link + copiar + instruções + "Sincronizar agora" + "Desativar"; pausado → último erro + re-tentar; desativado → reativar). Copy pt-BR conforme canvas.
|
||||
- **`src/app/(campaign)/campanha/(app)/agenda/page.tsx`** (editar): carrega estado do sync (staff) e monta `<AgendaGoogleSyncChrome>` ao lado do `AgendaFeedChrome` (ordem do cluster C95: [Semana][Link de import][Google][Notificações][IA] — verificar colisão de `id` dos `SetCampaignHeaderAction`).
|
||||
- **`.env.example`** (editar): `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` documentada.
|
||||
- **Migration:** `migrate:create add_google_calendar_sync` (tabela da collection nova — D5; sem collection join — D2).
|
||||
- **Access / Consent:** sem chave de Consent (dados internos da campanha, precedente `calendarFeed`); sem PII nova — emails de campanha não vão para o Google no v1 (corte de participantes). Fail-closed: sem env key o sync não dispara (estado `not-configured`), o hook é no-op.
|
||||
- **Docs:** entrada única em `docs/CHANGELOG-AGENTS.md`; runbook de ops (criação do calendário na conta da campanha + compartilhamento + permissão da service account + envs) — na AGENTS.md ou no plano? Decisão barata: na entrada do CHANGELOG + nota no impl.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
N/A — não há apresentação de dados no Teqo (o evento é do Google Calendar; decisão de apresentação do Google). Único dado novo visível: a pill de estado do sync (derivada, matriz unit-testada).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server** (dia 1): `pnpm add jose`; collection `googleCalendarSync` + migration + `generate:types` + `generate:importmap`; `googleCalendarEventMapping` + unit tests; `googleCalendarClient`; `googleCalendarSync` + int tests com stub in-memory do cliente (incl. **auto-resync ao trocar `calendarId`** e guard de re-entrada do afterChange); hook no `activity` + guard; actions; `migrate` local + `migrate:status`.
|
||||
2. **UI** (dia 1–2): chrome + dialog + wiring na página; e2e do estado `not-configured` (sem env no teste → determinístico, sem dependência externa) + diálogo; Impeccable shape→craft.
|
||||
3. **Gates** (dia 2): `pnpm gate:fast`; `pnpm format:check`; `pnpm exec knip`; `pnpm check:cycles`; `pnpm test` (unit+int); `pnpm test:e2e`; `pnpm build`; Aikido nos arquivos novos; `pnpm push` com CI green.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Watch channels do Google (push reverso para nós)** — exige webhook público e renovação; o hook do Teqo já dá latência de segundos.
|
||||
- **Fila/worker/cron próprio** — Hobby; hook + lazy retry cobre o aceite.
|
||||
- **Um calendário por filtro/recorte** — iCal cobre (intenção).
|
||||
- **Participantes/attendees (aviso garantido ao núcleo)** — corte v1: quem segue recebe conforme as configs do próprio Google (é o que o pedido assume); reabre como follow-up se a mesa quiser (decisão aberta P1).
|
||||
- **Sincronizar histórico completo** — janela 90d+365d (precedente do feed).
|
||||
- **Migrar eventos antigos para o Google** — fora de escopo.
|
||||
- **OAuth por pessoa** — anti-goal da intenção.
|
||||
- **Lock distribuído para a passada** — corridas convergem por comparação de conteúdo; D3.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Semântica da API Google desconhecida** (charset do id de evento, `end` obrigatório para timed, formato do link `cid`): verificar no doc oficial durante o impl (source-driven-development); os int tests com stub travam NOSSO contrato de mapeamento, não o do Google.
|
||||
- **E2E com dependência externa (Google real)** — flakiness em CI: e2e só cobre `not-configured` (env ausente); motor testado em int com stub.
|
||||
- **Latência no hook** — guard de campos relevantes (tarefa não sincroniza), try/catch total, escrita nunca bloqueada.
|
||||
- **Segredo vazando em log** — key só em env; client tipa erros sem corpo da credencial; Aikido nos arquivos novos.
|
||||
- **Env ausente em produção (C98 lesson)** — a pill mostra `não configurado` com runbook em vez de quebrar; `NEXT_PUBLIC_SITE_URL` já é conhecida; sem env nova obrigatória para o app funcionar (sync desligado é estado válido).
|
||||
- **Re-escrita de edição manual feita no Google** — direção intencional (Teqo SoT); documentar no runbook.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: push em minutos via hook; estado visível; re-tenta sem ação manual (view + botão + próximas escritas); Teqo funciona sem Google; leader lockdown intocado (gate staff da página + sem superfície para líder).
|
||||
- [ ] Reconfiguração auto-sincronizada: mudar `calendarId`/`disabledAt` dispara reconciliação contra o novo calendário sem ação manual (D7), com guard de re-entrada do afterChange.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: identificadores em inglês, copy pt-BR; server actions com `getCampaignActionContext`; sem PII nova; segredo em env; sem migration retroativa.
|
||||
- [ ] Testes de domínio previstos: unit (mapping, derivação de status, link), int (engine com stub: cria/atualiza só quando muda/cancela remove/fantasma limpo/falha→pausado→recupera/auto-resync de config; guard do hook), e2e (pill não-configurado + diálogo).
|
||||
- [ ] Self-score decision-quality: 5/5 (decisões caras com rejeitadas; cabe no appetite; rabbit holes nomeados; depth check reusa `calendarFeed.ts`/`activityAllDay.ts`/shells C94; intenção preservada).
|
||||
|
||||
## Decisões em aberto (produto — para o gate humano)
|
||||
|
||||
- **P1 — Participantes (núcleo) no v1?** Recomendação: **não** (quem segue recebe conforme as próprias configs; é o que o pedido assume; evita e-mail PII da campanha no Google e spam de convite a cada remarcação). Reabre como follow-up.
|
||||
- **Q1 — Dono da conta do calendário:** assumido A (conta Google da campanha; service account só escreve). Requer do humano: confirmação de que existe a conta e o `calendarId` (ops).
|
||||
- **Q2 — Visibilidade:** assumido A (público no link — a equipe é mista Google+Apple; conteúdo já é a agenda oficial sem PII).
|
||||
|
||||
## Referências
|
||||
|
||||
- Intenção: `docs/plans/c114-google-calendar-push-agenda-oficial.md` (gate de produto)
|
||||
- Canvas: `plan-c114-ui-draft.canvas.tsx` (4 estados da jornada + pill)
|
||||
- Precedentes: `src/utilities/calendarFeed.ts`, `src/collections/CalendarFeed.ts`, `src/app/(campaign)/campanha/actions/calendarFeed.ts`, `src/components/campaign/activity/CalendarFeedDialog.tsx` + `AgendaFeedChrome.tsx` (C94), `src/collections/Activity.ts` (hooks), `src/collections/SupporterImportBatch.ts` (infra admin-hidden), `src/lib/activityAllDay.ts`, `src/utilities/postgresTransactionLocks.ts` (lido; decidido não usar)
|
||||
- Testes existentes: `tests/e2e/campaignAgendaFeed.e2e.spec.ts`, `tests/unit/calendarFeed.unit.spec.ts`, `tests/int/calendarFeed.int.spec.ts`
|
||||
57
docs/plans/c122-c114-followup-testes-estados-espelho.md
Normal file
57
docs/plans/c122-c114-followup-testes-estados-espelho.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# C122 — C114 follow-up: cobertura de testes dos estados do espelho Google
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #<pendente>
|
||||
Priority: P3
|
||||
Model: composer-2.5
|
||||
Impeccable: B — superfícies existentes (pill + diálogo da agenda); sem fluxo novo
|
||||
Appetite: ~0,5 dia eng — fill-in de cobertura, sem mudança de comportamento
|
||||
|
||||
## Intenção
|
||||
|
||||
O C114 entregou o espelho Teqo→Google com três estados derivados na UI
|
||||
(sincronizado / pausado / não configurado / desativado), mas a cobertura de
|
||||
testes parou no estado `not-configured` (e2e) e no engine (int com stub). O
|
||||
que o /simplify deixou de fora, em um lote:
|
||||
|
||||
- **S14 — teste de componente dos chromes:** o auto-retry do
|
||||
`AgendaGoogleSyncChrome` (uma tentativa por mount quando `paused`) e o
|
||||
rótulo da pill por status não têm nenhum teste; o repo tem precedente
|
||||
jsdom+RTL (`wizardExpectedVotesStep.unit.spec.tsx`).
|
||||
- **S17 — e2e dos outros estados:** `disabled` (Reativar), `paused` (erro +
|
||||
link "Abrir Google Calendar") e `synced` (link block + copiar + instruções)
|
||||
nunca exercitados; o FAB mobile ("Agenda da Campanha") também não tem pin.
|
||||
- **S18 — int da action de desativar:** `setGoogleCalendarSyncDisabled`
|
||||
(grava `disabledAt`, re-enable limpa e o hook da config re-sincroniza — D7)
|
||||
sem teste.
|
||||
|
||||
## Aceite
|
||||
|
||||
- Unit (RTL): pill renderiza o rótulo/estado correto por status; auto-retry
|
||||
dispara UMA vez quando `paused` e nenhuma quando `synced`/`not-configured`.
|
||||
- E2E: dialog nos estados disabled/paused/synced (seed do doc
|
||||
`googleCalendarSync` + env key fake — estados determinísticos, sem rede) e
|
||||
FAB mobile abrindo o sheet.
|
||||
- Int: disable → `disabledAt` gravado; re-enable → limpo e o
|
||||
`googleCalendarSyncConfigHook` dispara a reconciliação (spy/observável via
|
||||
stub) — sem tocar na rede.
|
||||
|
||||
## Direção no codebase
|
||||
|
||||
- `src/components/campaign/activity/AgendaGoogleSyncChrome.tsx` +
|
||||
`GoogleCalendarSyncDialog.tsx` (alvos do teste de componente).
|
||||
- `src/app/(campaign)/campanha/actions/googleCalendarSync.ts` (alvo do int).
|
||||
- `tests/unit/`, `tests/int/`, `tests/e2e/` — precedentes:
|
||||
`wizardExpectedVotesStep.unit.spec.tsx`, `googleCalendarSync.int.spec.ts`,
|
||||
`campaignAgendaGoogleSync.e2e.spec.ts`.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Serialização de passes concorrentes (advisory lock no estado) — defer:
|
||||
self-healing (o próximo pass recobre), gatilho: reclamação de pill preso em
|
||||
`paused` ou o C115.
|
||||
- Teste dos estados no modo mobile do pill na linha de filtros (decisão C114:
|
||||
FAB é a superfície mobile).
|
||||
- Refactor do chrome de instruções addbyurl (2 call sites) — defer, gatilho:
|
||||
3ª superfície de calendário (C115).
|
||||
73
docs/plans/d12-getbylabel-atividade-relacionada-impl.md
Normal file
73
docs/plans/d12-getbylabel-atividade-relacionada-impl.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Impl: E2E — flake do `getByLabel('Atividade relacionada')` (strict mode) sob carga
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #641 (D12)
|
||||
Intenção: docs/plans/e2e-combobox-label-strict-mode-flake.md
|
||||
Appetite restante: herdado (P3, kind: defect, sem nova superfície)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `campaignRegisterDemand` verde em run completo e isolado; sem mudança de DOM/labels no estado fechado que afete outros specs.
|
||||
- **O que NÃO negociar:** copy visível e comportamento da combobox intactos; nenhuma regressão de a11y do dialog.
|
||||
- **O que reavaliar:** a hipótese "o conteúdo do CommandDialog fica sempre montado" da intenção **não se sustenta** — ver caracterização abaixo.
|
||||
|
||||
## Caracterização (completada na sessão, browser real + código)
|
||||
|
||||
1. **Estado estável (fechado): exatamente 1 match.** O trigger (`aria-label="Atividade relacionada: Nenhuma atividade"`) é o único nó. O `CommandDialog` usa `DialogPortal` **sem** `forceMount` (src/components/ui/dialog.tsx): o conteúdo (incl. `CommandInput` com `aria-label={label}`) só monta com `open` ou na animação de saída (~200ms, `duration-200`).
|
||||
2. **Dialog aberto: 2 matches** — trigger + `CommandInput` (`role="combobox"`, `aria-label="Atividade relacionada"`). Medido ao vivo.
|
||||
3. **O sr-only `DialogHeader` (título + descrição) fica montado permanentemente** — é filho direto de `Dialog.Root` (render inline, fora da porta). O snapshot pós-falha do D11 ("título + descrição no DOM com o dialog fechado") é o **estado normal da página**, não evidência de dialog montado.
|
||||
4. O teste que flakava (linha 38) **nunca abre o dialog** — logo o segundo match não pode ser o `CommandInput` por interação do próprio teste.
|
||||
5. A intenção registra o segundo match como **"ambos botões"** com o mesmo `aria-label` do trigger → duplicação **transitória de instância** (2 comboboxes idênticos no DOM por um instante), a classe já tratada em cd469857 (2026-08-09): "transient RSC-pending duplication … strict-mode flake on loaded machines" → fix estabelecido no repo: `.first()` nos asserts.
|
||||
6. Navegação dura (`page.goto`) medida ao vivo: 0→1 sem janela de 2 matches na máquina local; a duplicação é dependente de carga (máquina do pool, compiles de 6–27s).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Flake strict-mode 2 matches] --> B{2º match é...}
|
||||
B -->|"CommandInput do dialog"| C[Descartado: teste nunca abre o dialog]
|
||||
B -->|"2º trigger idêntico"| D[Duplicação transitória de instância<br/>classe cd469857]
|
||||
D --> E["Fix: .first() no assert<br/>precedente do repo"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (spec `.first()`) | B (spec `getByRole('button', …)`) | C (componente: remover label do `CommandInput` / `aria-hidden` no fechado)
|
||||
|
||||
**Recomendação:** A — `.first()` nos asserts de `getByLabel('Atividade relacionada')` dos dois specs que usam o label (`campaignRegisterDemand.e2e.spec.ts:38` e `campaignActivity.e2e.spec.ts:52`), com comentário citando o precedente cd469857.
|
||||
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B** — `getByRole('button', { name: /Atividade relacionada:/ })` resolve a duplicação trigger+input (input é `role=combobox`), mas **não** resolve o caso reportado ("ambos botões" = duplicação de instância): 2 triggers de mesmo `aria-label` → mesma strict violation.
|
||||
- **C** — o portal **não está montado no fechado** (medido): remover `aria-label` do `CommandInput` não muda o estado estável e degradaria a a11y do input de busca (o dialog precisa de label próprio); `aria-hidden` no fechado não tem alvo (nada montado). Não atinge a duplicação transitória, que é artefato de render do framework (Next/React), não do componente.
|
||||
- (descartada na análise) "esperar o dialog abrir/fechar": o teste não interage com o dialog.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`tests/e2e/campaignRegisterDemand.e2e.spec.ts:38`**: `page.getByLabel(CAMPAIGN_DEMAND_ACTIVITY_LABEL)` → `.first()`. Semântica preservada: o teste quer provar que o campo está presente/visível; qualquer uma das instâncias transitórias é o mesmo trigger.
|
||||
- **`tests/e2e/campaignActivity.e2e.spec.ts:52`**: idem (mesmo label, mesma classe de flake — página da atividade também monta `AsyncSearchCombobox` via `DemandFields`).
|
||||
- **Migration:** sem migration. **Access/Consent:** não toca. **UI/componente:** nenhuma mudança (aceite "sem mudança de DOM/labels").
|
||||
- Comentário nos dois asserts no estilo cd469857 (classe + motivo, não reescrever o histórico).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Baseline (reprodução)** — run completo do e2e em dev (em andamento, máquina com load real 11–22): registrar se o flake reproduz localmente. O D11 já provou pré-existência em main via stash (3/4 runs falharam no pool); a reprodução local é bônus, não gate.
|
||||
2. **Fix** — `.first()` nos 2 asserts.
|
||||
3. **Gates** — `campaignRegisterDemand` isolado (dev) + `campaignActivity` isolado; run completo do e2e verde; `pnpm gate:fast` (tsc, lint, format, unit+int) e `pnpm check:cycles`; knip.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não investigar o mecanismo exato da duplicação transitória do Next/React (bug upstream, dependente de carga; o repo já decidiu a resposta pragmática em cd469857).
|
||||
- Não mexer no `AsyncSearchCombobox`/`CommandDialog` (a11y do dialog é correta; portal não monta no fechado).
|
||||
- Não trocar `getByLabel` por `getByRole` nos outros ~100 usos do arquivo (fora do escopo do label em questão).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Flake não reproduz na máquina local** (condições do pool diferentes) → mitigação: validação = mecanismo caracterizado ao vivo (estado estável 1 match; 2 matches só em instância duplicada/open) + fix idêntico ao precedente aprovado cd469857 + runs completos verdes pós-fix (1+).
|
||||
- **`.first()` mascarar regressão real** (ex.: 2 instâncias ESTÁVEIS) → mitigação: assert continua verificando visibilidade do campo; se um dia houver duplicação estável, os outros asserts do spec (contagem 0/1 em linhas vizinhas) pegam.
|
||||
- **Aceite "grep getByLabel em e2e"** → verificado: os únicos usos do label `Atividade relacionada` são os 2 pontos alterados; nenhum outro spec depende do estado fechado da combobox.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite da intenção coberto (spec verde isolado e em run completo; DOM/labels fechados intactos)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (sem migration/access/transação; identificadores em inglês; precedente cd469857)
|
||||
- [ ] Testes: run completo e2e dev verde + specs alterados isolados; gates completos no fechamento
|
||||
112
docs/plans/escala-dry-pos-d9-impl.md
Normal file
112
docs/plans/escala-dry-pos-d9-impl.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Impl: Auto-cura de leftovers de consent/supporter + ABBA do shared-write do lease de consent (pós-D9)
|
||||
|
||||
Status: em execução (F1 + F2 implementados; review /simplify aplicado; gates verdes; PR pendente)
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #617
|
||||
Intenção: docs/plans/escala-dry-pos-d9.md
|
||||
Appetite restante: herdado (só testes/helpers; zero `src/`; zero migration)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** (F1) uma run abortada no meio de um fixture de rename/cleanup nunca mais envenena o run seguinte — renames órfãos de consent e contatos/supporters órfãos são reconhecidos e removidos deterministicamente; (F2) o ABBA latente (lease shared de consent + operation escritora) deixa de existir — cascata de timeouts não reproduz sob 6 workers + carga.
|
||||
- **O que NÃO negociar:** mecanismo de lease (advisory lock) é canônico; orçamento de 15s do vitest fora; 4ª key da Onda0 sem lease fora; nada de `src/` nem migration.
|
||||
- **O que reavaliar:** a hipótese "lista vs padrão vs re-snapshot por id" da F1 (o re-snapshot é impossível — ver decisões); o alvo exato da F2 (compartilhado entre `withLeasedConsent`/`withInviteConsent`/janela do `withMissingInviteConsentFixture`/`campaignSupporter:95` — o contrato de leitura/escrita cobre todos).
|
||||
|
||||
## Caracterização (auditoria feita no código)
|
||||
|
||||
**F1 — mecanismo exato da poluição auto-sustentada.** Os testes renomeiam a linha canônica para `chave-renomeada` (campaignInvite.int.spec.ts:1615), `chave-temporariamente-ausente` (campaignInvite.int.spec.ts:1679), `temporarily-renamed-consent` (testDatabaseLease.int.spec.ts:311) e `temporarily-unkeyed-<id>` (campaignFixtures.int.spec.ts:254), sempre dentro do lease **exclusivo** do fixture (renomeia + restaura no mesmo holder). Se o run morre entre o commit do rename e o restore, a linha órfã persiste com a key temporária e a key canônica fica ausente. No run seguinte o fixture snapshotta "ausente", cria linha nova, e o teste renomeia para a MESMA key temporária → UNIQUE `consent_key_idx` (`Valor deve ser único`) → poluição auto-sustentada. Garantia de segurança do purge: qualquer renomeador vivo está segurando o lease exclusivo da key — quem faz o purge também o segura, então nenhuma linha temporária viva pode existir durante o purge (renomeadores em fila só agem após o release, e partem do snapshot pós-purge).
|
||||
|
||||
**F2 — ciclo exato do ABBA.** `withLeasedConsent` (e `withInviteConsent`) segura o lease **shared** durante a `operation()`. Operations escritoras (redeem autofill/login → `UPDATE campaignInvite.usedAt`, `UPDATE leadership`, `UPDATE contact`, `CREATE campaignUser`; `createSupporterRecord`/`createLeaderSupporterRecord` → upsert `contact` + `CREATE supporter`) tomam row locks de invite/contato. Dois escritores em spec files diferentes seguram shared (compatível — zero serialização advisory) e intercalam writes sobre linhas sobrepostas → ABBA de row locks. Também: escritor shared + holder exclusivo (`withMutableConsentFixture`, que escreve invites no corpo do teste) → o exclusivo espera o shared enquanto seu corpo segura row locks que o shared-writer quer. Auditoria de ordem canônica e FIFO: a aquisição é sempre "consent-lease primeiro, row locks depois" — o ciclo existe porque DOIS holders seguram locks em ordens opostas (consent↔row); com escritores exclusivos e leitores shared (que não escrevem → não seguram row locks), nenhum ciclo é construtível.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
F1[F1: purge de renames órfãos] --> H1[purgeOrphanedConsentRenames<br/>lista explícita + padrão temporarily-unkeyed-%]
|
||||
H1 --> S1[chamado sob o lease exclusivo em<br/>withMutableConsentFixture / withMissingInviteConsentFixture / ensureLeasedConsent]
|
||||
F1b[F1b: contatos órfãos de residue] --> H2[purgeMunicipalityResidue deleta<br/>contatos sem outras referências]
|
||||
F2[F2: contrato read/write do withLeasedConsent] --> H3[opção mode:'write' → lease EXCLUSIVO]
|
||||
H3 --> S2[call sites escritores migram;<br/>leitores continuam shared]
|
||||
H3 --> S3[janela do withMissingInviteConsentFixture<br/>permanece shared + invariante documentado]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (mode `'write'` explícito por call site, default read) | B (exclusivo default, opt-out read) | C (escritas fora do escopo do lease shared) | D (janela do missing-consent exclusiva) | E (purge por lista explícita + padrão) | F (re-snapshot por id) | G (purge global de consent não-canônico)
|
||||
|
||||
**Recomendação:**
|
||||
|
||||
- **F1 = E**: purge determinístico por lista explícita (`temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) + `key LIKE 'temporarily-unkeyed-%'`, executado sob o lease exclusivo **antes do snapshot** nos três pontos que tocam a key canônica. — porque a lista cobre os 4 nomes reais hoje, o padrão cobre a família `temporarily-unkeyed-<id>` sem risco (nenhuma key estável legítima casa com o padrão) e um único DELETE indexado é barato.
|
||||
- **F2 = A**: `withLeasedConsent` ganha `{ mode: 'write' }` (lease exclusivo para o ensure + operation num único holder; sem loop de retry — ninguém pode deletar a linha sob exclusivo); default continua `'read'` (shared). Call sites que escrevem migram explicitamente. — porque escritores exclusivos serializam entre si (fim da intercalação de row locks), leitores shared não escrevem → não entram em ciclo, e a medição D9 (janela exclusiva estourou o budget de 15s) não se repete: os escritores de invite são operations curtas (~100–300 ms), não provisions multi-segundo.
|
||||
- **F2 janela missing-consent = shared, preservada (não D)**: a janela shared bloqueia os exclusivos (ensure/fixtures) sem parar leitores shared — a medição D9 foi explícita ("15s tight under load com janela exclusiva"). A operation da janela escreve, mas é a ÚNICA shared-writer da key hoje (1 arquivo por key: campaignSupporter:95 / testDatabaseLease com keys privadas) e o holder do window não espera por ninguém (exclusivos que esperam a janela não seguram row locks — adquirem após o wait) → ciclo não construtível; vira invariante documentado + comentário.
|
||||
|
||||
**Rejeitadas:** B porque serializaria os LEITORES atrás dos escritores (a fila que estourou o budget na medição D9 — leitores são muitos: `testDatabaseLease`, `campaignFixtures`, previews de `campaignInviteUi`); C porque quebra a garantia "a linha canônica está estável durante a operation" (writers sem lease veriam a ausência commitada de uma janela missing-consent → flake de fail-closed); D porque a medição D9 documentou o budget estourando sob carga com janela exclusiva; F porque o snapshot id morre com o run abortado (impossível re-snapshot por id — só o nome temporário sobrevive); G porque apagaria keys legítimas de fixtures (`fixtures.createConsent` gera keys runID-scoped aleatórias que coexistem).
|
||||
|
||||
## Revisão da F2 por medição (2026-08-11 — mudança material pós-gate)
|
||||
|
||||
O plano aprovado assumia a hipótese da intenção ("shared-writer = ABBA latente") e propunha o contrato `mode: 'write'` → lease **exclusivo** durante a operation. A medição sob carga (7 arquivos da key, 6 workers) **refutou a forma e o mecanismo**:
|
||||
|
||||
1. **Exclusivo durante a operation = cascata.** Fila de 17 waiters advisory + timeouts de 15s em cascata (campaignInvite/campaignInviteUi/testDatabaseLease) — o hold do exclusivo por ~30 testes serializa a key inteira. **Descartado medido.**
|
||||
2. **Operation sem lease = FK violation.** `withMutableConsentFixture` com snapshot ausente recria a linha canônica; o cleanup do fixture a deleta; um consumidor sem lease que a adotou (redeem gravando `leadership.consent_id`) → `supporter/leadership_consent_id_consent_id_fk`. **Descartado medido.**
|
||||
3. **O ABBA shared↔row não é construtível hoje:** os consumidores escrevem linhas disjuntas por teste; Onda0 é shared desde D9; nenhum ciclo advisory↔row existe. A cascata D9 era o ONDA0 **exclusivo** (multi-segundo) + fila — regime que já não existe.
|
||||
4. **As flakes residuais reais (baseline medido: 3/4 runs com 1 falha) eram:** (a) consumidores observando a ausência commitada da janela do missing-consent (janela shared D9 não bloqueia consumidores shared) → 'Consentimento ainda não configurado' em redeems; (b) o down-test da Onda0 deletando as 4 keys estáveis **não-atomicamente** (delete autocommit + restore separado) → FK `consent_id`; (c) o provision da Onda0 reescrevendo textos no meio de asserções de preview (mismatch de `requiresConsent`/`consentData`).
|
||||
|
||||
**Design final (medido 8/8 verdes sob 6 workers; baseline 1/4):**
|
||||
|
||||
- **Consumidores ficam SHARED** (D9) — `withLeasedConsent`/`withInviteConsent` inalterados no modo; o contrato `mode` foi **descartado** (call sites revertidos).
|
||||
- **Janela do `withMissingInviteConsentFixture` vira EXCLUSIVA** (era shared desde D9): nenhum consumidor observa a ausência commitada — é a mudança que mata a classe (a). O custo é fila limitada (~200–500ms) — a medição D9 do "15s tight" era sob o regime ONDA0-exclusivo, que não existe mais.
|
||||
- **Down-test da Onda0 atômico** (delete + count + restore numa transação) — mata a classe (b).
|
||||
- **Provision da Onda0 EXCLUSIVO** (~500ms, fila limitada) — mata a classe (c).
|
||||
- **Purge F1 sem o padrão `temporarily-unkeyed-%`**: o padrão só era produzido pelo teste `campaignFixtures` ('deletes the stable invite consent'), que agora usa chave privada `owned-consent-vacated-<id>` — a linha canônica renomeada com key no padrão do purge podia ser apagada por purge de outro arquivo no meio da operation (falha NotFound observada). O purge cobre os 3 nomes explícitos.
|
||||
- **Testes F1 auto-curáveis**: plantas de órfãos sob lease exclusivo + purge no início + cleanup no finally (uma falha do próprio teste não deixa poluição para o run seguinte).
|
||||
|
||||
### Componentes / mudanças (final)
|
||||
|
||||
- **`purgeOrphanedConsentRenames`** (`tests/helpers/testDatabaseLease.ts`): DELETE `WHERE key IN (3 nomes)` via `execute`; autocommit (nunca dentro da transação do lease — delete não-commitado seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada). Chamado sob o lease exclusivo por: (1) `withMutableConsentFixture` antes do snapshot; (2) `withMissingInviteConsentFixture` no setup antes do snapshot; (3) `ensureLeasedConsent` dentro da janela exclusiva antes do find.
|
||||
- **`withMissingInviteConsentFixture`** (`tests/helpers/testDatabaseLease.ts`): `serializeWindow: true` (default) → janela **exclusiva**; `false` inalterado (teste de ensures concorrentes em key privada).
|
||||
- **`withExclusiveTestDatabaseLease`** (`tests/helpers/testDatabaseLease.ts`): wrapper novo exportado (mirror do shared).
|
||||
- **`onda0Provision.int.spec.ts`**: provision com leases exclusivos; down-test com delete+assert+restore numa transação (`beginTransaction` + session db + commit/rollback).
|
||||
- **`campaignFixtures.ts`** (`purgeMunicipalityResidue`): além do purge de residue existente, deleta os **contatos** que os supporters residue órfãos deixaram (referência-check em leadership/signature/subscription/supporter — mesmo critério do `removeSupporterData`, reimplementado no helper).
|
||||
- **`campaignFixtures.int.spec.ts`** (teste 'deletes the stable invite consent...'): chave de vacância privada `owned-consent-vacated-<id>` (fora do padrão do purge).
|
||||
- **Testes novos** (`testDatabaseLease.int.spec.ts`): contrato da janela exclusiva (waiter SHARED bloqueado durante a janela — estende o teste D9); purge em ensure; purge no setup do missing; self-heal do canônico (repro do run abortado, 2 fixtures); **F1b** (`campaignFixtures.int.spec.ts`): purge de contatos órfãos preservando referenciados.
|
||||
- **Migration:** sem migration (só testes/helpers).
|
||||
- **Access / Consent:** nenhuma mudança em runtime; chaves e fail-closed intactos.
|
||||
- **UI:** nenhuma.
|
||||
|
||||
## Fases verificáveis (executadas)
|
||||
|
||||
1. **F2 contrato** — janela exclusiva + contrato testado (waiter SHARED bloqueado durante a janela); consumidores shared preservados.
|
||||
2. **F1 self-heal** — purge nos 3 pontos; repros: órfão plantado (sob lease), auto-cura no run seguinte (fixture 2×), sem UNIQUE; testes auto-curáveis (cleanup no finally).
|
||||
3. **F1b residue** — purge de contatos sem referências; repro: plant + purge direto, preservação de referenciado.
|
||||
4. **Gates + carga** — contraprova: 7 arquivos da key (`campaignInvite`, `campaignInviteUi`, `campaignFixtures`, `testDatabaseLease`, `collectionAccessLockdown`, `campaignSupporter`, `onda0Provision`) com workers=6 → **8/8 runs verdes** (baseline original: 1/4; designs intermediários: cascata de 15s / FK violations); suíte int completa 83 arquivos/701 testes verdes; gates abaixo em andamento.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Mecanismo de lease/advisory lock (canônico, fora — só o modo de aquisição da janela muda).
|
||||
- Orçamento de 15s do vitest (gatilho: recorrência com pool ativo).
|
||||
- 4ª key da Onda0 sem lease (gatilho: primeiro escritor do push key).
|
||||
- Atacar a poluição residual de contatos legados (inertes; fora — o F1b só impede acúmulo novo).
|
||||
- Re-trabalhar a spec Onda0 ou a Onda0 em si.
|
||||
- Gap setup→janela do missing-consent (ausência commitada por ~ms entre o commit do setup e a aquisição da janela; consumidor que insira com FK nesse gap ainda pode falhar — classe residual rara, mesma ordem da aceita na D9; gatilho de revisita: recorrência sob carga).
|
||||
|
||||
## Revisão /simplify (2 reviewers independentes, 2026-08-11) — fixes aplicados
|
||||
|
||||
- **P1-1 (ambos reviewers, obrigatório):** o purge deleta os 3 nomes globalmente sob a lease do chamador; os renomeadores vivos de `campaignInvite.int.spec.ts` (linhas 1615/1679) produziam `chave-renomeada`/`chave-temporariamente-ausente` sob a lease invite — um purge sob outra lease key (supporter/privada) podia apagar a linha renomeada viva (a classe exata que a D10 mata). **Fix aplicado:** os dois renames migraram para chave privada `consent-vacated-<id>` (mesmo padrão do `campaignFixtures`); agora NENHUM teste vivo produz os 3 nomes — o único produtor restante é a planta do self-heal (sob a lease invite, imediatamente antes do fixture curador). Invariante documentada no helper.
|
||||
- **P2-1:** comentários do diff diziam "Onda0 holds shared, never exclusive" — falsos pós-diff (provision é exclusivo). Reescritos.
|
||||
- **P2-2:** `withExclusiveTestDatabaseLease` era cópia verbatim do privado — colapsado em um único exportado (usado também internamente).
|
||||
- **P2-3:** teste 'purges orphaned renames during the missing-consent fixture setup' sem cleanup no finally — adicionado.
|
||||
- **P2 (reviewer 2):** down-test da Onda0 lançava sem rollback quando a sessão da transação faltava — rollback adicionado antes do throw.
|
||||
- **Débitos registrados (não-fix):** fault `beforeRestoreLeaseAcquire` vestigial com janela exclusiva (só tem efeito com `serializeWindow: false` — documentar/remover); exposição transitória do gap do self-heal (ms, mesma classe residual aceita); `privacy_policy.published=false` persiste após o down-test (comportamento D9, inalterado); micro-otimização dos selects do reference-check; callback `execute` do purge podia receber `payload` direto.
|
||||
|
||||
## Riscos e mitigação (medidos)
|
||||
|
||||
- **Fila nova na janela exclusiva / provision exclusivo** (consumidores esperam ~200–500ms): medido 8/8 verdes sob 6 workers — sem timeouts.
|
||||
- **F1 purge apagar linha viva:** impossível por construção (renomeador vivo segura o exclusivo; purge roda sob o exclusivo); testes plantam órfãos sob lease e limpam no finally.
|
||||
- **Down-test atômico:** consumers com FK no meio do delete+restore transacional esperam o commit (speculative insertion) e progridem — classe FK eliminada (medido).
|
||||
- **F1b deletar contato referenciado:** o filtro de outras-referências roda antes do delete; teste cobre ambos os lados (deleta só órfão; preserva referenciado).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Run abortado (rename órfão plantado) não envenena o run seguinte (F1 — repros + suíte)
|
||||
- [x] Nenhum consumidor observa a ausência commitada da janela missing-consent (janela exclusiva + contrato testado)
|
||||
- [x] Cascata de timeouts não reproduz sob 6 workers ×12 (F2 — repro 12/12 vs baseline 1/4)
|
||||
- [x] Review /simplify: P1-1 e P2s aplicados; débitos registrados
|
||||
- [x] Invariantes AGENTS/engineering-standards; só testes/helpers; sem migration; zero `src/` (tsc, lint, knip, cycles, format, unit 1938, int 701, e2e paridade com baseline)
|
||||
89
docs/plans/lembranca-status-liderancas-impl.md
Normal file
89
docs/plans/lembranca-status-liderancas-impl.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Impl: Status de apoio "Lembrança" em lideranças
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #661
|
||||
Intenção: docs/plans/lembranca-status-liderancas.md
|
||||
Appetite restante: herdado (~1 dia eng) — item pequeno, cabe folgado
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** novo valor `lembranca` no enum `supportStatus` de `leadership` (entre `em_disputa` e `negativo` no seletor), visível e marcável em TODAS as superfícies onde os 4 status atuais aparecem (célula da lista com auto-save, ficha/wizard/admin, filtro das listas de lideranças e de pessoas, Sollinha); **nunca** entra no critério de pendência de abordagem (fail-closed na query, não só na UI); badge visualmente distinto; nenhuma leitura existente quebra.
|
||||
- **O que NÃO negociar:** liderança "Lembrança" é favorável e não é pendente — exclusão das pendências espelhando o "Negativo" (na query `getPendingLeaderships`, que já é fail-closed); sem segundo eixo de classificação; sem fluxo próprio de votos (usa o campo de votos declarados existente); leader lockdown intacto (status é staff-only).
|
||||
- **O que reavaliar:** a hipótese da intenção cobre as áreas certas, mas encontrei duas superfícies que ela não nomeou: (1) `peopleData.ts` `leadershipStatusesInOrder` — lista dura dos 4 valores que **silenciosamente omitiria** o novo status do facet da lista de pessoas; (2) `getLeaderships.ts` mantém uma tabela de rótulos duplicada (`SUPPORT_LABELS`) que também esqueceria o valor novo — candidata a DRY com `supportStatusLabels`.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[leadershipSupportStatuses\n+ 'lembranca'] --> B[collection options\n+ migration ALTER TYPE]
|
||||
A --> C[labels + badge variant\n+ tokens CSS]
|
||||
A --> D[listas/filtros/forms\nvia enum array]
|
||||
A --> E[getLeaderships usa\nsupportStatusLabels]
|
||||
F[getPendingLeaderships\nINTOCADO + teste pin] --> G[pendências excluem\nlembranca fail-closed]
|
||||
H[systemPrompt +1 linha\nreconhecimento] --> I[Sollinha responde\nsobre o status]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (valor novo no enum existente, fonte única) | B (segundo campo/eixo de classificação) | C (status composto em string)
|
||||
**Recomendação:** A — o produto decidiu no gate que é um valor a mais no enum; a engenharia tem um único source of truth (`leadershipSupportStatuses` + `supportStatusLabels`) e as superfícies que derivam dele (listas, forms, URLs, omnibox) ganham o valor automaticamente.
|
||||
**Rejeitadas:** B porque a intenção corta explicitamente o segundo eixo (matriz de combinações); C porque destrói filtro `in` no Postgres e indexação.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
- **D1 — Migration do enum.** `ALTER TYPE "public"."enum_leadership_support_status" ADD VALUE 'lembranca' BEFORE 'negativo'` — o `BEFORE` preserva a ordem do enum no Postgres igual à do seletor (decisão do gate: entre "Em disputa" e "Negativo"), então `sort: 'supportStatus'` na lista ordena igual ao seletor.
|
||||
- _Rejeitadas:_ `ADD VALUE` sem `BEFORE` (anexa no fim → ordenação por status divergiria do seletor); recriar o tipo inteiro (rename+create+cast, precedente C14) — destrutivo demais para um valor novo, risco desnecessário em prod.
|
||||
- Precedentes: `20260723_025513` (ADD VALUE … BEFORE), `20260728_041547` / `20260724_180000` (ADD VALUE). Se o `migrate:create` gerar `ADD VALUE` sem `BEFORE`, editar o `.ts` da migration nova (migration não shipada — editável; nunca editar as antigas).
|
||||
- **D2 — Badge distinto.** Novo variant `support-remembered` no `Badge` + tokens `--support-remembered`/`-foreground` (índigo suave: `#e0e7ff` / `#3730a3`) — distinto dos 4 atuais (verde/cinza/âmbar/vermelho) e semanticamente "resolvido e favorável".
|
||||
- _Rejeitadas:_ reusar `estimate-*` (âmbar colide com "Em disputa"); variant `default` (não distinto).
|
||||
- **D3 — `getLeaderships.ts`: matar a tabela duplicada.** Substituir `SUPPORT_LABELS` por `supportStatusLabels` de `leadershipLabels.ts` (tabela de strings pura, client-safe, importável do server). Um valor novo no enum seria propagado pela fonte única em vez de exigir lembrar da duplicata.
|
||||
- _Rejeitada:_ só adicionar `lembranca` na duplicata — deixa a segunda fonte de verdade viva para o próximo status.
|
||||
- **D4 — `peopleData.ts` `leadershipStatusesInOrder`:** trocar a lista dura `['engajado','a_abordar','em_disputa','negativo']` por `leadershipSupportStatuses.filter(s => statuses.has(s))`. Sem isso o status novo não apareceria no facet da lista de pessoas (miss silencioso, sem teste pegando).
|
||||
- **D5 — Pendência: não tocar.** `PENDING_SUPPORT_STATUSES = ['a_abordar','em_disputa','engajado']` e `PENDING_CRITERION` ficam como estão — "Lembrança" e "Negativo" já são excluídos pela ausência da lista (fail-closed na query) e o critério declarado continua sem o novo estado (aceite: "pendências declaram o critério sem o novo estado"). Adicionar teste que pin a exclusão.
|
||||
- **D6 — Sollinha reconhece o status:** +1 linha na seção "Lideranças pendentes de abordagem" do `systemPrompt.ts` (ex.: "Liderança com status 'Lembrança' é favorável sem compromisso — não conta como pendente de abordagem"). O rótulo na leitura vem de D3.
|
||||
- **Semânticas que excluem "Lembrança" intencionalmente (verificar com testes, não mudar):** convite de login exige `engajado` (`campaignInvite*`), acesso ao app exige `engajado` (`access/contacts.ts`, `access/municipalities.ts`), opção de "responsável" de atividade exige `engajado` (`activityLeadershipOptions.ts`) — uma "Lembrança" não é engajada, comportamento correto.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`leadershipSupportStatuses`** (`src/lib/schemas/leadership.ts`): `['engajado', 'a_abordar', 'em_disputa', 'lembranca', 'negativo']` — tipo `SupportStatus` e `isSupportStatus` derivam sozinhos.
|
||||
- **`Leadership.ts`** (collection): opção `{ label: 'Lembrança', value: 'lembranca' }` na posição 4 (admin select + admin filter automáticos).
|
||||
- **Migration:** `add_lembranca_support_status` via `pnpm migrate:create`, SQL conforme D1.
|
||||
- **`payload-types.ts`:** regenerar via `pnpm generate:types`.
|
||||
- **`leadershipLabels.ts`:** `lembranca: 'Lembrança'`.
|
||||
- **`SupportStatusBadge.tsx`:** `lembranca: { variant: 'support-remembered' }`.
|
||||
- **`Badge.tsx`** + **`styles.css`:** variant e tokens (D2).
|
||||
- **`getLeaderships.ts`:** `SUPPORT_LABELS` → `supportStatusLabels` (D3).
|
||||
- **`systemPrompt.ts`:** linha de reconhecimento (D6).
|
||||
- **`peopleData.ts`:** `leadershipStatusesInOrder` via enum canônico (D4).
|
||||
- **Automático (sem edição):** `LeadershipListSupportStatusControl`, `LeadershipForm`/`LeadershipInternalForm`/`WizardLeadershipForm`, `leadershipListFilters`/`peopleListFilters`, `leadershipListUrl`/`peopleListUrl` (parse enum exaustivo), omniboxes, `leadershipData`, `wizardLeadershipContract`, `municipalityV2Network*`, actions/routes zod (`z.enum(leadershipSupportStatuses)`), `support-status/route.ts`.
|
||||
- **Access / Consent:** nenhum — status é staff-only (`canManageCampaignStaffField`), leader lockdown intocado.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
Não se aplica (nenhuma superfície de dado nova; valor de enum flui por filtros/facets existentes — decisão de produto do gate).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Schema+server** — enum array + collection options + migration + `generate:types`; verificação: `pnpm migrate:status` e `pnpm migrate` local.
|
||||
2. **UI** — labels + Badge + tokens + `SupportStatusBadge`; verificação: badge unit test (it.each) renderiza `Lembrança`.
|
||||
3. **IA + pessoas** — `getLeaderships` DRY, `systemPrompt`, `peopleData` ordem; verificação: testes de tool e listas.
|
||||
4. **Testes** — atualizar os que enumeram status (badge it.each, "every status member" do `leadershipListUrl`, fixture e2e, int spec com `lembranca` persistindo, pin de exclusão de pendência); NENHUM teste novo de feature em volume.
|
||||
5. **Gates** — `tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build` local.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Migração destrutiva do enum (recreate) — desnecessária com `BEFORE`.
|
||||
- Reordenar `a_abordar` no seletor — produto não pediu.
|
||||
- Tratamento especial de votos para "Lembrança" — decidido no gate: campo de votos declarados já existente.
|
||||
- Qualquer mudança em convites/acesso-app/atividade (engajado-only é o comportamento correto).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Migration `ADD VALUE` + transação Postgres:** permitido desde PG 12 dentro de transação (novo valor não usado na mesma transação); precedente shipado em prod (`20260723_025513`). Mitigação: verificar `pnpm migrate` local e `migrate:status` antes do push.
|
||||
- **Superfície enumerada esquecida:** mitigado por D3/D4 (fonte única) + teste de badge it.each + teste "every status member" da URL.
|
||||
- **Filtro `lembranca` vazando para pendências:** fail-closed já está na query; o teste unit do `getPendingLeaderships` passa a incluir `lembranca` na exclusão pinada.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (superfícies todas + pendências excluem + badge distinto + Sollinha reconhece)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem transações novas, sem access novo, nomes EN, rótulos pt-BR)
|
||||
- [x] Testes de domínio previstos (badge it.each, URL enum exaustivo, pin pendência, int persistence)
|
||||
71
docs/plans/multiplos-telefones-por-pessoa-impl.md
Normal file
71
docs/plans/multiplos-telefones-por-pessoa-impl.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Impl: Múltiplos telefones por pessoa
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #626
|
||||
Intenção: docs/plans/multiplos-telefones-por-pessoa.md
|
||||
Appetite restante: ~1–2 dias eng (intenção); sem cortes de aceite
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** toda pessoa (liderança, apoiador, dobradinha, assessor) tem N telefones na ficha; listas mostram 1 (o principal); ficha mostra todos; WhatsApp/convite/import usam o principal; busca por telefone acha por qualquer número; guardrail C111 (números compartilhados entre pessoas) preservado.
|
||||
- **O que NÃO negociar:** ordem = prioridade sem flag explícita; sem rótulos/tipos de telefone; import CSV com uma coluna; login da liderança continua o `username` (não muda); dedupe dentro da MESMA ficha bloqueia salvar; **UI pública intocada** — formulários de petição/WhatsApp (e o componente `PhoneInput`) não mudam; a mudança de dados entra só no mapeamento server-side das ações públicas.
|
||||
- **O que reavaliar:** a hipótese da intenção listava `src/lib/phone.ts`, view models e import — ok; mas **assessor** tem camada extra (o telefone mora em `campaignUser.phone`, espelhado na ficha) e **`Contact.phone` é lido pelo site público** (petição/WhatsApp) e por export — a intenção não citava esses dois.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C[Contact] -->|array field| P[phones: {value}[]]
|
||||
P -->|primary| H[primaryPhoneOf]
|
||||
H --> VM[view models / listas / wa.me / convite]
|
||||
W[formulários criação/edição] -->|PhonesFieldEditor| P
|
||||
S[busca/import/findOrCreate] -->|where phones.value| P
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** A — array field Payload `phones` com subcampo `value` na `Contact`, removendo `phone` (fonte única). Payload onde com `'phones.value': { equals/contains }` resolve busca/dedupe com JOIN nativo, `_order` preserva ordem = prioridade, e o admin UI ganha a edição de graça.
|
||||
**Rejeitadas:**
|
||||
|
||||
- B — `jsonb phones: string[]` (custom/JSON): perde query/index Payload; `supporterListSqlFilters` e `supporterImportBulk` teriam que viver de SQL cru e a busca Payload (supporter filters, pickers) ficaria sem where nativo. Pior contrato para o caso de uso mais comum.
|
||||
- C — collection join `contactPhone`: entidade demais para um campo sem rótulos/tipos; clareza zero de "principal" (teria `_order` escondido do mesmo jeito); admin group extra.
|
||||
- D — manter `phone` + `phones` desnormalizados: duas fontes de verdade sincronizando em N write paths; rejeitado por invariante (uma fonte).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`phones`** (`src/collections/Contact.ts`): array field `phones` (label "Telefones", subcampo `value` text 11 dígitos, `required: false`); remove o campo `phone`. `beforeValidate` da collection normaliza cada item (`normalizeBrazilianPhone` / regex 11 dígitos), filtra vazios, rejeita inválido com `BRAZILIAN_PHONE_INVALID_MESSAGE` e rejeita **duplicado dentro da ficha** (nova copy: `BRAZILIAN_PHONE_DUPLICATE_MESSAGE` = "Telefone repetido na ficha.").
|
||||
- **Migration** `add_contact_phones_array`: tabela gerada `contact_phones` (\_parent_id, \_order, value) + backfill manual `INSERT ... SELECT id, phone, 0 FROM contact WHERE phone IS NOT NULL` + `DROP COLUMN contact.phone` (derruba `contact_phone_idx` junto) + índice manual `contact_phones (value)` (Payload não indexa subcampo de array; o equals/in da busca precisa).
|
||||
- **`primaryPhoneOf`** (`src/lib/phone.ts`): `phones[0]?.value ?? null` — o único tradutor array→principal. Todos os readers de lista/ficha passam por ele (view models `leadershipData`, `stateDeputyData`, `peopleData`, `supporterViewModels`, `leaderContactsPageData`, `campaignInvitePageData`, `activityViewModels`, home search, `MunicipalityDossier`, `ContactCombobox`, `wizardLeadershipContract`).
|
||||
- **`setContactPhones`** (nova utility `src/utilities/contactPhones.ts`): read-modify-write transacional do array (usada pelas actions de edição inline "seta principal preservando o resto" e pelos editors de ficha); `normalizeContactPhones` (pure, compartilhada com o hook e com o zod).
|
||||
- **`PhonesFieldEditor`** (novo componente `src/components/campaign/shared/PhonesFieldEditor.tsx`): inputs mascarados repetíveis (reusa `FormattedInput` + `formatBrazilianPhoneInput`), remover por linha, "+ Adicionar telefone", setas ↑/↓ de reordenação (ordem = prioridade; sem arrasto — anti-goal). Serializa `phones` repetido no FormData (padrão `repeatedRelationshipFormValues`; novo `repeatedPhoneFormValues` em `src/lib/formData.ts`).
|
||||
- **Schemas** (`src/lib/schemas/`): **`contactSchema` permanece com `phone`** (só o público usa — petição/WhatsApp); as ações públicas mapeiam `phone → phones: [{ value }]` ao gravar a `Contact` (UI e zod públicos intocados). Nos schemas de campanha: `leadershipCreateSchema`/`wizard`/`supporter`/`leaderSupporter`/`invite` trocam `phone` por `phones` (array, min 1 nos fluxos que exigiam); `contactFieldUpdateSchema`: branch `field: 'phone'` passa a significar "escrever principal preservando o resto" e novo branch `field: 'phones'` (array completo) para os editors de ficha.
|
||||
- **Access / Consent:** nenhum (campo interno de `Contact`; sem Consent novo).
|
||||
- **UI:** Impeccable B — formulários de criação/edição + ficha + listas existentes; shape→craft→critique→polish no editor compartilhado; shells e células inline existentes (`CampaignInlineEditableCell`) permanecem para as listas (mostram o principal, editam o principal).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer schema+server** — campo array + hook + migration (backfill+drop+índice) + `primaryPhoneOf` + `findOrCreateContactByPhone` via `phones.value` + view models trocando para o principal + ações públicas mapeando `phone → phones[0]` (sem tocar UI/zod públicos) + `signatureExport` + seed/fixtures. **Spike aqui:** confirmar semântica do `where: { 'phones.value': { equals/contains } }` em Payload 3.82 (int spec) — se falhar, fallback é JOIN cru nos poucos sites Payload-where.
|
||||
2. **Write paths** — zod/schemas das 5 superfícies + actions (leadership create/wizard/inline, supporter create/tombstone LGPD, stateDeputy inline, leaderSupporter, convite redemption, sync `CampaignUser` (append se faltar), import CSV preview+bulk) + where de busca (`supporterListFilters`, `supporterListSqlFilters`, pickers de atividade, combobox).
|
||||
3. **UI** — `PhonesFieldEditor` + forms de criação (LeadershipForm, SupporterForm, LeaderContactForm, WizardLeadershipForm) + fichas (LeadershipContactSection, StateDeputyContactSection, "Dados de contato" do apoiador com editor novo, assessor ganha seção "Telefones" na ficha) + células inline inalteradas na lista.
|
||||
4. **Gates** — unit (primaryPhoneOf, normalize/dedupe, schemas, formData) + int (multi-telefone nas 5 superfícies, dedupe bloqueia, principal preservado no inline, import, convite, busca por qualquer número, tombstone substitui TODOS os telefones, sync append) + `pnpm gate:fast` + `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não adicionar busca por telefone nas listas que hoje só buscam por nome (lideranças, dobradinhas, pessoas, assessores) — o aceite é "a busca por telefone encontra por qualquer número" onde busca por telefone existe (apoiadores, pickers); adicionar nas outras é item futuro se a mesa pedir. **Gatilho de revisitação:** mesa perguntar "por que não acho a liderança pelo número na lista de lideranças".
|
||||
- Sem cap de quantidade de telefones; sem flag "principal"; sem arrasto (setas são suficientes para trocar prioridade).
|
||||
- `campaignUser.phone` permanece campo único da conta (canal de contato/login); não vira array.
|
||||
- Sem espelho ficha→conta (conta continua espelhando UNICAMENTE via sync existente conta→ficha, agora append).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Payload onde em subcampo de array** (`'phones.value'`): spike na Fase 1 com int spec; fallback JOIN cru documentado nos poucos sites.
|
||||
- **Migration destrutiva em prod (PII real):** backfill roda ANTES do drop no mesmo migration; `migrate:status` local + revisão do SQL no checklist de deploy.
|
||||
- **Churn de testes** (muitos specs leem `contact.phone`): helper de fixture `createContact` continua aceitando `phone` e mapeia internamente para `phones` — maioria dos specs int fica intocada; asserts com `where: { phone }` viram `where: { 'phones.value': … }`.
|
||||
- **Race do sync conta→ficha (append)**: sem transação garantida no hook; se o append colidir com edição concorrente, o dedupe fail-closed rejeita o save da conta (raro, mensagem clara).
|
||||
- **Inline edit de lista** apagar principal: "limpar o campo" remove o index 0 (o resto sobe); nunca zera os demais.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (N telefones, lista=principal, ficha=todos, wa.me/convite/import=principal, busca por qualquer, C111 preservado, dedupe interno)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (fonte única em `Contact`; transação+req nas escritas multi-collection; copy pt-BR; identificadores inglês)
|
||||
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — acima
|
||||
110
docs/plans/pessoas-edicao-inplace-lista-impl.md
Normal file
110
docs/plans/pessoas-edicao-inplace-lista-impl.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Impl: Pessoas — edição onde você vê (edit-where-you-see) na lista desktop
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #655
|
||||
Intenção: docs/plans/pessoas-edicao-inplace-lista.md
|
||||
Appetite restante: ~1,5–2 dias eng; um outcome verificável — a linha da lista é editável sem sair da tabela
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** na tabela desktop de `/campanha/pessoas`, toda célula (exceto Ações) é um **input permanente sem destaque** — parece texto, se comporta como input; salva no blur/Enter (Esc descarta); Nome mantém o valor como link; colunas de municípios (Assessora, Lidera, Aliada em) e Assessorado usam **chips internos** com colapso por território ("Sertão do São Francisco (5)"), grupo "Salvador (19)", "+X" expande, X no hover remove; ações ganham tooltip.
|
||||
- **O que NÃO negociar:** sem migration (escreve no dono da ficha — `Contact` nome/telefone/e-mail/cidade; vínculos nas collections donas); leader fora da rota; escopo do assessor preservado (só a carteira dele); mobile intacto; nada criado fora do catálogo (texto sem match não vira chip); link do Nome navega; "—" vira placeholder apagado.
|
||||
- **O que reavaliar:** a hipótese de reusar `CampaignInlineEditableCell` (B163) — seu `editTrigger='cell'` tem estado de edição explícito; o paradigma travado exige input **sempre montado**, então a mudança é um **modo novo** no componente compartilhado, não um ajuste do modo existente. A hipótese de reusar `MunicipalityPortfolioCell`/`RelationChipCell` confirma-se: colapso por território já existe (chips batch); o trabalho novo é **expandir chip batch por clique/hover** (interação que nenhuma célula do sistema tem hoje), o grupo "Salvador (19)" (zona-cidade completa) e o modo `quiet` (sem tint de hover/foco).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
L[lib/municipalityPortfolio: grupo Salvador + expand chips] --> M[MunicipalityPortfolioCell: opt-ins quiet/+X/expand]
|
||||
M --> P[PeopleMunicipalityCell: estado de expansão + colapso no mouse-leave]
|
||||
C[CampaignInlineEditableCell: modo permanent] --> T[Células de texto: Nome link-overlay, Contato, E-mail, Base]
|
||||
R[RelationOptionCell: Assessorado readOnly + quiet] --> A[PeopleAssessoradoCell]
|
||||
S[Actions: updatePersonContact, setPersonAssessora, setPersonAdvisor] --> F[pessoas/formActions.ts]
|
||||
T --> PG[page.tsx: colunas com matriz de edição por papel]
|
||||
A --> PG
|
||||
P --> PG
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — estender os componentes compartilhados com props opt-in** (`CampaignInlineEditableCell` modo `permanent`; `RelationChipCell` com `quiet`/`onChipClick`/`overflowToggleLabel`; `MunicipalityPortfolioCell` repassa e expande chips; novo grupo no `buildMunicipalityPortfolioChips`): uma máquina de save e uma máquina de chips para todo o sistema; as outras listas ficam intactas (defaults inalterados).
|
||||
- **B — componentes novos `people/*` que duplicam a máquina de save/chips**: isolamento total, mas dois donos para blur/Enter/Esc/pending/erro e para o combobox ARIA — exatamente o twin que o repo proíbe.
|
||||
- **C — modo `permanent` virar o novo default do B163**: coerente com o paradigma, mas muda o comportamento visual das dobradinhas/lideranças sem ser pedido deste item.
|
||||
|
||||
**Recomendação: A** — o trabalho de interação nova (expandir chip batch, +X, quiet) entra como opt-in nas máquinas existentes e a expansão fica como **estado de apresentação** do wrapper `PeopleMunicipalityCell` (puro, sem tocar o ciclo de commit do `RelationChipCell`). C é rejeitada porque altera superfícies fora do escopo; B porque cria twin.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
**Puro (lib, client-safe):**
|
||||
|
||||
- **`src/lib/municipalityPortfolio.ts`**: (1) novo kind de chip `city` — zonas de uma zona-cidade **completas** viram UM chip `{ kind: 'city', label: 'Salvador', city: 'Salvador', municipalityIds: [19 ids] }`, checado **antes** do colapso por território (senão as 19 zonas colapsariam como "Metropolitano de Salvador (19)"); `MunicipalityPortfolioChip` ganha o variant; (2) helper puro `expandMunicipalityPortfolioChips(chips, expandedKeys)` — substitui chip batch (territory/city) cuja key está expandida pelos chips municipais membros; (3) testes unit.
|
||||
- **`src/lib/schemas/contact.ts`**: variante `city` no `contactFieldUpdateSchema` compartilhado (bounds da collection: 2–100; aditivo — as actions de dobradinhas/liderança não expõem `city` e continuam falhando em campo desconhecido).
|
||||
|
||||
**Server (`person.ts` + formActions):**
|
||||
|
||||
- **`src/app/(campaign)/campanha/actions/person.ts`**: três record-functions novas, todas `withPayloadTransaction` + `req`, Local API com `user`/`overrideAccess: false` e bypass justificado só depois da checagem:
|
||||
- `updatePersonContactRecord(payload, actor, input)` — escopo: irrestrito sempre; assessor somente se **pelo menos uma** entidade da pessoa (leadership OU stateDeputy, lida com `overrideAccess: false`) é gerenciável por ele (regra "edita o que vê" da linha; staff-only → assessor nega). Depois, bypass do Contact (precedente B163/C99).
|
||||
- `setPersonAssessoraMembershipRecord(payload, actor, { contactID, municipalityIds, assigned })` — `reloadUnrestrictedActor` (canAssignMunicipalityAdvisors é unrestricted-only); transação única: resolve as contas staff da pessoa por `contact`, **asserta exatamente uma** (multi-conta → célula read-only no client; o server nega com mensagem segura), e para cada município: lock advisory `municipality-advisors:<id>` + toggle — **extrair** o toggle de `setMunicipalityAdvisorMembershipRecord` (`src/app/(campaign)/campanha/actions/municipality.ts`) para um helper compartilhado usado pelos dois (edit the owner, sem duplicar o bloco lock+toggle).
|
||||
- `setPersonAdvisorMembershipRecord(payload, actor, { contactID, advisorId, assigned })` — `reloadUnrestrictedActor` (regra B156); transação única: aplica o delta de advisor em **cada entidade** da pessoa (leadership e/ou stateDeputy) via `nextAdvisorIdsAfterMembership`/helper existente — semântica person-centric: a coluna Assessorado é atributo da pessoa, adicionar/remover atinge os dois vínculos quando ambos existem.
|
||||
- **`src/app/(campaign)/campanha/(app)/pessoas/formActions.ts`** (novo): `updatePersonContactFormAction`, `setPersonAssessoraFormAction`, `setPersonAssessoradoFormAction`, `setPersonLeadershipMunicipalitiesFormAction` (envolve `setLeadershipMunicipalitiesMembership`), `setPersonStateDeputyMunicipalitiesFormAction` (envolve `setStateDeputyMunicipalitiesBatch`) — cascas `runCampaignFormAction` no padrão das dobradinhas, com safe-messages existentes.
|
||||
- **Revalidação:** `/campanha/pessoas` + caminhos de detalhe das entidades tocadas (`liderancas/[id]`, `dobradinhas/[id]`, municípios tocados no assessora) — espelhando `revalidateStateDeputyPaths`/`revalidateLeadershipMunicipalityPaths`.
|
||||
- **`src/utilities/people/peopleData.ts`**: `PeopleRowViewModel` ganha `assessoradoIDs: number[]` (união `leadershipAdvisorIDs ∪ deputyAdvisorIDs`, já resolvida hoje para os nomes) — ids que os chips do Assessorado precisam.
|
||||
|
||||
**UI (Impeccable B — encaixe em tela existente):**
|
||||
|
||||
- **`src/components/campaign/shared/CampaignInlineEditableCell.tsx`**: prop `permanent?: boolean` (default false — B163/dobradinhas/lideranças intactos). Em `permanent`:
|
||||
- texto (Contato/E-mail/Base): input **sempre montado**, `bg-transparent border-transparent` (mesmo vocabulário de `campaignInlineInputClassName`), valor = draft, salva no blur/Enter, Esc reverte, vazio → `placeholder="—"` apagado (o "—" atual vira placeholder), telefone mantém formatação; máquina de save/pending/erro/saved-feedback/requestId reutilizada tal qual.
|
||||
- Nome (`permanent` + `href`): **mecanismo travado no gate** — input sempre montado `absolute inset-0 z-0` com `value` **sempre vazio** (`bg-transparent`), link em `z-10` com `pointer-events-auto` cujo texto renderiza `draft || value`; cada tecla atualiza o draft **no display** (texto do link), o valor real do input permanece vazio; clique no link navega; foco por Tab/clique fora do texto → digitação; blur/Enter salva, Esc descarta. Sufixo `({party})` permanece fora do link como texto read-only.
|
||||
- Indicador de foco **discreto**: `focus-visible` ring suave; nada em hover (paradigma sem fundo/borda).
|
||||
- **`src/components/campaign/shared/RelationChipCell.tsx`**: três opt-ins, defaults inalterados (B34/B36/B37/B156/B157/B159/B169 intactos):
|
||||
- `quiet?: boolean` — remove `pointer-fine:hover:bg-muted/40` do container; mantém apenas indicador discreto de foco (guardrail de acessibilidade);
|
||||
- `onChipClick?: (chip) => void` — chip **sem href** clicável (hoje só navega); com stopPropagation;
|
||||
- `overflowToggleLabel?: (hiddenCount) => string` — o toggle de overflow do B170 renderiza como chip `+N` em vez de "Ver mais…".
|
||||
- **`src/components/campaign/shared/MunicipalityPortfolioCell.tsx`**: repassa os três opt-ins ao `RelationChipCell` e aplica `expandMunicipalityPortfolioChips` no `buildChips` quando `expandedKeys` é passado.
|
||||
- **`src/components/campaign/people/PeopleMunicipalityCell.tsx`** (novo, substitui o `PeopleMunicipalityCell` local do page.tsx): wrapper de `MunicipalityPortfolioCell` com estado local `expandedKeys` + `onMouseLeave` que **colapsa** chips expandidos cujos membros continuam todos atribuídos (lê `ids` da prop via ref); serve as colunas Assessora/Lidera/Aliada com `commitAction`/`buildFormData`/`minItems`/`readOnly` definidos pela página.
|
||||
- **`src/components/campaign/people/PeopleAssessoradoCell.tsx`** (novo): wrapper de `RelationOptionCell` — chips = assessorados com href `/campanha/assessores/[id]` (só para irrestrito; regra B156), `options` de `loadEligibleAdvisorOptions`, `readOnly` para assessor; copy do `ADVISOR_COPY` (sem o twin: extrair/reusar a copy de `LeadershipAdvisorRelationCell` se o diff permitir, senão constante local).
|
||||
- **`src/app/(campaign)/campanha/(app)/pessoas/page.tsx`**: colunas Contato/E-mail/Base com `CampaignInlineEditableCell permanent`; Nome com `permanent` + `href` (`/campanha/liderancas/[id]` quando `leadershipID`); Assessora/Lidera/Aliada com `PeopleMunicipalityCell`; Assessorado com `PeopleAssessoradoCell`; **matriz de edição por papel** (helper puro na page ou `lib`): irrestrito edita tudo; assessor edita células de texto e Lidera/Aliada (com `addableIds = administeredIds`, `minItems=1` em Lidera) apenas nas linhas cujo vínculo cruza a carteira; Assessora e Assessorado **read-only** para assessor (canAssignMunicipalityAdvisors / B156 são unrestricted-only). Ações: `CampaignHoverTooltip` com a label (WhatsApp, Convidar, Apagar) nos três botões. `PeopleMobileCards` intacto.
|
||||
- **Migration:** nenhuma — sem mudança de schema.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- **Forma escolhida:** inputs permanentes sem destaque + chips internos ao input — o paradigma travado no gate 2026-08-11, que É a forma deste item (não há dado analítico; o chip de território mostra contagem, não métrica). Rejeitadas: toggle global de edição, lápis obrigatório e células read→edit com estado visual — tudo divergente do aceite.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / puro + UI machines** (~1/2 appetite): grupo `city` de Salvador + `expandMunicipalityPortfolioChips` + unit tests; opt-ins no `RelationChipCell`/`MunicipalityPortfolioCell`; modo `permanent` no `CampaignInlineEditableCell`; `PeopleMunicipalityCell` + `PeopleAssessoradoCell`. Validar que dobradinhas/lideranças não regridem (testes existentes + revisão visual).
|
||||
2. **Server / access** (~1/3 appetite): variante `city` no schema; as três record-functions + formActions; `assessoradoIDs` no VM; int tests — coordenador/candidato editam tudo, assessor só a carteira (célula de texto em linha em escopo ok; linha staff-only negada; Lidera/Aliada com addableIds; Assessora/Assessorado read-only), multi-conta do Assessora nega, union do Assessorado escreve nas duas entidades, telefone conflitante falha com mensagem segura.
|
||||
3. **Page + polish** (~1/3 appetite): colunas com a matriz por papel, tooltips nas ações, indicador de foco discreto, `+X`, colapso por mouse-leave; shape→craft→critique→polish.
|
||||
4. **Gates:** após cada slice `pnpm exec tsc --noEmit` + testes focados; ao final `pnpm lint` (0 warnings), `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm test:e2e` (se tocar jornada), `pnpm build` local.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Contenteditable real para chips internos — o comportamento continua o de input (chips como dados, teclado previsível); a mecânica é chips + input inline do `RelationChipCell`.
|
||||
- Estado de expansão global / animações entre linhas — expansão é local à célula, por hover/foco, sem estado persistido.
|
||||
- Tree-view de território com sub-chips persistentes — grupo colapsa/expande in loco; nada de hierarquia.
|
||||
- Edição em lote / spreadsheet mode / reorder de colunas / coluna nova.
|
||||
- C112 (telefones múltiplos) e B155+ (rota em vez de form action) continuam suaves — o editor de Contato segue o shape atual e o padrão de escrita vigente (form action).
|
||||
- Migration de schema — nenhuma.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão B163/dobradinhas/lideranças:** modo `permanent` default-off; testes existentes do controle compartilhado cobrem os modos antigos; revisão visual das duas listas na Fase 1.
|
||||
- **Regressão `RelationChipCell` (B34/B36/B37/B156/B157/B159/B169):** três props opt-in com defaults inalterados; testes unit/int existentes das células de relação rodam intactos na Fase 1.
|
||||
- **Mecanismo do Nome (input vazio + draft no link):** caret invisível é trade-off do mecanismo travado; a navegação e a digitação coexistem; teste de componente cobre clique-no-link (navega) vs clique-fora (edita), Enter/Esc/blur.
|
||||
- **Assessor com linha visível por capacidade fora da carteira:** matriz por coluna derruba a edição quando a entidade subjacente não é gerenciável (Lidera/Aliada read-only na linha); o server nega de qualquer forma (access da collection dona).
|
||||
- **Pessoa com múltiplas contas staff (Assessora):** célula read-only + server nega; edge raro e honesto.
|
||||
- **Chips batch (território/Salvador) removidos no X removem o grupo todo:** semântica já existente no sistema; o undo toast cobre a remoção em lote.
|
||||
- **Revalidação de paths novos** (`/campanha/pessoas` + detalhes das entidades): seguir os helpers de revalidação existentes por entidade; nunca `revalidatePath` global.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (inputs permanentes, Nome-link, chips com colapso por território/Salvador, +X, X no hover, tooltips, mobile intacto, sem lote)
|
||||
- [ ] Sem migration; escrita no dono da ficha (`Contact`) e nas collections donas dos vínculos
|
||||
- [ ] Local API com `user` + `overrideAccess: false`; bypass do Contact/entities justificado após checagem de escopo; transações com `req` em toda escrita multi-collection
|
||||
- [ ] Escopo do assessor preservado (matriz por coluna + access da collection dona); leader fora da rota; multi-conta do Assessora nega
|
||||
- [ ] `contactFieldUpdateSchema` ganha `city` de forma aditiva; mensagens seguras allowlisted
|
||||
- [ ] Testes previstos: unit (grupo Salvador, expand chips, +X) e int (escopo de célula por papel, assessora single-account, union do assessorado, telefone conflitante)
|
||||
- [ ] Nome em identificadores inglês; copy pt-BR
|
||||
|
||||
**Self-score decision-quality: 5/5** — decisões caras (estender vs criar twin, mecanismo do Nome, semântica do Assessorado/Assessora) têm alternativas rejeitadas com justificativa; appetite bounded por fases; rabbit holes nomeados; reuso máximo das máquinas existentes; o aceite da intenção não foi alterado.
|
||||
|
|
@ -14,7 +14,7 @@ Responsável: —
|
|||
Dois achados P2 do /simplify da entrega C117 (#656), mesma superfície (omnibox de pessoas):
|
||||
|
||||
1. **Descoberta de ordenação no mobile (S12).** No mobile os headers sortáveis não existem (cards), e o grupo "Ordenação" só aparece digitando keyword; ao digitar o genérico "ordenar", o cap de 8 por grupo (`campaignListOmnibox.ts`) corta em `Nome/Contato/Assessora/Lidera`, escondendo `Aliada em`, `Assessorado` e `Base` — o usuário mobile não descobre que pode ordenar por essas colunas.
|
||||
2. **Facet de ausência "completo" (S14).** Marcar a 3ª ausência zera o facet inteiro (contrato B18 "selecionar todos = todas" = ausente): a consulta "quem está sem *algum* dado" (fichas incompletas) é inexpressível — o terceiro clique faz os três chips sumirem no meio da interação.
|
||||
2. **Facet de ausência "completo" (S14).** Marcar a 3ª ausência zera o facet inteiro (contrato B18 "selecionar todos = todas" = ausente): a consulta "quem está sem _algum_ dado" (fichas incompletas) é inexpressível — o terceiro clique faz os três chips sumirem no meio da interação.
|
||||
|
||||
## Fases (ordem de ROI)
|
||||
|
||||
|
|
|
|||
62
docs/plans/sollinha-close-mid-stream-persist.md
Normal file
62
docs/plans/sollinha-close-mid-stream-persist.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Sollinha: fechar o drawer durante streaming pode reabri-lo num reload (race de persistência)
|
||||
|
||||
Status: registrado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #<N>
|
||||
Priority: P3
|
||||
Model: composer-2.5
|
||||
Impeccable: B — comportamento do drawer existente, sem nova superfície
|
||||
Appetite: ~0,5 dia eng; um ajuste no persist effect do provider
|
||||
|
||||
## Intenção
|
||||
|
||||
O persist effect do chat (`CampaignAISidebarContext.tsx`) só grava a sessão quando `status === 'ready'`. Se o usuário fecha o drawer enquanto a resposta ainda está chegando (X manual, swipe — ou, desde B198, um toque num link da resposta que fecha o drawer no mesmo tab) e um reload acontece antes do settle, o `sessionStorage` ainda carrega o estado velho `open: true` — o restore reabre o drawer "sozinho" na próxima página (mesma classe OPS22 que o repo já caça).
|
||||
|
||||
Pré-existente (o X tinha a mesma janela), mas o link-navegação do B198 torna o fechamento-durante-streaming um fluxo normal de campo: o usuário toca no link no meio da resposta, o drawer fecha, ele navega; um reload (ou o próprio fluxo de volta) não deve ressuscitar o drawer.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** assessor/liderança no celular; toca num link interno enquanto o Sollinha ainda está respondendo.
|
||||
- **Job principal:** fechar o drawer em qualquer momento (mesmo mid-stream) e ter um reload sem drawer fantasma.
|
||||
- **Fluxo desejado:** resposta em streaming → toque no link → drawer fecha → reload → drawer continua fechado, conversa restaurada.
|
||||
- **Anti-goal:** não reabrir a raça de restore (OPS22/B188): fechar mid-stream não pode ser tratado como "estado velho válido".
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Fechar o drawer com `status !== 'ready'` persiste `open: false` mesmo antes do settle — um reload imediato não reabre o drawer.
|
||||
- A conversa em andamento continua persistindo normalmente (nada de descartar mensagens do stream em andamento).
|
||||
- O mecanismo `openBy`/`userToggledOpenRef` continua governando o restore como hoje (não inverter intenção).
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
Dados: N/A — persistência client-side (`sessionStorage`), sem servidor.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Área provável:** `src/components/campaign/shell/ai/CampaignAISidebarContext.tsx` — o persist effect (linhas ~116-121) hoje early-returna com `status !== 'ready'`. Hipótese: persistir `open` em mudanças imediatas (efeito separado só para `open`, sem gate de status, reusando `writeSollinhaChatSession` com as mensagens atuais do último commit), deixando o gate de `status` apenas para as mensagens do stream. Validar interação com `sessionRestored` e com o write do settle (que já escreve tudo junto).
|
||||
- **Precedente a olhar:** `src/lib/sollinhaChatSession.ts` (write/read fail-closed), `docs/plans/sollinha-contexto-sessao-janela.md` (B188) e `sollinha-drawer-fecha-link-mobile-impl.md` (B198).
|
||||
- **Risco:** escrever a sessão com um `messages` mid-stream congelaria uma resposta pela metade no storage — se o write de `open` e o write de `messages` forem o mesmo `writeSollinhaChatSession`, o gate de mensagens precisa continuar (ou o write de close pode usar o último snapshot `ready` de mensagens).
|
||||
|
||||
## Dependências
|
||||
|
||||
- #660 (B198) — o fluxo de fechamento por link é o amplificador; destrava sozinha quando o pai flipar `done`.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Mudar o mecanismo de restore/`openBy` (OPS22/B188) — só o write de fechamento.
|
||||
- Persistir mensagens mid-stream (mantém a regra atual de só gravar stream completo).
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **Escrever o stream pela metade.** Se o ajuste gravar `messages` mid-stream, um reload perde a cauda do turno. **Corte:** fechamento persiste `open: false` com o snapshot de mensagens do último estado `ready` (ou o array atual se vazio/estável).
|
||||
- **Reabrir a raça de restore.** Mexer em `openBy` para "consertar" isto troca um bug raro por outro. **Corte:** só o `open` muda; `openBy` continua como hoje.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- Nenhuma — comportamento: fechar é fechar, em qualquer fase do turno.
|
||||
|
||||
## Referências
|
||||
|
||||
- GitHub Issue #<N> (pai: #660)
|
||||
- `src/components/campaign/shell/ai/CampaignAISidebarContext.tsx`, `src/lib/sollinhaChatSession.ts`
|
||||
- `docs/plans/sollinha-contexto-sessao-janela.md`, `docs/plans/sollinha-drawer-fecha-link-mobile-impl.md`
|
||||
95
docs/plans/sollinha-drawer-fecha-link-mobile-impl.md
Normal file
95
docs/plans/sollinha-drawer-fecha-link-mobile-impl.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Impl: Sollinha mobile: fechar o drawer ao navegar por link
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #660
|
||||
Intenção: docs/plans/sollinha-drawer-fecha-link-mobile.md
|
||||
Appetite restante: herdado (~0,5 dia eng; um encaixe no renderer de links do chat)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** em mobile, tocar num link interno (`/campanha…`) na resposta do Sollinha fecha o drawer no toque (antes de a página carregar) e a navegação segue no mesmo tab; reabrir restaura a conversa intacta; desktop inalterado; link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre o drawer.
|
||||
- **O que NÃO negociar:** a conversa não pode ser descartada (sessão intacta); o painel desktop não pode fechar; só links das respostas do chat fecham (chips/sugestões/busca ficam fora — são `sendMessage`, não navegação, então nem chegam perto do renderer de links).
|
||||
- **O que reavaliar:** a hipótese "fechar é chamar `setOpen(false)` no toque, provavelmente só quando `isMobile`" — validar o efeito no `userToggledOpenRef`/`openBy` persistido (ver Riscos).
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
### D1 — Onde fechar o drawer
|
||||
|
||||
```
|
||||
Opções: A | B | C
|
||||
Recomendação: A — no onClick do renderer de link interno (mobile) — porque é o
|
||||
único ponto que conhece a tripla href + viewport + gesto do clique: o renderer
|
||||
já decide interno vs externo (B187/B188), o `ctx.isMobile` vem do provider e o
|
||||
click event diz se é clique simples (mesmo tab). Fecha no toque, exatamente o
|
||||
aceite ("antes de a página carregar"). Sessão intocada.
|
||||
Alternativas rejeitadas: B (efeito em pathname no provider) porque fecha em
|
||||
QUALQUER navegação — incluindo back/forward e cliques na faixa de 10% da página
|
||||
visível acima do drawer —, mais amplo que o aceite (o corte da intenção é "só
|
||||
os links das respostas") e dispara após o commit da navegação, não no toque; C
|
||||
(expor `closeForNavigation` no contexto que não marca intenção de usuário)
|
||||
porque `requestOpen(false)` já é seguro (ver Riscos) — crescer o API do
|
||||
contexto sem mudança de comportamento é over-engineering.
|
||||
```
|
||||
|
||||
### D2 — Onde mora a decisão pura (testável)
|
||||
|
||||
```
|
||||
Opções: A | B
|
||||
Recomendação: A — em `src/lib/ai/markdownLinks.ts` (dono da decisão de link
|
||||
interno/externo, B187) — porque o regex `APP_INTERNAL_LINK` já é conhecimento
|
||||
de link que vive no renderer; consolidar lá como `isCampaignInternalLink` e
|
||||
adicionar `shouldCloseDrawerOnLinkClick` pura (href + isMobile + metadados do
|
||||
click), unit-testável sem DOM. O renderer vira fiação fina.
|
||||
Alternativas rejeitadas: B (deixar a condição inline no componente) porque a
|
||||
regra de fechamento (mobile + interno + clique simples, ignorando
|
||||
ctrl/meta/shift/alt/middle-click e defaultPrevented) merece pin de teste de
|
||||
unidade, e o owner já existe.
|
||||
```
|
||||
|
||||
### D3 — Como o renderer alcança o contexto
|
||||
|
||||
`markdownComponents` hoje é constante de módulo (sem acesso ao `useAISidebar`).
|
||||
Mover para dentro de `CampaignAIChat` num `useMemo` (deps `[ctx?.isMobile,
|
||||
ctx?.setOpen]`) — o `a` interno ganha `onClick` que fecha quando a função pura
|
||||
responde true. `setOpen` (requestOpen) é estável via `useCallback`; o memo não
|
||||
re-renderiza o markdown à toa. Alternativa rejeitada: componente novo
|
||||
`AssistantMarkdown` — o chat já é o dono do markdown; fatiar em outro
|
||||
componente é cerimônia sem volatilidade.
|
||||
|
||||
## Componentes / mudanças
|
||||
|
||||
- **`isCampaignInternalLink`** (`src/lib/ai/markdownLinks.ts`): regex `^\/campanha(?:\/|$)` promovido de `CampaignAIChat.tsx` para o owner; usado pelo renderer e pela função de fechamento. Sem mudança de semântica.
|
||||
- **`shouldCloseDrawerOnLinkClick(href, isMobile, event)`** (`src/lib/ai/markdownLinks.ts`): `true` ⇔ link interno && `isMobile` && clique simples (`button === 0`, sem ctrl/meta/shift/alt, `!defaultPrevented`). Modifier/middle-click abre em nova aba — a aba de origem mantém o drawer (mesma regra do link externo).
|
||||
- **`CampaignAIChat.tsx`**: `markdownComponents` vira `useMemo` no corpo do componente lendo `ctx`; o branch interno do `a` adiciona `onClick` que chama `setOpen(false)` quando `shouldCloseDrawerOnLinkClick`. Branch externo e desktop intactos (desktop: `isMobile` false → no-op).
|
||||
- **`tests/unit/aiMarkdownLinks.unit.spec.ts`**: pin das duas funções puras (interno/externo, viewport, modifiers, botão, defaultPrevented).
|
||||
- **`tests/e2e/campaignSollinhaContext.e2e.spec.ts`**: dois testes mobile com o mock de SSE já usado no arquivo (resposta com link interno → drawer fecha + URL muda sem `load`; resposta com link externo → drawer permanece e abre nova aba).
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** N/A.
|
||||
- **UI:** Impeccable B — comportamento do drawer existente, sem superfície nova; shape→critique leve (não há shape novo).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Núcleo puro** — `markdownLinks.ts` + unit tests; `pnpm test:unit` (spec de markdownLinks).
|
||||
2. **Fiação UI** — `CampaignAIChat.tsx` (useMemo + onClick); `pnpm typecheck` + `pnpm lint`.
|
||||
3. **E2E** — dois testes no `campaignSollinhaContext.e2e.spec.ts`; rodar só o arquivo.
|
||||
4. **Gates** — `pnpm gate:fast`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`; push via `pnpm push` no PR.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Animar/transicionar o fechamento; mudar drawer/vaul; chips/sugestões/busca (não navegam); sessão/restauração; painel desktop; prefetch de link.
|
||||
- Decisão de "qual drawer state se anima durante navegação": não existe — `setOpen(false)` usa o mecanismo existente.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **`openBy` persistido como `'user'` após fechamento por navegação** (porque `requestOpen` marca `userToggledOpenRef`): inerte — o restore só reabre quando `session.open === true` (CampaignAISidebarContext.tsx:89), e aqui `open` vira `false` antes do persist effect rodar (`status` ready + mudança de `open`). Reload não reabre (aceite). Validação no teste e2e existente "drawer aberto volta aberto após reload" não é afetada (aquele fluxo não navega).
|
||||
- **Clique modificado (cmd/ctrl+click) em link interno**: abre nova aba; sem fechar — a aba de origem mantém o chat montado. Coberto por unit test da função pura.
|
||||
- **Clique em link durante frame de hidratação (isMobile não medido)**: impossível — o drawer só renderiza com `isMobile` true, que só vira true após a medição (`useIsMobileMeasured`).
|
||||
- **`markdownComponents` recriado por render**: o `useMemo` mantém identidade estável; `ReactMarkdown` recebe `components` novo apenas quando viewport/`setOpen` mudam.
|
||||
- **Fechar + navegar no mesmo handler**: state update (drawer) e router push (next/link) coexistem sem conflito; o `e.defaultPrevented` não é tocado.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (5 itens do aceite → testes unit + e2e)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem access, identificadores em inglês, pt-BR só em strings de UI)
|
||||
- [x] Testes de domínio previstos: unit (decisão pura) + e2e (comportamento drawer mobile)
|
||||
43
docs/plans/worktree-dev-port-nao-honrado.md
Normal file
43
docs/plans/worktree-dev-port-nao-honrado.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# OPS40 — worktree dev: `PORT` do `.env.local` não é honrado pelo `next dev`
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: (OPS40 — a registrar)
|
||||
Priority: P2
|
||||
Model: composer-2.5
|
||||
Impeccable: A — sem UI
|
||||
Appetite: ~2–4h eng; um outcome verificável — dois worktrees em `pnpm dev` simultâneos não colidem na porta
|
||||
|
||||
## Intenção
|
||||
|
||||
O contrato de ambientes paralelos (OPS24/OPS26/OPS28, AGENTS.md "Per-worktree environments") promete um dev-server por worktree em `3100+slot` (ex.: C116 → 3216). O provisionador escreve `PORT=<port>` no `.env.local` do worktree — mas **`next dev` (Next 15.4.11) não honra `PORT` vindo de arquivo `.env`**: o startup log mostra "Local: http://localhost:3000" com `PORT=3216` presente no `.env.local` (evidência colhida na sessão do C116, 2026-08-11). Consequência: dois agentes com `pnpm dev` rodando colidem na porta 3000 (ou um rouba a porta do outro), quebrando o isolamento que o modelo paralelo vende. O e2e escapa do defeito porque o `playwright.config.ts` injeta `PORT` **explicitamente no env do webServer** — o caminho manual (`pnpm dev`) é o exposto.
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- `pnpm dev` num worktree provisionado sobe em `3100+slot` (a porta do `.env.local`), não em 3000.
|
||||
- Dois worktrees com `pnpm dev` simultâneos não colidem (cada um na sua porta).
|
||||
- O e2e (webServer com `PORT` explícito) e o main repo (`pnpm dev` em 3000) ficam intactos.
|
||||
- O mecanismo escolhido não depende de o usuário lembrar `-p <port>`.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- `package.json` script `dev` (`node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev`) — o script pode derivar a porta do `.env.local` e passar `next dev -p <port>` (ou ler `process.env.PORT` — o cross-env repassa env do processo; o problema é o `next dev` ignorar env-file, então a porta precisa ir como **flag CLI**).
|
||||
- Candidatos: (a) wrapper `scripts/dev.mjs` que lê `PORT` do `.env.local` (ou `process.env.PORT` via dotenv) e executa `next dev -p <port>`; (b) trocar `cross-env ... next dev` por `next dev -p "$PORT"` — mas `PORT` do arquivo `.env.local` não está no shell — precisaria de carga explícita; (c) o próprio Next respeitar `PORT` de env-file (upstream — não controlável).
|
||||
- Verificar se o prewarm/setup e2e (que também usa `pnpm dev` como webServer command em dev-mode) não depende do comportamento atual.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Mudar como o Next resolve porta (upstream).
|
||||
- Re-provisionar worktrees existentes (o `.env.local` já tem a porta certa; só o dev server a ignora).
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- Carregar `.env.local` duas vezes (guard + script) com ordens conflitantes — o wrapper deve ler a MESMA fonte que o provisionador escreveu (variável `PORT` do arquivo).
|
||||
- Quebrar o e2e dev-mode: o webServer passa `PORT` no env do processo — se o script novo der prioridade ao `.env.local` sobre o env do processo, o e2e perderia a porta forçada. O wrapper deve dar prioridade ao `process.env.PORT` real (o que o webServer injeta) e cair no `.env.local` só quando ausente.
|
||||
|
||||
## Referências
|
||||
|
||||
- `scripts/lib/worktree-env.mjs` (escreve `PORT=${env.devPort}` no `.env.local`)
|
||||
- `scripts/guard-dev-db.mjs` (preflight do `pnpm dev`)
|
||||
- `playwright.config.ts` webServer (`env: { PORT: webServerPort }` — precedente do env explícito)
|
||||
- Evidência da sessão C116: log `next dev` com "Local: http://localhost:3000" e `.env.local` com `PORT=3216`
|
||||
|
|
@ -102,6 +102,7 @@
|
|||
"cross-env": "^7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"graphql": "^16.8.1",
|
||||
"jose": "^6.2.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.570.0",
|
||||
"next": "15.4.11",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,9 @@ importers:
|
|||
graphql:
|
||||
specifier: ^16.8.1
|
||||
version: 16.12.0
|
||||
jose:
|
||||
specifier: ^6.2.8
|
||||
version: 6.2.8
|
||||
leaflet:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
|
|
@ -5509,8 +5512,8 @@ packages:
|
|||
jose@5.10.0:
|
||||
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||
|
||||
jose@6.2.1:
|
||||
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
|
||||
jose@6.2.8:
|
||||
resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==}
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
|
|
@ -9094,7 +9097,7 @@ snapshots:
|
|||
express: 5.2.1
|
||||
express-rate-limit: 8.3.1(express@5.2.1)
|
||||
hono: 4.12.7
|
||||
jose: 6.2.1
|
||||
jose: 6.2.8
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
|
|
@ -13133,7 +13136,7 @@ snapshots:
|
|||
|
||||
jose@5.10.0: {}
|
||||
|
||||
jose@6.2.1: {}
|
||||
jose@6.2.8: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
prefixes: [
|
||||
'src/components/campaign/shell/ai',
|
||||
'src/lib/sollinhaChatSession',
|
||||
'src/lib/ai/markdownLinks',
|
||||
`${CAMPAIGN_APP}/api/ai-chat`,
|
||||
],
|
||||
specs: [
|
||||
|
|
|
|||
|
|
@ -199,8 +199,12 @@ try {
|
|||
for (const entry of MINIMAL_LEADERSHIPS) {
|
||||
const contact = await upsert(
|
||||
'contact',
|
||||
{ phone: { equals: entry.contactPhone } },
|
||||
{ name: entry.contactName, phone: entry.contactPhone, state: 'BA' },
|
||||
{ 'phones.value': { equals: entry.contactPhone } },
|
||||
{
|
||||
name: entry.contactName,
|
||||
phones: [{ value: entry.contactPhone }],
|
||||
state: 'BA',
|
||||
},
|
||||
`contact ${entry.contactPhone}`,
|
||||
)
|
||||
const consent = consentIdByKey.get(entry.consentKey)
|
||||
|
|
@ -226,8 +230,12 @@ try {
|
|||
for (const entry of MINIMAL_SUPPORTERS) {
|
||||
const contact = await upsert(
|
||||
'contact',
|
||||
{ phone: { equals: entry.contactPhone } },
|
||||
{ name: entry.contactName, phone: entry.contactPhone, state: 'BA' },
|
||||
{ 'phones.value': { equals: entry.contactPhone } },
|
||||
{
|
||||
name: entry.contactName,
|
||||
phones: [{ value: entry.contactPhone }],
|
||||
state: 'BA',
|
||||
},
|
||||
`contact ${entry.contactPhone}`,
|
||||
)
|
||||
const municipalityID = await municipalityIdBySlug(entry.municipalitySlug)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ import {
|
|||
createLeadershipWizard,
|
||||
updateLeadershipWizard,
|
||||
} from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { optionalFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import {
|
||||
optionalFormText,
|
||||
repeatedPhoneFormValues,
|
||||
requiredFormText,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import {
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
LEADERSHIP_INVALID_CONTACT_MESSAGE,
|
||||
|
|
@ -37,7 +42,7 @@ const parseSupportStatus = (formData: FormData) => {
|
|||
|
||||
const wizardFieldsFromForm = (formData: FormData) => ({
|
||||
name: requiredFormText(formData, 'name'),
|
||||
phone: requiredFormText(formData, 'phone'),
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
email: optionalFormText(formData, 'email'),
|
||||
exclusive: parseExclusive(formData),
|
||||
supportStatus: parseSupportStatus(formData),
|
||||
|
|
|
|||
|
|
@ -7,9 +7,15 @@ import {
|
|||
listCalendarFeeds,
|
||||
revokeCalendarFeed,
|
||||
} from '@/app/(campaign)/campanha/actions/calendarFeed'
|
||||
import {
|
||||
getGoogleCalendarSyncState,
|
||||
runGoogleCalendarSyncNow,
|
||||
setGoogleCalendarSyncDisabled,
|
||||
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
|
||||
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
|
||||
import { AgendaFeedChrome } from '@/components/campaign/activity/AgendaFeedChrome'
|
||||
import { AgendaGoogleSyncChrome } from '@/components/campaign/activity/AgendaGoogleSyncChrome'
|
||||
import { AgendaViewChrome } from '@/components/campaign/activity/AgendaViewChrome'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
|
|
@ -41,10 +47,11 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
requireCampaignPageActor({ gate: 'staff' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
const [municipalityOptions, knownTags, feeds] = await Promise.all([
|
||||
const [municipalityOptions, knownTags, feeds, googleSyncState] = await Promise.all([
|
||||
loadMunicipalityOptions(payload, user),
|
||||
loadAccessibleActivityTags(payload, user),
|
||||
listCalendarFeeds().catch(() => []),
|
||||
getGoogleCalendarSyncState(),
|
||||
])
|
||||
const state = restrictActivityAgendaState(
|
||||
resolvedUrl.state,
|
||||
|
|
@ -79,6 +86,18 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
return revokeCalendarFeed(feedId)
|
||||
}}
|
||||
/>
|
||||
{/* C114: pill Google no header (após o link de import) + FAB mobile. */}
|
||||
<AgendaGoogleSyncChrome
|
||||
initialState={googleSyncState}
|
||||
onSyncNow={async () => {
|
||||
'use server'
|
||||
return runGoogleCalendarSyncNow()
|
||||
}}
|
||||
onSetDisabled={async (disabled) => {
|
||||
'use server'
|
||||
return setGoogleCalendarSyncDisabled(disabled)
|
||||
}}
|
||||
/>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<ActivityAgendaFilters
|
||||
|
|
|
|||
|
|
@ -10,9 +10,15 @@ import { redirect } from 'next/navigation'
|
|||
import {
|
||||
removeSupporterData,
|
||||
setSupporterVoteIntention,
|
||||
updateSupporterContact,
|
||||
} from '@/app/(campaign)/campanha/actions/supporter'
|
||||
import { SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys'
|
||||
import { checkboxFormValue, requiredRelationshipFormValue } from '@/lib/formData'
|
||||
import {
|
||||
checkboxFormValue,
|
||||
repeatedPhoneFormValues,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE, BRAZILIAN_PHONE_INVALID_MESSAGE } from '@/lib/phone'
|
||||
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
|
||||
import {
|
||||
SUPPORTER_STAFF_MESSAGE,
|
||||
|
|
@ -21,6 +27,8 @@ import {
|
|||
} from '@/lib/schemas/supporter'
|
||||
import {
|
||||
mapCampaignFormActionError,
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
type CampaignFormErrorState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
|
||||
|
|
@ -41,6 +49,12 @@ const safeVoteIntentionMessages = [
|
|||
SUPPORTER_STAFF_MESSAGE,
|
||||
] as const
|
||||
|
||||
const safeContactMessages = [
|
||||
SUPPORTER_STAFF_MESSAGE,
|
||||
BRAZILIAN_PHONE_INVALID_MESSAGE,
|
||||
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
|
||||
] as const
|
||||
|
||||
const toMessageOnlyState = (mapped: CampaignFormErrorState<unknown>): { message?: string } => {
|
||||
const message = firstFormActionMessage(mapped)
|
||||
return message ? { message } : {}
|
||||
|
|
@ -98,3 +112,20 @@ export const removeSupporterDataFormAction = async (
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const updateSupporterContactFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await updateSupporterContact({
|
||||
id: requiredRelationshipFormValue(formData, 'supporterId'),
|
||||
field: 'phones',
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
})
|
||||
return { message: 'Telefones atualizados.' }
|
||||
},
|
||||
safeMessages: safeContactMessages,
|
||||
genericMessage: 'Não foi possível salvar os telefones.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ import Link from 'next/link'
|
|||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { setSupporterVoteIntentionFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
|
||||
import {
|
||||
setSupporterVoteIntentionFormAction,
|
||||
updateSupporterContactFormAction,
|
||||
} from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
|
||||
import { ConsentText } from '@/components/campaign/shared/ConsentText'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { RemoveSupporterDataButton } from '@/components/campaign/supporter/RemoveSupporterDataButton'
|
||||
import { SupporterShareKit } from '@/components/campaign/supporter/SupporterShareKit'
|
||||
|
|
@ -112,7 +116,15 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
|
|||
<CardTitle className="text-base">Dados de contato</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<p className="tabular-nums">{supporter.phoneDisplay || '—'}</p>
|
||||
<PhonesFieldEditor
|
||||
defaultValues={supporter.phones}
|
||||
label="Celulares"
|
||||
saveAction={updateSupporterContactFormAction}
|
||||
recordId={supporter.id}
|
||||
recordIdField="supporterId"
|
||||
saveLabel="Salvar"
|
||||
cancelLabel="Cancelar"
|
||||
/>
|
||||
{supporter.email ? <p>{supporter.email}</p> : null}
|
||||
<p>
|
||||
{supporter.municipalityName ? (
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@ import {
|
|||
SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE,
|
||||
SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE,
|
||||
} from '@/lib/campaignConsentKeys'
|
||||
import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData'
|
||||
import { sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import {
|
||||
checkboxFormValue,
|
||||
nullableRelationshipFormValue,
|
||||
optionalFormText,
|
||||
repeatedPhoneFormValues,
|
||||
} from '@/lib/formData'
|
||||
import {
|
||||
SUPPORTER_DUPLICATE_MESSAGE,
|
||||
SUPPORTER_STAFF_MESSAGE,
|
||||
|
|
@ -28,7 +32,7 @@ export type SupporterFormState = {
|
|||
|
||||
type SupporterFormValues = {
|
||||
name?: string
|
||||
phone?: string
|
||||
phones?: string[]
|
||||
email?: string
|
||||
city?: string
|
||||
municipality?: string
|
||||
|
|
@ -50,7 +54,7 @@ export const createSupporterFormAction = async (
|
|||
// Parsed before the ladder (non-throwing readers) so failures echo them back.
|
||||
const values: SupporterFormValues = {
|
||||
name: optionalFormText(formData, 'name'),
|
||||
phone: optionalFormText(formData, 'phone'),
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
email: optionalFormText(formData, 'email'),
|
||||
city: optionalFormText(formData, 'city'),
|
||||
municipality: optionalFormText(formData, 'municipality'),
|
||||
|
|
@ -59,13 +63,12 @@ export const createSupporterFormAction = async (
|
|||
|
||||
return runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
|
||||
const voteIntention = values.voteIntention?.trim() || undefined
|
||||
|
||||
const municipality = nullableRelationshipFormValue(formData, 'municipality')
|
||||
const input = supporterCreateSchema.parse({
|
||||
name: values.name ?? '',
|
||||
phone,
|
||||
phones: values.phones ?? [],
|
||||
email: values.email,
|
||||
city: values.city,
|
||||
...(municipality ? { municipality } : {}),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { getPayload } from 'payload'
|
|||
|
||||
import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell'
|
||||
import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
|
|
@ -14,7 +15,11 @@ import { formatBrazilianPhoneInput } from '@/lib/phone'
|
|||
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
|
||||
import { loadAdvisorDetail } from '@/utilities/advisorData'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions'
|
||||
import {
|
||||
sendAdvisorPasswordResetFormAction,
|
||||
updateAdvisorContactFormAction,
|
||||
updateAdvisorProfileFormAction,
|
||||
} from '../formActions'
|
||||
|
||||
export async function generateMetadata({ params }: AdvisorDetailPageProps) {
|
||||
const { id: rawId } = await params
|
||||
|
|
@ -115,6 +120,29 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro
|
|||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="advisor-contact-title"
|
||||
className="flex flex-col gap-2 rounded-xl border p-4"
|
||||
>
|
||||
<h2 id="advisor-contact-title" className="text-base font-medium">
|
||||
Telefones da pessoa
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Todos os números da ficha — o primeiro é o principal (listas, WhatsApp e convites).
|
||||
</p>
|
||||
{advisor.contactID !== null ? (
|
||||
<PhonesFieldEditor
|
||||
defaultValues={advisor.fichaPhones}
|
||||
label="Telefones"
|
||||
saveAction={updateAdvisorContactFormAction}
|
||||
recordId={advisor.contactID}
|
||||
recordIdField="contactId"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Ficha de contato ainda não vinculada.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="advisor-password-title" className="flex flex-col gap-3">
|
||||
<h2 id="advisor-password-title" className="text-base font-medium">
|
||||
Acesso
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import {
|
|||
createAdvisor,
|
||||
sendAdvisorPasswordReset,
|
||||
setAdvisorMunicipalitiesBatch,
|
||||
updateAdvisorContactFicha,
|
||||
updateAdvisorProfile,
|
||||
} from '@/app/(campaign)/campanha/actions/advisor'
|
||||
import {
|
||||
nullableFormText,
|
||||
repeatedPhoneFormValues,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormBoolean,
|
||||
requiredFormText,
|
||||
|
|
@ -88,6 +90,26 @@ export const sendAdvisorPasswordResetFormAction = async (
|
|||
genericMessage: 'Não foi possível enviar o link de redefinição de senha.',
|
||||
})
|
||||
|
||||
/**
|
||||
* C112 — the advisor's ficha (Contact) phone list. The account keeps its own
|
||||
* single channel phone ("Conta"); this editor targets the person's ficha.
|
||||
*/
|
||||
export const updateAdvisorContactFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await updateAdvisorContactFicha({
|
||||
contactId: requiredRelationshipFormValue(formData, 'contactId'),
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
})
|
||||
return { message: 'Telefones atualizados.' }
|
||||
},
|
||||
safeMessages: advisorSafeMessages,
|
||||
genericMessage: 'Não foi possível salvar os telefones.',
|
||||
})
|
||||
|
||||
export const createAdvisorFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { getPayload } from 'payload'
|
|||
|
||||
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
|
||||
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
|
||||
import { primaryPhoneOf } from '@/lib/phone'
|
||||
import {
|
||||
searchActivityResponsibleOptions,
|
||||
type ActivityResponsibleSearchOption,
|
||||
|
|
@ -28,18 +29,22 @@ export const searchActivityContactOptions = async (
|
|||
const result = await payload.find({
|
||||
collection: 'contact',
|
||||
where: {
|
||||
or: [{ name: { contains: trimmed } }, { phone: { contains: digits || trimmed } }],
|
||||
or: [{ name: { contains: trimmed } }, { 'phones.value': { contains: digits || trimmed } }],
|
||||
},
|
||||
depth: 0,
|
||||
limit: CONTACT_OPTION_LIMIT,
|
||||
page: 1,
|
||||
sort: 'name',
|
||||
select: { name: true, phone: true },
|
||||
select: { name: true, phones: { value: true } },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return result.docs.map(({ id, name, phone }) => ({ id, name, phone: phone ?? null }))
|
||||
return result.docs.map(({ id, name, phones }) => ({
|
||||
id,
|
||||
name,
|
||||
phone: primaryPhoneOf(phones),
|
||||
}))
|
||||
}
|
||||
|
||||
export const searchActivityResponsibleOptionsAction = async (
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai
|
|||
stateDeputyId={stateDeputy.id}
|
||||
name={stateDeputy.name}
|
||||
email={stateDeputy.email}
|
||||
phone={stateDeputy.phone}
|
||||
phones={stateDeputy.phones}
|
||||
/>
|
||||
{stateDeputy.notes ? (
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{stateDeputy.notes}</p>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import {
|
||||
nullableFormText,
|
||||
repeatedPhoneFormValues,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormBoolean,
|
||||
requiredFormText,
|
||||
|
|
@ -69,6 +70,12 @@ export const updateStateDeputyContactFormAction = async (
|
|||
field: 'phone',
|
||||
phone: nullableFormText(formData, 'phone'),
|
||||
})
|
||||
} else if (field === 'phones') {
|
||||
await updateStateDeputyContact({
|
||||
id,
|
||||
field: 'phones',
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
})
|
||||
} else {
|
||||
throw new Error(STATE_DEPUTY_INVALID_FIELD_MESSAGE)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP
|
|||
leadershipId={leadership.id}
|
||||
name={leadership.name}
|
||||
email={leadership.email}
|
||||
phone={leadership.phone}
|
||||
phones={leadership.phones}
|
||||
/>
|
||||
{leadership.stateDeputies.length > 0 ? (
|
||||
<StateDeputyChips deputies={leadership.stateDeputies} />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import {
|
||||
nullableFormText,
|
||||
repeatedPhoneFormValues,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormBoolean,
|
||||
requiredFormText,
|
||||
|
|
@ -65,6 +66,12 @@ export const updateLeadershipContactFormAction = async (
|
|||
field: 'phone',
|
||||
phone: nullableFormText(formData, 'phone'),
|
||||
})
|
||||
} else if (field === 'phones') {
|
||||
await updateLeadershipContact({
|
||||
id,
|
||||
field: 'phones',
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
})
|
||||
} else {
|
||||
throw new Error(INVALID_FIELD_MESSAGE)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
|
||||
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
|
||||
import { createLeadership } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData'
|
||||
import {
|
||||
optionalFormText,
|
||||
repeatedPhoneFormValues,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormText,
|
||||
} from '@/lib/formData'
|
||||
import {
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
|
|
@ -33,7 +38,7 @@ export const createLeadershipFormAction = async (
|
|||
|
||||
return createLeadership({
|
||||
name: requiredFormText(formData, 'name'),
|
||||
phone: requiredFormText(formData, 'phone'),
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
email: optionalFormText(formData, 'email'),
|
||||
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
|
||||
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
|
||||
|
|
|
|||
188
src/app/(campaign)/campanha/(app)/pessoas/formActions.ts
Normal file
188
src/app/(campaign)/campanha/(app)/pessoas/formActions.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
'use server'
|
||||
|
||||
import { setLeadershipMunicipalitiesMembership } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import {
|
||||
setPersonAdvisorMembership,
|
||||
setPersonAssessoraMembership,
|
||||
updatePersonContact,
|
||||
} from '@/app/(campaign)/campanha/actions/person'
|
||||
import { setStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import {
|
||||
nullableFormText,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormBoolean,
|
||||
requiredFormText,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import {
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
LEADERSHIP_STAFF_MESSAGE,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import {
|
||||
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
|
||||
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
|
||||
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
|
||||
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
|
||||
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
|
||||
PERSON_CELL_STAFF_MESSAGE,
|
||||
PERSON_CONTACT_INVALID_MESSAGE,
|
||||
} from '@/lib/schemas/personCell'
|
||||
import {
|
||||
STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
} from '@/lib/schemas/stateDeputy'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
|
||||
const personContactSafeMessages = [
|
||||
PERSON_CELL_STAFF_MESSAGE,
|
||||
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
|
||||
PERSON_CONTACT_INVALID_MESSAGE,
|
||||
] as const
|
||||
|
||||
const personAssessoraSafeMessages = [
|
||||
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
|
||||
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
|
||||
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
|
||||
] as const
|
||||
|
||||
const personAssessoradoSafeMessages = [
|
||||
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
|
||||
PERSON_CONTACT_INVALID_MESSAGE,
|
||||
] as const
|
||||
|
||||
const leadershipMunicipalitiesSafeMessages = [
|
||||
LEADERSHIP_STAFF_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
] as const
|
||||
|
||||
/** Per-field Contact edit for the people list cells (C116). */
|
||||
export const updatePersonContactFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const contactId = requiredRelationshipFormValue(formData, 'contactId')
|
||||
const field = requiredFormText(formData, 'field')
|
||||
|
||||
if (field === 'name') {
|
||||
await updatePersonContact({
|
||||
id: contactId,
|
||||
field: 'name',
|
||||
name: requiredFormText(formData, 'name'),
|
||||
})
|
||||
} else if (field === 'email') {
|
||||
await updatePersonContact({
|
||||
id: contactId,
|
||||
field: 'email',
|
||||
email: nullableFormText(formData, 'email') ?? undefined,
|
||||
})
|
||||
} else if (field === 'phone') {
|
||||
await updatePersonContact({
|
||||
id: contactId,
|
||||
field: 'phone',
|
||||
phone: nullableFormText(formData, 'phone'),
|
||||
})
|
||||
} else if (field === 'city') {
|
||||
await updatePersonContact({
|
||||
id: contactId,
|
||||
field: 'city',
|
||||
city: requiredFormText(formData, 'city'),
|
||||
})
|
||||
} else {
|
||||
throw new Error(PERSON_CELL_NOT_IN_SCOPE_MESSAGE)
|
||||
}
|
||||
|
||||
return { message: 'Salvo.' }
|
||||
},
|
||||
safeMessages: personContactSafeMessages,
|
||||
genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
|
||||
})
|
||||
|
||||
/**
|
||||
* Assessora column (C116): one territory / "Salvador (19)" batch or single
|
||||
* municipality toggling the person's staff account on `municipality.advisors`.
|
||||
*/
|
||||
export const setPersonAssessoraFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setPersonAssessoraMembership({
|
||||
contactId: requiredRelationshipFormValue(formData, 'contactId'),
|
||||
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Carteira atualizada.' }
|
||||
},
|
||||
safeMessages: personAssessoraSafeMessages,
|
||||
genericMessage: 'Não foi possível atualizar a carteira. Tente novamente.',
|
||||
})
|
||||
|
||||
/**
|
||||
* Assessorado column (C116): one advisor delta applied to every entity of the
|
||||
* person (leadership and/or dobradinha).
|
||||
*/
|
||||
export const setPersonAssessoradoFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setPersonAdvisorMembership({
|
||||
contactId: requiredRelationshipFormValue(formData, 'contactId'),
|
||||
advisorId: requiredRelationshipFormValue(formData, 'advisorId'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Assessores atualizados.' }
|
||||
},
|
||||
safeMessages: personAssessoradoSafeMessages,
|
||||
genericMessage: 'Não foi possível atualizar os assessores. Tente novamente.',
|
||||
})
|
||||
|
||||
/** Lidera column (C116) — reuses the leadership municipalities write. */
|
||||
export const setPersonLeadershipMunicipalitiesFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setLeadershipMunicipalitiesMembership({
|
||||
leadershipId: requiredRelationshipFormValue(formData, 'ownerId'),
|
||||
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Municípios atualizados.' }
|
||||
},
|
||||
safeMessages: leadershipMunicipalitiesSafeMessages,
|
||||
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
|
||||
})
|
||||
|
||||
/** Aliada em column (C116) — reuses the dobradinha municipalities write. */
|
||||
export const setPersonStateDeputyMunicipalitiesFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
await setStateDeputyMunicipalitiesBatch({
|
||||
stateDeputyId: requiredRelationshipFormValue(formData, 'ownerId'),
|
||||
municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'),
|
||||
assigned: requiredFormBoolean(formData, 'assigned'),
|
||||
})
|
||||
return { message: 'Municípios atualizados.' }
|
||||
},
|
||||
safeMessages: [
|
||||
...STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES,
|
||||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
] as const,
|
||||
genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.',
|
||||
})
|
||||
|
|
@ -6,21 +6,27 @@ import type { ReactNode } from 'react'
|
|||
|
||||
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
|
||||
import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton'
|
||||
import { PeopleAssessoradoCell } from '@/components/campaign/people/PeopleAssessoradoCell'
|
||||
import { PeopleFilters } from '@/components/campaign/people/PeopleFilters'
|
||||
import { PeopleListPageChrome } from '@/components/campaign/people/PeopleListPageChrome'
|
||||
import { PeopleMunicipalityCell } from '@/components/campaign/people/PeopleMunicipalityCell'
|
||||
import { PeopleSortableHead } from '@/components/campaign/people/PeopleSortableHead'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
|
||||
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
CampaignTransitionAnchor,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
|
||||
import {
|
||||
CampaignTable,
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import type { RelationCellOption } from '@/components/campaign/shared/RelationOptionCell'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
|
|
@ -35,14 +41,18 @@ import {
|
|||
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
|
||||
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
|
||||
import {
|
||||
resolvedPortfolioEntriesById,
|
||||
type ResolvedPortfolioEntry,
|
||||
} from '@/lib/municipalityPortfolio'
|
||||
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getAdvisorMunicipalityIds } from '@/utilities/access/municipalities'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadEligibleAdvisorOptions } from '@/utilities/campaignRelationOptions'
|
||||
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
|
||||
import { loadPeopleListPageData, type PeopleRowViewModel } from '@/utilities/people/peopleData'
|
||||
import {
|
||||
|
|
@ -56,6 +66,14 @@ import {
|
|||
type PeopleListState,
|
||||
} from '@/utilities/people/peopleListUrl'
|
||||
|
||||
import {
|
||||
setPersonAssessoraFormAction,
|
||||
setPersonAssessoradoFormAction,
|
||||
setPersonLeadershipMunicipalitiesFormAction,
|
||||
setPersonStateDeputyMunicipalitiesFormAction,
|
||||
updatePersonContactFormAction,
|
||||
} from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('pessoas')
|
||||
|
||||
type PeoplePageProps = {
|
||||
|
|
@ -97,44 +115,55 @@ const PeopleListEmptyState = ({
|
|||
</Empty>
|
||||
)
|
||||
|
||||
const PeopleMunicipalityCell = ({
|
||||
ids,
|
||||
index,
|
||||
}: {
|
||||
ids: readonly number[]
|
||||
index: ReadonlyMap<number, ResolvedPortfolioEntry>
|
||||
}) => {
|
||||
if (!ids.length) return <span className="text-muted-foreground">—</span>
|
||||
const names = ids
|
||||
.map((id) => index.get(id)?.name)
|
||||
.filter((name): name is string => name !== undefined)
|
||||
if (!names.length) return <span className="text-muted-foreground">—</span>
|
||||
const visible = names.slice(0, 2)
|
||||
const rest = names.length - visible.length
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{visible.map((name) => (
|
||||
<Badge key={name} variant="outline" className="max-w-full truncate font-normal">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
{rest > 0 ? (
|
||||
<Badge variant="outline" className="font-normal text-muted-foreground">
|
||||
+{rest}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
/**
|
||||
* C116 editability matrix by actor. The advisor rule mirrors each owner
|
||||
* collection's access: Lidera/Aliada em follow
|
||||
* `canManageLeadership`/`canReadStateDeputy` (the row's own municipalities
|
||||
* must cross the carteira — the row may be visible via another capacity);
|
||||
* Assessora and Assessorado are unrestricted-only
|
||||
* (`canAssignMunicipalityAdvisors` / B156), and Assessora needs exactly ONE
|
||||
* staff account for the batch write to have a target. The ficha text cells
|
||||
* stay always-editable and the server enforces the scope ("you edit what you
|
||||
* see") with a per-cell error.
|
||||
*/
|
||||
type PeopleEditability = {
|
||||
administered: ReadonlySet<number> | null
|
||||
canEditLidera: (row: PeopleRowViewModel) => boolean
|
||||
canEditAliada: (row: PeopleRowViewModel) => boolean
|
||||
canEditAssessora: (row: PeopleRowViewModel) => boolean
|
||||
canEditAssessorado: boolean
|
||||
}
|
||||
|
||||
const buildPeopleEditability = (
|
||||
userRole: CampaignUser['role'],
|
||||
administered: ReadonlySet<number> | null,
|
||||
): PeopleEditability => {
|
||||
const unrestricted = isUnrestrictedCampaignRole(userRole)
|
||||
const inCarteira = (ids: readonly number[]): boolean =>
|
||||
administered !== null && ids.some((id) => administered.has(id))
|
||||
return {
|
||||
administered,
|
||||
canEditLidera: (row) =>
|
||||
row.leadershipID !== null && (unrestricted || inCarteira(row.leadershipMunicipalityIDs)),
|
||||
canEditAliada: (row) =>
|
||||
row.deputyID !== null && (unrestricted || inCarteira(row.deputyMunicipalityIDs)),
|
||||
canEditAssessora: (row) => unrestricted && row.staff.length === 1,
|
||||
canEditAssessorado: unrestricted,
|
||||
}
|
||||
}
|
||||
|
||||
const peopleColumns = ({
|
||||
state,
|
||||
municipalityIndex,
|
||||
canDelete,
|
||||
editability,
|
||||
advisorOptions,
|
||||
}: {
|
||||
state: PeopleListState
|
||||
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
|
||||
municipalityIndex: readonly MunicipalityPortfolioIndexEntry[]
|
||||
canDelete: boolean
|
||||
editability: PeopleEditability
|
||||
advisorOptions: RelationCellOption[]
|
||||
}): Array<CampaignTableColumn<PeopleRowViewModel>> => [
|
||||
{
|
||||
id: 'name',
|
||||
|
|
@ -142,10 +171,21 @@ const peopleColumns = ({
|
|||
mandatory: true,
|
||||
head: <PeopleSortableHead state={state} sortKey="name" />,
|
||||
cell: (row) => (
|
||||
<span className="font-medium">
|
||||
{row.name}
|
||||
{row.party ? <span className="text-muted-foreground"> ({row.party})</span> : null}
|
||||
</span>
|
||||
<div className="flex min-w-0 items-baseline gap-1">
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.contactID}
|
||||
recordIdField="contactId"
|
||||
field="name"
|
||||
value={row.name}
|
||||
label="Nome"
|
||||
formAction={updatePersonContactFormAction}
|
||||
{...(row.leadershipID !== null
|
||||
? { href: `/campanha/liderancas/${row.leadershipID}` }
|
||||
: {})}
|
||||
permanent
|
||||
/>
|
||||
{row.party ? <span className="shrink-0 text-muted-foreground">({row.party})</span> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -153,22 +193,36 @@ const peopleColumns = ({
|
|||
label: 'Contato',
|
||||
cellClassName: 'whitespace-normal',
|
||||
head: <PeopleSortableHead state={state} sortKey="contact" />,
|
||||
cell: (row) =>
|
||||
row.phone ? (
|
||||
<span className="font-medium tabular-nums">{formatBrazilianPhoneInput(row.phone)}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
cell: (row) => (
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.contactID}
|
||||
recordIdField="contactId"
|
||||
field="phone"
|
||||
value={row.phone}
|
||||
label="Contato"
|
||||
formAction={updatePersonContactFormAction}
|
||||
permanent
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
// B197 — the email left the "Contato" cell (which now carries only the
|
||||
// phone) and became its own column, hidden by default. The mobile cards
|
||||
// still show it as the no-phone fallback, untouched. C117 keeps it out of
|
||||
// the sort keys on purpose: no ordering by hidden columns.
|
||||
// still show it as the no-phone fallback, untouched.
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (row) => (row.email ? row.email : <span className="text-muted-foreground">—</span>),
|
||||
cell: (row) => (
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.contactID}
|
||||
recordIdField="contactId"
|
||||
field="email"
|
||||
value={row.email}
|
||||
label="E-mail"
|
||||
formAction={updatePersonContactFormAction}
|
||||
permanent
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'assessora',
|
||||
|
|
@ -176,7 +230,16 @@ const peopleColumns = ({
|
|||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
head: <PeopleSortableHead state={state} sortKey="assessora" />,
|
||||
cell: (row) => (
|
||||
<PeopleMunicipalityCell ids={row.assessoraMunicipalityIDs} index={municipalityIndex} />
|
||||
<PeopleMunicipalityCell
|
||||
ownerId={editability.canEditAssessora(row) ? (row.staff[0]?.id ?? null) : null}
|
||||
ownerName={row.name}
|
||||
municipalityIds={row.assessoraMunicipalityIDs}
|
||||
municipalityIndex={municipalityIndex}
|
||||
commitAction={setPersonAssessoraFormAction}
|
||||
drawerTitle="Municípios assessorados"
|
||||
updateErrorMessage="Não foi possível atualizar a carteira. Tente novamente."
|
||||
readOnly={!editability.canEditAssessora(row)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -185,7 +248,18 @@ const peopleColumns = ({
|
|||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
head: <PeopleSortableHead state={state} sortKey="lidera" />,
|
||||
cell: (row) => (
|
||||
<PeopleMunicipalityCell ids={row.leadershipMunicipalityIDs} index={municipalityIndex} />
|
||||
<PeopleMunicipalityCell
|
||||
ownerId={row.leadershipID}
|
||||
ownerName={row.name}
|
||||
municipalityIds={row.leadershipMunicipalityIDs}
|
||||
municipalityIndex={municipalityIndex}
|
||||
{...(editability.administered ? { addableIds: editability.administered } : {})}
|
||||
minItems={1}
|
||||
commitAction={setPersonLeadershipMunicipalitiesFormAction}
|
||||
drawerTitle="Municípios liderados"
|
||||
updateErrorMessage="Não foi possível atualizar os municípios. Tente novamente."
|
||||
readOnly={!editability.canEditLidera(row)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -194,7 +268,17 @@ const peopleColumns = ({
|
|||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
head: <PeopleSortableHead state={state} sortKey="aliada" />,
|
||||
cell: (row) => (
|
||||
<PeopleMunicipalityCell ids={row.deputyMunicipalityIDs} index={municipalityIndex} />
|
||||
<PeopleMunicipalityCell
|
||||
ownerId={row.deputyID}
|
||||
ownerName={row.name}
|
||||
municipalityIds={row.deputyMunicipalityIDs}
|
||||
municipalityIndex={municipalityIndex}
|
||||
{...(editability.administered ? { addableIds: editability.administered } : {})}
|
||||
commitAction={setPersonStateDeputyMunicipalitiesFormAction}
|
||||
drawerTitle="Municípios da dobradinha"
|
||||
updateErrorMessage="Não foi possível atualizar os municípios. Tente novamente."
|
||||
readOnly={!editability.canEditAliada(row)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -202,13 +286,32 @@ const peopleColumns = ({
|
|||
label: 'Assessorado',
|
||||
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
|
||||
head: <PeopleSortableHead state={state} sortKey="assessorado" />,
|
||||
cell: (row) => row.assessoradoNames.join(', ') || '—',
|
||||
cell: (row) => (
|
||||
<PeopleAssessoradoCell
|
||||
ownerId={row.contactID}
|
||||
ownerName={row.name}
|
||||
assessorados={row.assessorados}
|
||||
options={editability.canEditAssessorado ? advisorOptions : []}
|
||||
commitAction={setPersonAssessoradoFormAction}
|
||||
readOnly={!editability.canEditAssessorado}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'base',
|
||||
label: 'Base',
|
||||
head: <PeopleSortableHead state={state} sortKey="base" />,
|
||||
cell: (row) => row.city ?? '—',
|
||||
cell: (row) => (
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.contactID}
|
||||
recordIdField="contactId"
|
||||
field="city"
|
||||
value={row.city}
|
||||
label="Base"
|
||||
formAction={updatePersonContactFormAction}
|
||||
permanent
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
|
@ -224,23 +327,27 @@ const peopleColumns = ({
|
|||
return (
|
||||
<div className="inline-flex items-center justify-end gap-1">
|
||||
{row.leadershipID !== null ? (
|
||||
<LeadershipInviteRowAction
|
||||
leadershipID={row.leadershipID}
|
||||
name={row.name}
|
||||
hasValidPhone={whatsAppHref !== null}
|
||||
/>
|
||||
<CampaignHoverTooltip content="Convidar">
|
||||
<LeadershipInviteRowAction
|
||||
leadershipID={row.leadershipID}
|
||||
name={row.name}
|
||||
hasValidPhone={whatsAppHref !== null}
|
||||
/>
|
||||
</CampaignHoverTooltip>
|
||||
) : null}
|
||||
{whatsAppHref ? (
|
||||
<Button asChild variant="ghost" size="icon" className="size-10">
|
||||
<a
|
||||
href={whatsAppHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Enviar WhatsApp para ${row.name}`}
|
||||
>
|
||||
<MessageCircleIcon className="size-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
<CampaignHoverTooltip content="WhatsApp">
|
||||
<Button asChild variant="ghost" size="icon" className="size-10">
|
||||
<a
|
||||
href={whatsAppHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Enviar WhatsApp para ${row.name}`}
|
||||
>
|
||||
<MessageCircleIcon className="size-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
</CampaignHoverTooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -254,7 +361,9 @@ const peopleColumns = ({
|
|||
</Button>
|
||||
)}
|
||||
{canDelete ? (
|
||||
<DeletePersonButton personName={row.name} contactId={row.contactID} />
|
||||
<CampaignHoverTooltip content="Apagar">
|
||||
<DeletePersonButton personName={row.name} contactId={row.contactID} />
|
||||
</CampaignHoverTooltip>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -331,7 +440,7 @@ const PeopleMobileCards = ({
|
|||
capacity.ids.length > 0 ? (
|
||||
<div key={capacity.label} className="flex flex-wrap items-center gap-1 text-xs">
|
||||
<span className="font-medium text-muted-foreground">{capacity.label}:</span>
|
||||
<PeopleMunicipalityCell ids={capacity.ids} index={municipalityIndex} />
|
||||
<PeopleMunicipalityCellMobile ids={capacity.ids} index={municipalityIndex} />
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
|
|
@ -350,6 +459,36 @@ const PeopleMobileCards = ({
|
|||
</ul>
|
||||
)
|
||||
|
||||
const PeopleMunicipalityCellMobile = ({
|
||||
ids,
|
||||
index,
|
||||
}: {
|
||||
ids: readonly number[]
|
||||
index: ReadonlyMap<number, ResolvedPortfolioEntry>
|
||||
}) => {
|
||||
if (!ids.length) return <span className="text-muted-foreground">—</span>
|
||||
const names = ids
|
||||
.map((id) => index.get(id)?.name)
|
||||
.filter((name): name is string => name !== undefined)
|
||||
if (!names.length) return <span className="text-muted-foreground">—</span>
|
||||
const visible = names.slice(0, 2)
|
||||
const rest = names.length - visible.length
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{visible.map((name) => (
|
||||
<Badge key={name} variant="outline" className="max-w-full truncate font-normal">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
{rest > 0 ? (
|
||||
<Badge variant="outline" className="font-normal text-muted-foreground">
|
||||
+{rest}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function PeoplePage({ searchParams }: PeoplePageProps) {
|
||||
const rawSearchParams = await searchParams
|
||||
const canonicalUrl = resolvePeopleListUrl(rawSearchParams)
|
||||
|
|
@ -360,11 +499,16 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
|
|||
getPayload({ config }),
|
||||
])
|
||||
|
||||
const [columnVisibility, listData, municipalityIndex] = await Promise.all([
|
||||
const [columnVisibility, listData, municipalityIndex, administeredIds] = await Promise.all([
|
||||
readCampaignColumnVisibility('pessoas'),
|
||||
loadPeopleListPageData(payload, user, canonicalUrl.state),
|
||||
loadMunicipalityPortfolioIndex(),
|
||||
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
|
||||
])
|
||||
const editability = buildPeopleEditability(
|
||||
user.role,
|
||||
administeredIds ? new Set(administeredIds) : null,
|
||||
)
|
||||
const resolvedUrl = resolvePeopleListUrl(rawSearchParams, listData.totalPages)
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
const { state } = resolvedUrl
|
||||
|
|
@ -378,8 +522,25 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
|
|||
.filter((option): option is PeopleFilterOption => option !== null)
|
||||
.sort((left, right) => left.label.localeCompare(right.label, 'pt-BR'))
|
||||
|
||||
const advisorOptions = editability.canEditAssessorado
|
||||
? (await loadEligibleAdvisorOptions(payload, user)).map((option) => ({
|
||||
id: option.id,
|
||||
searchLabel: option.name,
|
||||
item: {
|
||||
id: option.id,
|
||||
label: option.name,
|
||||
href: `/campanha/assessores/${option.id}`,
|
||||
},
|
||||
}))
|
||||
: []
|
||||
const canDelete = isUnrestrictedCampaignRole(user.role)
|
||||
const columns = peopleColumns({ state, municipalityIndex: resolvedIndex, canDelete })
|
||||
const columns = peopleColumns({
|
||||
state,
|
||||
municipalityIndex,
|
||||
canDelete,
|
||||
editability,
|
||||
advisorOptions,
|
||||
})
|
||||
const hasFilters = Boolean(
|
||||
state.q ||
|
||||
state.capacities?.length ||
|
||||
|
|
@ -416,21 +577,25 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
|
|||
/>
|
||||
}
|
||||
/>
|
||||
<CampaignTable
|
||||
className="hidden md:block"
|
||||
caption="Uma linha por pessoa — cada pessoa pode assessorar, liderar e ser dobradinha ao mesmo tempo."
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={listData.rows}
|
||||
rowKey={(row) => row.contactID}
|
||||
empty={
|
||||
<PeopleListEmptyState
|
||||
hasFilters={hasFilters}
|
||||
scoped={user.role === 'advisor'}
|
||||
state={state}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* One shared Drawer for every chip-cell sheet on coarse pointers
|
||||
(miss #52 — never a Drawer root per opened cell). */}
|
||||
<CampaignListSheetProvider>
|
||||
<CampaignTable
|
||||
className="hidden md:block"
|
||||
caption="Uma linha por pessoa — cada pessoa pode assessorar, liderar e ser dobradinha ao mesmo tempo. Edite direto na célula; o nome leva à ficha."
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={listData.rows}
|
||||
rowKey={(row) => row.contactID}
|
||||
empty={
|
||||
<PeopleListEmptyState
|
||||
hasFilters={hasFilters}
|
||||
scoped={user.role === 'advisor'}
|
||||
state={state}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</CampaignListSheetProvider>
|
||||
{listData.rows.length ? (
|
||||
<CampaignListFooter
|
||||
totalDocs={listData.totalDocs}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
type AdvisorPasswordResetInput,
|
||||
type AdvisorProfileUpdateInput,
|
||||
} from '@/lib/schemas/advisor'
|
||||
import { contactPhonesSchema } from '@/lib/schemas/contact'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import {
|
||||
getCampaignActionContext,
|
||||
|
|
@ -163,6 +164,29 @@ export const updateAdvisorProfile = async (input: AdvisorProfileUpdateInput) =>
|
|||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* C112 — write the advisor's ficha (Contact) phone list. The advisor page is
|
||||
* unrestricted-staff gated; the ficha is the person's record, distinct from
|
||||
* the account's single channel phone.
|
||||
*/
|
||||
export const updateAdvisorContactFicha = async (input: { contactId: number; phones: string[] }) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
await reloadUnrestrictedActor(payload, actor, ADVISOR_UNRESTRICTED_MESSAGE)
|
||||
// Same zod gate as the other ficha editors: valid mobiles, no duplicates.
|
||||
const parsed = contactPhonesSchema.parse(input.phones)
|
||||
|
||||
await payload.update({
|
||||
collection: 'contact',
|
||||
id: input.contactId,
|
||||
data: { phones: parsed.map((value) => ({ value })) },
|
||||
depth: 0,
|
||||
// Intentional admin bypass: the unrestricted route gate above established
|
||||
// staff scope; the ficha is the advisor's own normalized Contact.
|
||||
overrideAccess: true,
|
||||
})
|
||||
revalidatePath('/campanha/assessores', 'page')
|
||||
}
|
||||
|
||||
export const setAdvisorMunicipalitiesBatchRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
|
|
|
|||
96
src/app/(campaign)/campanha/actions/googleCalendarSync.ts
Normal file
96
src/app/(campaign)/campanha/actions/googleCalendarSync.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
'use server'
|
||||
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { buildGoogleCalendarAddLink } from '@/lib/googleCalendarLink'
|
||||
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
|
||||
import {
|
||||
loadGoogleCalendarSyncConfig,
|
||||
readGoogleCalendarSyncView,
|
||||
runCampaignCalendarSync,
|
||||
type GoogleCalendarSyncView,
|
||||
} from '@/utilities/googleCalendarSync'
|
||||
|
||||
const GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE =
|
||||
'Apenas a equipe da campanha pode gerenciar a sincronização com o Google.'
|
||||
const GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE =
|
||||
'Não foi possível atualizar o estado da sincronização com o Google.'
|
||||
|
||||
export type GoogleCalendarSyncActionResult = {
|
||||
ok: boolean
|
||||
message?: string
|
||||
} & GoogleCalendarSyncView & { addLink: string | null }
|
||||
|
||||
const withLinks = (view: GoogleCalendarSyncView): Omit<GoogleCalendarSyncActionResult, 'ok'> => ({
|
||||
...view,
|
||||
addLink: view.calendarId ? buildGoogleCalendarAddLink(view.calendarId) : null,
|
||||
})
|
||||
|
||||
const failure = (message: string): GoogleCalendarSyncActionResult => ({
|
||||
ok: false,
|
||||
message,
|
||||
status: 'not-configured',
|
||||
calendarId: null,
|
||||
lastSyncedAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastErrorAt: null,
|
||||
lastError: null,
|
||||
addLink: null,
|
||||
})
|
||||
|
||||
const requireStaff = async (payload: Payload, actor: CampaignUser): Promise<CampaignUser> =>
|
||||
reloadStaffActor(payload, actor, GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE)
|
||||
|
||||
/** Read model for the agenda pill/dialog — status derived, never stored. */
|
||||
export const getGoogleCalendarSyncState = async (): Promise<GoogleCalendarSyncActionResult> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
try {
|
||||
await requireStaff(payload, actor)
|
||||
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
|
||||
} catch (error) {
|
||||
console.error('getGoogleCalendarSyncState failed', error)
|
||||
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
/** Manual retry / auto-retry (agenda page view when paused). */
|
||||
export const runGoogleCalendarSyncNow = async (): Promise<GoogleCalendarSyncActionResult> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
try {
|
||||
await requireStaff(payload, actor)
|
||||
await runCampaignCalendarSync(payload, { reason: 'manual' })
|
||||
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
|
||||
} catch (error) {
|
||||
console.error('runGoogleCalendarSyncNow failed', error)
|
||||
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
/** Staff pauses/resumes the mirror — the afterChange hook reconciles on resume. */
|
||||
export const setGoogleCalendarSyncDisabled = async (
|
||||
disabled: boolean,
|
||||
): Promise<GoogleCalendarSyncActionResult> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
try {
|
||||
const currentActor = await requireStaff(payload, actor)
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
if (doc) {
|
||||
await payload.update({
|
||||
collection: 'googleCalendarSync',
|
||||
id: doc.id,
|
||||
data: { disabledAt: disabled ? new Date().toISOString() : null },
|
||||
depth: 0,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
}
|
||||
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
|
||||
} catch (error) {
|
||||
console.error('setGoogleCalendarSyncDisabled failed', error)
|
||||
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,12 @@ import { revalidatePath } from 'next/cache'
|
|||
import type { Payload } from 'payload'
|
||||
|
||||
import { SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys'
|
||||
import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData'
|
||||
import { sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import {
|
||||
checkboxFormValue,
|
||||
nullableRelationshipFormValue,
|
||||
optionalFormText,
|
||||
repeatedPhoneFormValues,
|
||||
} from '@/lib/formData'
|
||||
import {
|
||||
LEADER_SUPPORTER_ONLY_MESSAGE,
|
||||
leaderSupporterCreateSchema,
|
||||
|
|
@ -32,7 +36,7 @@ export type LeaderSupporterFormState = {
|
|||
|
||||
type LeaderSupporterFormValues = {
|
||||
name?: string
|
||||
phone?: string
|
||||
phones?: string[]
|
||||
city?: string
|
||||
municipality?: string
|
||||
}
|
||||
|
|
@ -89,7 +93,7 @@ export const createLeaderSupporterRecord = async (
|
|||
const { contactID, reused } = await findOrCreateContactByPhone({
|
||||
payload,
|
||||
req,
|
||||
phone: data.phone,
|
||||
phones: data.phones,
|
||||
name: data.name,
|
||||
city: data.city,
|
||||
})
|
||||
|
|
@ -133,18 +137,17 @@ export const createLeaderSupporterFormAction = async (
|
|||
): Promise<LeaderSupporterFormState> => {
|
||||
const values: LeaderSupporterFormValues = {
|
||||
name: optionalFormText(formData, 'name'),
|
||||
phone: optionalFormText(formData, 'phone'),
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
city: optionalFormText(formData, 'city'),
|
||||
municipality: optionalFormText(formData, 'municipality'),
|
||||
}
|
||||
|
||||
return runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
|
||||
const municipality = nullableRelationshipFormValue(formData, 'municipality')
|
||||
const input = leaderSupporterCreateSchema.parse({
|
||||
name: values.name ?? '',
|
||||
phone,
|
||||
phones: values.phones ?? [],
|
||||
city: values.city,
|
||||
municipality,
|
||||
consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { Payload } from 'payload'
|
|||
import { nextLeadershipAdvisorIdsAfterMembership } from '@/lib/leadershipAdvisorMembership'
|
||||
import { nextMunicipalityIdsAfterLeadershipMembership } from '@/lib/leadershipMunicipalityMembership'
|
||||
import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership'
|
||||
import { reorderWithPrimaryPhone } from '@/lib/phone'
|
||||
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import {
|
||||
LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE,
|
||||
|
|
@ -81,8 +82,8 @@ const isUniqueLeadershipConflict = (error: unknown): boolean => {
|
|||
}
|
||||
|
||||
type LeadershipCreateData = ReturnType<typeof leadershipCreateSchema.parse>
|
||||
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phone'> & {
|
||||
phone?: string | null
|
||||
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phones'> & {
|
||||
phones?: string[]
|
||||
}
|
||||
|
||||
const createValidatedLeadershipRecord = async (
|
||||
|
|
@ -118,7 +119,7 @@ const createValidatedLeadershipRecord = async (
|
|||
const { contactID, reused } = await findOrCreateContactByPhone({
|
||||
payload,
|
||||
req,
|
||||
phone: data.phone ?? null,
|
||||
phones: data.phones ?? [],
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
city: city ?? undefined,
|
||||
|
|
@ -254,18 +255,25 @@ export const updateLeadershipContactRecord = async (
|
|||
throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE)
|
||||
}
|
||||
|
||||
const contactData: Partial<Pick<Contact, 'name' | 'phone' | 'email'>> = {}
|
||||
const contactData: Partial<Pick<Contact, 'name' | 'email'>> & {
|
||||
phones?: { value: string }[]
|
||||
} = {}
|
||||
|
||||
if (data.field === 'name') {
|
||||
contactData.name = data.name
|
||||
} else if (data.field === 'email') {
|
||||
contactData.email = data.email ?? null
|
||||
} else if (data.field === 'phone') {
|
||||
if (data.phone) {
|
||||
contactData.phone = data.phone
|
||||
} else {
|
||||
contactData.phone = null
|
||||
}
|
||||
// Inline cell edit = set the PRIMARY phone, keeping the rest of the
|
||||
// list untouched: the new number goes first (an earlier occurrence
|
||||
// moves, never duplicates) and clearing removes the primary.
|
||||
const contact = current.contact
|
||||
contactData.phones = reorderWithPrimaryPhone(
|
||||
typeof contact === 'object' && contact !== null ? contact.phones : null,
|
||||
data.phone,
|
||||
).map((value) => ({ value }))
|
||||
} else if (data.field === 'phones') {
|
||||
contactData.phones = data.phones.map((value) => ({ value }))
|
||||
}
|
||||
|
||||
// bypass: contact write is staff-scoped via leadership access check above.
|
||||
|
|
@ -336,7 +344,7 @@ const updateLeadershipWizardRecord = async (
|
|||
id: contactID,
|
||||
data: {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
phones: data.phones.map((value) => ({ value })),
|
||||
email: data.email,
|
||||
},
|
||||
depth: 0,
|
||||
|
|
@ -370,7 +378,7 @@ const createLeadershipWizardRecord = async (
|
|||
const data = leadershipWizardCreateSchema.parse(input)
|
||||
return createValidatedLeadershipRecord(payload, actor, {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
phones: data.phones,
|
||||
email: data.email,
|
||||
municipalities: [data.municipalityId],
|
||||
exclusive: data.exclusive,
|
||||
|
|
@ -415,7 +423,7 @@ export const createMunicipalityLeadershipRecord = async (
|
|||
const data = municipalityLeadershipCreateSchema.parse(input)
|
||||
const leadership = await createValidatedLeadershipRecord(payload, actor, {
|
||||
name: data.name,
|
||||
phone: null,
|
||||
phones: [],
|
||||
municipalities: [data.municipalityId],
|
||||
email: undefined,
|
||||
exclusive: true,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import {
|
|||
type MunicipalityStrategyUpdateInput,
|
||||
} from '@/lib/schemas/municipality'
|
||||
import { normalizeVoteEstimateOnSave, toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
import {
|
||||
getCampaignActionContext,
|
||||
reloadStaffActor,
|
||||
|
|
@ -290,6 +290,52 @@ export const assignMunicipalityAdvisors = async (input: MunicipalityAdvisorsAssi
|
|||
* revalidate — the list's facet/sort by advisor reconciles on next navigation,
|
||||
* same choice as the votos estimados popover.
|
||||
*/
|
||||
/**
|
||||
* One municipality↔advisor toggle under the caller's transaction: advisory
|
||||
* lock, read-modify-write. Returns the updated document, or `null` for a no-op
|
||||
* (the delta does not change the list). The unrestricted-role gate is the
|
||||
* CALLER's job — `setMunicipalityAdvisorMembershipRecord` asserts it for a
|
||||
* single toggle, and the C116 person-cell action asserts it once for a whole
|
||||
* batch. Reads/writes run under a justified bypass: the `advisors` field is
|
||||
* admin-only by field access, and the `validateMunicipalityAdvisors`
|
||||
* beforeValidate hook still runs under the bypass and re-checks eligibility.
|
||||
*/
|
||||
export const toggleMunicipalityAdvisorMembership = async (
|
||||
payload: Payload,
|
||||
req: PayloadTransactionRequest,
|
||||
municipalityId: number,
|
||||
advisorId: number,
|
||||
assigned: boolean,
|
||||
): Promise<Municipality | null> => {
|
||||
await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipalityId}`])
|
||||
|
||||
// Intentional bypass: the unrestricted-role gate is the caller's job (see
|
||||
// the doc above); the `advisors` field is admin-only by field access, and
|
||||
// the `validateMunicipalityAdvisors` beforeValidate hook still runs.
|
||||
const current = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
depth: 0,
|
||||
select: { advisors: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
const currentAdvisorIDs = uniqueRelationshipIds(current.advisors)
|
||||
const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisorId, assigned)
|
||||
if (nextAdvisorIDs === null) return null
|
||||
|
||||
// Same intentional bypass as the read above: the gate ran in the caller.
|
||||
return payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipalityId,
|
||||
data: { advisors: nextAdvisorIDs },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
export const setMunicipalityAdvisorMembershipRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
|
|
@ -307,9 +353,17 @@ export const setMunicipalityAdvisorMembershipRecord = async (
|
|||
req,
|
||||
)
|
||||
|
||||
await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipality}`])
|
||||
const toggled = await toggleMunicipalityAdvisorMembership(
|
||||
payload,
|
||||
req,
|
||||
municipality,
|
||||
advisor,
|
||||
assigned,
|
||||
)
|
||||
if (toggled !== null) return toggled
|
||||
|
||||
const current = await payload.findByID({
|
||||
// No-op: nothing to write — the caller still expects the current doc.
|
||||
return payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: municipality,
|
||||
depth: 0,
|
||||
|
|
@ -317,22 +371,6 @@ export const setMunicipalityAdvisorMembershipRecord = async (
|
|||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
const currentAdvisorIDs = uniqueRelationshipIds(current.advisors)
|
||||
const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisor, assigned)
|
||||
if (nextAdvisorIDs === null) return current
|
||||
|
||||
// Intentional admin bypass: unrestricted role was freshly verified above;
|
||||
// the advisors field is admin-only by field access, and eligibility is
|
||||
// reconfirmed by the `validateMunicipalityAdvisors` beforeValidate hook.
|
||||
return payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipality,
|
||||
data: { advisors: nextAdvisorIDs },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a atualização de assessores.' },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,42 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
import { toggleMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/actions/municipality'
|
||||
import { nextLeadershipAdvisorIdsAfterMembership } from '@/lib/leadershipAdvisorMembership'
|
||||
import { reorderWithPrimaryPhone } from '@/lib/phone'
|
||||
import { uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
|
||||
import {
|
||||
PERSON_ADVISORS_UNRESTRICTED_MESSAGE,
|
||||
PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE,
|
||||
PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE,
|
||||
PERSON_ASSESSORA_UNRESTRICTED_MESSAGE,
|
||||
PERSON_CELL_NOT_IN_SCOPE_MESSAGE,
|
||||
PERSON_CELL_STAFF_MESSAGE,
|
||||
PERSON_CONTACT_INVALID_MESSAGE,
|
||||
personAdvisorMembershipSchema,
|
||||
personAssessoraMembershipSchema,
|
||||
type PersonAdvisorMembershipInput,
|
||||
type PersonAssessoraMembershipInput,
|
||||
} from '@/lib/schemas/personCell'
|
||||
import {
|
||||
PERSON_DELETE_FORBIDDEN_MESSAGE,
|
||||
personDeleteInputSchema,
|
||||
} from '@/lib/schemas/personDelete'
|
||||
import { nextStateDeputyAdvisorIdsAfterMembership } from '@/lib/stateDeputyAdvisorMembership'
|
||||
import type { CampaignUser, Contact } from '@/payload-types'
|
||||
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import {
|
||||
getCampaignActionContext,
|
||||
reloadStaffActor,
|
||||
reloadUnrestrictedActor,
|
||||
} from '@/utilities/campaignActionContext'
|
||||
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { deletePersonRecord, loadPersonDeleteManifest } from '@/utilities/people/personDelete'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
/**
|
||||
* C100 "Apagar pessoa" server actions. Both are coordinator/candidate-only
|
||||
|
|
@ -34,3 +62,273 @@ export const deletePersonAction = async (input: unknown) => {
|
|||
)
|
||||
return deletePersonRecord(payload, currentActor, data.contactId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C116 — "edit where you see": per-cell writes of the people list table.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* C116 — the cell-edit scope rule for a person's ficha fields: unrestricted
|
||||
* actors always pass; an advisor passes only when at least ONE entity of the
|
||||
* person (leadership or dobradinha) is readable with his own access — the "you
|
||||
* edit what you see" rule, mirroring the row's visibility (a staff-only person
|
||||
* is not entity-manageable and therefore not editable by an advisor).
|
||||
*/
|
||||
const assertPersonContactEditable = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
contactID: number,
|
||||
req: { transactionID: number | string },
|
||||
): Promise<void> => {
|
||||
if (isCampaignUnrestricted(actor)) return
|
||||
|
||||
const [leaderships, deputies] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: { contact: { equals: contactID } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
req,
|
||||
}),
|
||||
])
|
||||
|
||||
if (leaderships.docs.length === 0 && deputies.docs.length === 0) {
|
||||
throw new Error(PERSON_CELL_NOT_IN_SCOPE_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
export const updatePersonContactRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: ContactFieldUpdateInput,
|
||||
) => {
|
||||
const data = contactFieldUpdateSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await reloadStaffActor(payload, actor, PERSON_CELL_STAFF_MESSAGE, req)
|
||||
|
||||
await assertPersonContactEditable(payload, currentActor, data.id, req)
|
||||
|
||||
const contactData: Partial<Pick<Contact, 'name' | 'email' | 'city'>> & {
|
||||
phones?: { value: string }[]
|
||||
} = {}
|
||||
if (data.field === 'name') {
|
||||
contactData.name = data.name
|
||||
} else if (data.field === 'email') {
|
||||
contactData.email = data.email ?? null
|
||||
} else if (data.field === 'phone') {
|
||||
// Inline cell edit = set the PRIMARY phone, keeping the rest of the
|
||||
// list untouched (C112 shape — same contract as the leadership and
|
||||
// dobradinha siblings): the new number goes first, clearing removes
|
||||
// the primary and the rest shifts up.
|
||||
const current = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: data.id,
|
||||
depth: 0,
|
||||
select: { phones: true },
|
||||
// Intentional bypass: the scope check above established the actor's
|
||||
// right over the person's ficha; this read only resolves the current
|
||||
// primary-phone list to reorder.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
contactData.phones = reorderWithPrimaryPhone(current.phones, data.phone).map((value) => ({
|
||||
value,
|
||||
}))
|
||||
} else if (data.field === 'city') {
|
||||
contactData.city = data.city
|
||||
}
|
||||
|
||||
// Bypass: the scope check above established the actor's right over the
|
||||
// person's ficha (same precedent as the leadership/dobradinha cell edits).
|
||||
await payload.update({
|
||||
collection: 'contact',
|
||||
id: data.id,
|
||||
data: contactData,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a edição da pessoa.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const updatePersonContact = async (input: ContactFieldUpdateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
await updatePersonContactRecord(payload, actor, input)
|
||||
revalidatePath('/campanha/pessoas', 'page')
|
||||
revalidatePath('/campanha/liderancas', 'page')
|
||||
revalidatePath('/campanha/dobradinhas', 'page')
|
||||
}
|
||||
|
||||
/**
|
||||
* C116 — the Assessora column of a person: toggles the person's staff account
|
||||
* on `municipality.advisors` for a batch of municipalities (one territory / ZE
|
||||
* chip or a "Salvador (19)" group), in ONE transaction. Unrestricted-only
|
||||
* (`canAssignMunicipalityAdvisors`), and the person must have EXACTLY ONE
|
||||
* staff account — with more, "which account" is ambiguous, so the server
|
||||
* refuses with a safe message (the cell renders read-only for those rows).
|
||||
*/
|
||||
export const setPersonAssessoraMembershipRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: PersonAssessoraMembershipInput,
|
||||
) => {
|
||||
const { contactId, municipalityIds, assigned } = personAssessoraMembershipSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
await reloadUnrestrictedActor(payload, actor, PERSON_ASSESSORA_UNRESTRICTED_MESSAGE, req)
|
||||
|
||||
const accounts = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { contact: { equals: contactId } },
|
||||
depth: 0,
|
||||
limit: 2,
|
||||
pagination: false,
|
||||
// Intentional bypass: the unrestricted gate above is the authorization;
|
||||
// `contact` is identity-gated and the accounts are only counted.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
if (accounts.docs.length === 0) throw new Error(PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE)
|
||||
if (accounts.docs.length > 1) throw new Error(PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE)
|
||||
const accountId = accounts.docs[0].id
|
||||
|
||||
for (const municipalityId of municipalityIds) {
|
||||
await toggleMunicipalityAdvisorMembership(payload, req, municipalityId, accountId, assigned)
|
||||
}
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a atualização da carteira.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const setPersonAssessoraMembership = async (input: PersonAssessoraMembershipInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
await setPersonAssessoraMembershipRecord(payload, actor, input)
|
||||
revalidatePath('/campanha/pessoas', 'page')
|
||||
revalidatePath('/campanha/assessores', 'page')
|
||||
revalidateMunicipalityListPaths({ scope: 'list' })
|
||||
}
|
||||
|
||||
/**
|
||||
* C116 — the Assessorado column: the advisor delta is applied to EVERY entity
|
||||
* of the person (leadership and/or dobradinha) in one transaction — the column
|
||||
* is a person attribute, so adding/removing an advisor touches both vínculos
|
||||
* when both exist. Unrestricted-only (B156 rule: advisor assignment is
|
||||
* coordinator/candidate).
|
||||
*/
|
||||
export const setPersonAdvisorMembershipRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: PersonAdvisorMembershipInput,
|
||||
) => {
|
||||
const { contactId, advisorId, assigned } = personAdvisorMembershipSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
await reloadUnrestrictedActor(payload, actor, PERSON_ADVISORS_UNRESTRICTED_MESSAGE, req)
|
||||
|
||||
const [leaderships, deputies] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'leadership',
|
||||
where: { contact: { equals: contactId } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: { id: true, advisors: true },
|
||||
// Intentional bypass: the unrestricted gate above is the
|
||||
// authorization; the `advisors` field is admin-only by field access
|
||||
// (same contract as the C99/B156 per-entity cells).
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: { contact: { equals: contactId } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: { id: true, advisors: true },
|
||||
// Intentional bypass: same gate as the leadership read above.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
])
|
||||
|
||||
const touched: number[] = []
|
||||
for (const leadership of leaderships.docs) {
|
||||
await acquireTextAdvisoryLocks(payload, req, [`leadership-advisors:${leadership.id}`])
|
||||
const currentAdvisorIDs = uniqueRelationshipIds(leadership.advisors)
|
||||
const nextAdvisorIDs = nextLeadershipAdvisorIdsAfterMembership(
|
||||
currentAdvisorIDs,
|
||||
advisorId,
|
||||
assigned,
|
||||
)
|
||||
if (nextAdvisorIDs === null) continue
|
||||
// Intentional bypass: the unrestricted gate above is the authorization;
|
||||
// the `advisors` field is admin-only by field access (C99 contract).
|
||||
await payload.update({
|
||||
collection: 'leadership',
|
||||
id: leadership.id,
|
||||
data: { advisors: nextAdvisorIDs },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
touched.push(leadership.id)
|
||||
}
|
||||
|
||||
for (const deputy of deputies.docs) {
|
||||
await acquireTextAdvisoryLocks(payload, req, [`state-deputy-advisors:${deputy.id}`])
|
||||
const currentAdvisorIDs = uniqueRelationshipIds(deputy.advisors)
|
||||
const nextAdvisorIDs = nextStateDeputyAdvisorIdsAfterMembership(
|
||||
currentAdvisorIDs,
|
||||
advisorId,
|
||||
assigned,
|
||||
)
|
||||
if (nextAdvisorIDs === null) continue
|
||||
// Intentional bypass: same gate as the leadership write above.
|
||||
await payload.update({
|
||||
collection: 'stateDeputy',
|
||||
id: deputy.id,
|
||||
data: { advisors: nextAdvisorIDs },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
touched.push(deputy.id)
|
||||
}
|
||||
|
||||
if (touched.length === 0) {
|
||||
throw new Error(PERSON_CONTACT_INVALID_MESSAGE)
|
||||
}
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível iniciar a atualização dos assessores.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const setPersonAdvisorMembership = async (input: PersonAdvisorMembershipInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
await setPersonAdvisorMembershipRecord(payload, actor, input)
|
||||
revalidatePath('/campanha/pessoas', 'page')
|
||||
revalidatePath('/campanha/liderancas', 'page')
|
||||
revalidatePath('/campanha/dobradinhas', 'page')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
|
|||
import type { Payload } from 'payload'
|
||||
|
||||
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
|
||||
import { reorderWithPrimaryPhone } from '@/lib/phone'
|
||||
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
|
||||
import {
|
||||
|
|
@ -69,7 +70,7 @@ const createStateDeputyWithContact = async (
|
|||
data: {
|
||||
name: data.name,
|
||||
email: null,
|
||||
phone: null,
|
||||
phones: [],
|
||||
state: 'BA' as Contact['state'],
|
||||
city: null,
|
||||
},
|
||||
|
|
@ -165,7 +166,7 @@ export const updateStateDeputyContactRecord = async (
|
|||
const current = await payload.findByID({
|
||||
collection: 'stateDeputy',
|
||||
id: data.id,
|
||||
depth: 0,
|
||||
depth: 1,
|
||||
select: { contact: true },
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
|
|
@ -175,18 +176,24 @@ export const updateStateDeputyContactRecord = async (
|
|||
const contactID = relationshipId(current.contact)
|
||||
if (contactID === null) throw new Error(STATE_DEPUTY_INVALID_CONTACT_MESSAGE)
|
||||
|
||||
const contactData: Partial<Pick<Contact, 'name' | 'phone' | 'email'>> = {}
|
||||
const contactData: Partial<Pick<Contact, 'name' | 'email'>> & {
|
||||
phones?: { value: string }[]
|
||||
} = {}
|
||||
if (data.field === 'name') {
|
||||
await assertStateDeputyNameAvailable(payload, req, data.name, data.id)
|
||||
contactData.name = data.name
|
||||
} else if (data.field === 'email') {
|
||||
contactData.email = data.email ?? null
|
||||
} else if (data.field === 'phone') {
|
||||
if (data.phone) {
|
||||
contactData.phone = data.phone
|
||||
} else {
|
||||
contactData.phone = null
|
||||
}
|
||||
// Inline cell edit = set the PRIMARY phone, keeping the rest of the
|
||||
// list untouched (see the leadership sibling).
|
||||
const contact = current.contact
|
||||
contactData.phones = reorderWithPrimaryPhone(
|
||||
typeof contact === 'object' && contact !== null ? contact.phones : null,
|
||||
data.phone,
|
||||
).map((value) => ({ value }))
|
||||
} else if (data.field === 'phones') {
|
||||
contactData.phones = data.phones.map((value) => ({ value }))
|
||||
}
|
||||
|
||||
// Bypass: the StateDeputy row access above established staff scope.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { requireRelationshipId } from '@/lib/relationship'
|
||||
import { relationshipId, requireRelationshipId } from '@/lib/relationship'
|
||||
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
|
||||
import {
|
||||
SUPPORTER_DUPLICATE_MESSAGE,
|
||||
SUPPORTER_STAFF_MESSAGE,
|
||||
|
|
@ -37,6 +39,8 @@ const getFreshStaffActor = (
|
|||
req?: PayloadTransactionRequest,
|
||||
): Promise<CampaignUser> => reloadStaffActor(payload, actor, SUPPORTER_STAFF_MESSAGE, req)
|
||||
|
||||
const INVALID_FIELD_MESSAGE = 'Campo inválido.'
|
||||
|
||||
const assertMunicipalityManagement = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
|
|
@ -92,7 +96,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i
|
|||
const { contactID, reused } = await findOrCreateContactByPhone({
|
||||
payload,
|
||||
req,
|
||||
phone: data.phone,
|
||||
phones: data.phones,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
city: data.city,
|
||||
|
|
@ -183,6 +187,54 @@ export const setSupporterVoteIntention = async (input: unknown) => {
|
|||
return setSupporterVoteIntentionRecord(payload, actor, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* C112 — the supporter ficha's phone list editor. Staff only (same supporter
|
||||
* access gate as every supporter write); only the `phones` branch of the
|
||||
* shared per-field schema is honored.
|
||||
*/
|
||||
export const updateSupporterContactRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: ContactFieldUpdateInput,
|
||||
) => {
|
||||
const data = contactFieldUpdateSchema.parse(input)
|
||||
|
||||
return withPayloadTransaction(
|
||||
payload,
|
||||
async ({ req }) => {
|
||||
const currentActor = await getFreshStaffActor(payload, actor, req)
|
||||
const supporter = await assertCanManageSupporter(payload, currentActor, data.id, req)
|
||||
|
||||
const contactID = relationshipId(supporter.contact)
|
||||
if (contactID === null) throw new Error(SUPPORTER_STAFF_MESSAGE)
|
||||
|
||||
if (data.field !== 'phones') {
|
||||
throw new Error(INVALID_FIELD_MESSAGE)
|
||||
}
|
||||
|
||||
// bypass: the supporter row access above established staff scope.
|
||||
await payload.update({
|
||||
collection: 'contact',
|
||||
id: contactID,
|
||||
data: { phones: data.phones.map((value) => ({ value })) },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
return supporter
|
||||
},
|
||||
{ beginFailureMessage: 'Não foi possível atualizar o contato do apoiador.' },
|
||||
)
|
||||
}
|
||||
|
||||
export const updateSupporterContact = async (input: ContactFieldUpdateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const supporter = await updateSupporterContactRecord(payload, actor, input)
|
||||
revalidatePath(`/campanha/apoiadores/${supporter.id}`, 'page')
|
||||
return supporter
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV import pipeline — implementation lives in ./supporterImport. A top-level
|
||||
// `'use server'` module cannot re-export values (only async functions defined
|
||||
|
|
@ -275,7 +327,9 @@ const anonymizeContact = async (
|
|||
data: {
|
||||
name: 'Titular removido',
|
||||
email: null,
|
||||
phone: tombstonePhone,
|
||||
// The tombstone REPLACES every number — the remaining phones are the
|
||||
// person's PII and must not survive the anonymization (C112).
|
||||
phones: [{ value: tombstonePhone }],
|
||||
gender: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
|
|
|
|||
|
|
@ -224,28 +224,35 @@ export const previewSupporterImportText = async (
|
|||
if (candidatePhones.length > 0) {
|
||||
const contacts = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { in: candidatePhones } },
|
||||
where: { 'phones.value': { in: candidatePhones } },
|
||||
depth: 0,
|
||||
// No LIMIT: a phone can now legitimately match many fichas (C111), so a
|
||||
// finite limit would silently miss a shared phone past the cutoff and
|
||||
// the preview would promise rows the confirm then aborts.
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { phone: true },
|
||||
select: { phones: { value: true } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
// C111 — the phone is not unique: a CSV phone resolving to two or more
|
||||
// fichas cannot be matched to a person, so every row with that phone is
|
||||
// flagged for manual resolution (never a silent "last one wins").
|
||||
// flagged for manual resolution (never a silent "last one wins"). Keyed by
|
||||
// the CSV phone ITSELF, not the ficha's primary: the JOIN matched any
|
||||
// position, so a number carried as secondary by two fichas must be shared
|
||||
// here — exactly what the confirm's fail-closed check will abort on.
|
||||
const candidatePhoneSet = new Set(candidatePhones)
|
||||
const contactByPhone = new Map<string, number>()
|
||||
const sharedPhones = new Set<string>()
|
||||
for (const doc of contacts.docs) {
|
||||
if (!doc.phone) continue
|
||||
if (contactByPhone.has(doc.phone)) {
|
||||
sharedPhones.add(doc.phone)
|
||||
continue
|
||||
for (const entry of doc.phones ?? []) {
|
||||
const phone = entry.value
|
||||
if (!phone || !candidatePhoneSet.has(phone)) continue
|
||||
if (contactByPhone.has(phone)) {
|
||||
sharedPhones.add(phone)
|
||||
continue
|
||||
}
|
||||
contactByPhone.set(phone, doc.id)
|
||||
}
|
||||
contactByPhone.set(doc.phone, doc.id)
|
||||
}
|
||||
const contactIDs = [...contactByPhone.values()]
|
||||
|
||||
|
|
|
|||
|
|
@ -37,9 +37,15 @@ export const submitPetitionSignature = async ({
|
|||
}
|
||||
|
||||
try {
|
||||
const { phone, ...contactFields } = data
|
||||
const contact = await payload.create({
|
||||
collection: 'contact',
|
||||
data,
|
||||
data: {
|
||||
...contactFields,
|
||||
// The public forms keep a single phone input; the ficha stores the
|
||||
// phones array with that number as primary (C112).
|
||||
phones: phone ? [{ value: phone }] : [],
|
||||
},
|
||||
req: { transactionID },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -28,9 +28,15 @@ export const submitWhatsapp = async (input: WhatsAppFormInput) => {
|
|||
}
|
||||
|
||||
try {
|
||||
const { phone, ...contactFields } = contactInput
|
||||
const contact = await payload.create({
|
||||
collection: 'contact',
|
||||
data: contactInput,
|
||||
data: {
|
||||
...contactFields,
|
||||
// The public forms keep a single phone input; the ficha stores the
|
||||
// phones array with that number as primary (C112).
|
||||
phones: phone ? [{ value: phone }] : [],
|
||||
},
|
||||
req: { transactionID },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ body,
|
|||
--color-support-to-approach: var(--support-to-approach);
|
||||
--color-support-disputed-foreground: var(--support-disputed-foreground);
|
||||
--color-support-disputed: var(--support-disputed);
|
||||
--color-support-remembered-foreground: var(--support-remembered-foreground);
|
||||
--color-support-remembered: var(--support-remembered);
|
||||
--color-support-negative-foreground: var(--support-negative-foreground);
|
||||
--color-support-negative: var(--support-negative);
|
||||
--color-estimate-confirmed-foreground: var(--estimate-confirmed-foreground);
|
||||
|
|
@ -249,6 +251,8 @@ body,
|
|||
--support-to-approach-foreground: #46505c;
|
||||
--support-disputed: #fef3c7;
|
||||
--support-disputed-foreground: #92400e;
|
||||
--support-remembered: #e0e7ff;
|
||||
--support-remembered-foreground: #3730a3;
|
||||
--support-negative: #fee2e2;
|
||||
--support-negative-foreground: #991b1b;
|
||||
--estimate-confirmed: #dcfce7;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
eligibleCampaignStaffWhere,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
|
||||
import { activityGoogleCalendarSyncHook } from '@/utilities/googleCalendarSyncHooks'
|
||||
|
||||
const isActivityMutationShortcut = (context: Record<string, unknown> | undefined) =>
|
||||
context?.mutationKind === 'taskToggle' || context?.mutationKind === 'appendUpdate'
|
||||
|
|
@ -404,7 +405,11 @@ export const Activity: CollectionConfig = {
|
|||
await notifyActivityNeedsAttention(req, doc)
|
||||
return doc
|
||||
},
|
||||
// C114: pushes the official calendar mirror to Google (best-effort —
|
||||
// never throws into the write path; failures land in `paused`).
|
||||
activityGoogleCalendarSyncHook,
|
||||
],
|
||||
afterDelete: [activityGoogleCalendarSyncHook],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ const resolveContactForAccount = async ({
|
|||
const { contactID } = await findOrCreateContactByPhone({
|
||||
payload,
|
||||
req,
|
||||
phone: typeof phone === 'string' && phone.length > 0 ? phone : null,
|
||||
phones: typeof phone === 'string' && phone.length > 0 ? [phone] : [],
|
||||
name,
|
||||
email: email && !isPlanilhaPlaceholderEmail(email) ? email : null,
|
||||
})
|
||||
|
|
@ -205,7 +205,11 @@ const ensureCampaignUserContactIdentity: CollectionBeforeChangeHook<CampaignUser
|
|||
const linkedContactID = relationshipId(originalDoc?.contact)
|
||||
|
||||
if (linkedContactID !== null) {
|
||||
const contactData: { name?: string; email?: string | null; phone?: string } = {}
|
||||
const contactData: {
|
||||
name?: string
|
||||
email?: string | null
|
||||
phones?: { value: string }[]
|
||||
} = {}
|
||||
|
||||
if (data.name !== undefined && data.name !== originalDoc?.name) {
|
||||
contactData.name = data.name
|
||||
|
|
@ -216,14 +220,32 @@ const ensureCampaignUserContactIdentity: CollectionBeforeChangeHook<CampaignUser
|
|||
}
|
||||
}
|
||||
// Clearing the account's phone never clears the ficha's: the ficha's
|
||||
// phone is the person's dedupe key across every join (supporter,
|
||||
// phones are the person's dedupe keys across every join (supporter,
|
||||
// leadership, dobradinha), and the account edit is not a ficha edit.
|
||||
// A changed account phone is APPENDED when the ficha lacks it (never
|
||||
// reordering the mesa's priority, never removing the previous number);
|
||||
// creation still seeds it as the primary via find-or-create.
|
||||
if (
|
||||
typeof data.phone === 'string' &&
|
||||
data.phone.length > 0 &&
|
||||
data.phone !== originalDoc?.phone
|
||||
) {
|
||||
contactData.phone = data.phone
|
||||
const contact = await req.payload.findByID({
|
||||
collection: 'contact',
|
||||
id: linkedContactID,
|
||||
depth: 0,
|
||||
select: { phones: { value: true } },
|
||||
// Intentional admin bypass: same account-scoped policy as the ficha
|
||||
// write below — the read only derives the append target.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const existingPhones = (contact.phones ?? [])
|
||||
.map((entry) => entry.value)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
if (!existingPhones.includes(data.phone)) {
|
||||
contactData.phones = [...existingPhones, data.phone].map((value) => ({ value }))
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(contactData).length === 0) return data
|
||||
|
|
|
|||
|
|
@ -1,11 +1,63 @@
|
|||
import { CitiesByState } from '@/lib/cities'
|
||||
import { BRAZILIAN_PHONE_INVALID_MESSAGE, normalizeBrazilianPhone } from '@/lib/phone'
|
||||
import {
|
||||
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
|
||||
BRAZILIAN_PHONE_INVALID_MESSAGE,
|
||||
normalizeBrazilianPhone,
|
||||
} from '@/lib/phone'
|
||||
import { trimmedText } from '@/lib/text'
|
||||
import { canManageContacts, canReadContacts } from '@/utilities/campaignAccess'
|
||||
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
|
||||
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
type ContactPhoneValue = { value: string }
|
||||
|
||||
/**
|
||||
* Normalizes the ficha's phone list: each entry is a valid Brazilian mobile
|
||||
* (11 digits, `^[1-9]{2}9\d{8}$`), empty entries are dropped, and the same
|
||||
* number cannot be stored twice in the same ficha (the person already has it;
|
||||
* sharing a number BETWEEN fichas stays allowed — C111). `undefined` (admin
|
||||
* untouched) passes through; `null`/`''` collapse to an empty list.
|
||||
*/
|
||||
const normalizeContactPhones = (data: unknown): ContactPhoneValue[] => {
|
||||
const phones = Array.isArray(data) ? data : []
|
||||
const seen = new Set<string>()
|
||||
const normalized: ContactPhoneValue[] = []
|
||||
|
||||
for (const entry of phones) {
|
||||
if (entry === null || entry === undefined) continue
|
||||
const raw =
|
||||
typeof entry === 'object' && 'value' in (entry as Record<string, unknown>)
|
||||
? (entry as Record<string, unknown>).value
|
||||
: entry
|
||||
|
||||
if (raw === null || raw === undefined || raw === '') continue
|
||||
|
||||
const input = String(raw)
|
||||
let value: string | null
|
||||
if (/^\d{11}$/.test(input)) {
|
||||
if (/^[1-9]{2}9\d{8}$/.test(input)) {
|
||||
value = input
|
||||
} else {
|
||||
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
|
||||
}
|
||||
} else {
|
||||
value = normalizeBrazilianPhone(input)
|
||||
if (!value) {
|
||||
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
throw new APIError(BRAZILIAN_PHONE_DUPLICATE_MESSAGE, 400)
|
||||
}
|
||||
seen.add(value)
|
||||
normalized.push({ value })
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
const enforceStateDeputyName: CollectionBeforeChangeHook = async ({
|
||||
data,
|
||||
operation,
|
||||
|
|
@ -57,27 +109,14 @@ export const Contact: CollectionConfig = {
|
|||
hooks: {
|
||||
beforeValidate: [
|
||||
({ data }) => {
|
||||
if (!data || data.phone === undefined) return data
|
||||
if (!data || data.phones === undefined) return data
|
||||
|
||||
if (data.phone === null || data.phone === '') {
|
||||
data.phone = null
|
||||
if (data.phones === null || data.phones === '') {
|
||||
data.phones = []
|
||||
return data
|
||||
}
|
||||
|
||||
const input = String(data.phone)
|
||||
if (/^\d{11}$/.test(input)) {
|
||||
if (!/^[1-9]{2}9\d{8}$/.test(input)) {
|
||||
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const phone = normalizeBrazilianPhone(input)
|
||||
if (!phone) {
|
||||
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
|
||||
}
|
||||
|
||||
data.phone = phone
|
||||
data.phones = normalizeContactPhones(data.phones)
|
||||
return data
|
||||
},
|
||||
],
|
||||
|
|
@ -99,15 +138,22 @@ export const Contact: CollectionConfig = {
|
|||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
type: 'text',
|
||||
label: 'Celular',
|
||||
minLength: 11,
|
||||
maxLength: 11,
|
||||
name: 'phones',
|
||||
type: 'array',
|
||||
label: 'Telefones',
|
||||
// Optional at the collection level so name-only records (e.g. leaderships imported
|
||||
// from the projection sheet) can exist; UI flows still require it via zod schemas.
|
||||
required: false,
|
||||
index: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'text',
|
||||
label: 'Celular',
|
||||
minLength: 11,
|
||||
maxLength: 11,
|
||||
index: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'gender',
|
||||
|
|
|
|||
111
src/collections/GoogleCalendarSync.ts
Normal file
111
src/collections/GoogleCalendarSync.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import {
|
||||
canCreateGoogleCalendarSync,
|
||||
canDeleteGoogleCalendarSync,
|
||||
canReadGoogleCalendarSync,
|
||||
canSetGoogleCalendarSyncConfigField,
|
||||
canSetGoogleCalendarSyncDisabled,
|
||||
canSetGoogleCalendarSyncSystemField,
|
||||
canUpdateGoogleCalendarSync,
|
||||
} from '@/utilities/access/googleCalendarSync'
|
||||
import { googleCalendarSyncConfigHook } from '@/utilities/googleCalendarSyncHooks'
|
||||
|
||||
/**
|
||||
* C114 — configuration and state of the campaign→Google calendar mirror
|
||||
* (one row in practice, created/edited by Payload admin in ops). The service
|
||||
* account private key never lives here — it is `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`
|
||||
* in the environment. Changing `calendarId` (or re-enabling) re-runs the full
|
||||
* reconciliation automatically via `googleCalendarSyncConfigHook` (D7).
|
||||
*/
|
||||
export const GoogleCalendarSync: CollectionConfig = {
|
||||
slug: 'googleCalendarSync',
|
||||
labels: {
|
||||
singular: 'Sincronização Google Agenda',
|
||||
plural: 'Sincronização Google Agenda',
|
||||
},
|
||||
admin: {
|
||||
group: 'Campanha',
|
||||
useAsTitle: 'calendarId',
|
||||
defaultColumns: ['calendarId', 'lastSyncedAt', 'lastSuccessAt', 'lastError'],
|
||||
},
|
||||
access: {
|
||||
create: canCreateGoogleCalendarSync,
|
||||
read: canReadGoogleCalendarSync,
|
||||
update: canUpdateGoogleCalendarSync,
|
||||
delete: canDeleteGoogleCalendarSync,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [googleCalendarSyncConfigHook],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'calendarId',
|
||||
type: 'text',
|
||||
label: 'ID do calendário (Google)',
|
||||
index: true,
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncConfigField,
|
||||
update: canSetGoogleCalendarSyncConfigField,
|
||||
},
|
||||
admin: {
|
||||
description:
|
||||
'O calendarId do calendário "Agenda da Campanha" na conta Google da campanha (ex.: c_…@group.calendar.google.com). O calendário precisa estar público no link e a service account com permissão de edição.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'disabledAt',
|
||||
type: 'date',
|
||||
label: 'Desativado em',
|
||||
index: true,
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncDisabled,
|
||||
update: canSetGoogleCalendarSyncDisabled,
|
||||
},
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Enquanto preenchido, o Teqo não escreve no Google.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastSyncedAt',
|
||||
type: 'date',
|
||||
label: 'Última sincronização',
|
||||
admin: { readOnly: true },
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastSuccessAt',
|
||||
type: 'date',
|
||||
label: 'Última sincronização com sucesso',
|
||||
admin: { readOnly: true },
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastErrorAt',
|
||||
type: 'date',
|
||||
label: 'Último erro',
|
||||
admin: { readOnly: true },
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastError',
|
||||
type: 'textarea',
|
||||
label: 'Mensagem do último erro',
|
||||
admin: { readOnly: true },
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -146,6 +146,7 @@ export const Leadership: CollectionConfig = {
|
|||
{ label: 'Engajado', value: 'engajado' },
|
||||
{ label: 'A abordar', value: 'a_abordar' },
|
||||
{ label: 'Em disputa', value: 'em_disputa' },
|
||||
{ label: 'Lembrança', value: 'lembranca' },
|
||||
{ label: 'Negativo', value: 'negativo' },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
121
src/components/campaign/activity/AgendaGoogleSyncChrome.tsx
Normal file
121
src/components/campaign/activity/AgendaGoogleSyncChrome.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog'
|
||||
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import type { GoogleCalendarSyncStatus } from '@/utilities/googleCalendarSync'
|
||||
|
||||
type AgendaGoogleSyncChromeProps = {
|
||||
initialState: GoogleCalendarSyncActionResult
|
||||
onSyncNow: () => Promise<GoogleCalendarSyncActionResult>
|
||||
onSetDisabled: (disabled: boolean) => Promise<GoogleCalendarSyncActionResult>
|
||||
}
|
||||
|
||||
const PILL_COPY: Record<
|
||||
GoogleCalendarSyncStatus,
|
||||
{ label: string; dot: string; className: string }
|
||||
> = {
|
||||
synced: {
|
||||
label: 'Google: sincronizado',
|
||||
dot: 'bg-green-500',
|
||||
className: 'text-green-700',
|
||||
},
|
||||
paused: {
|
||||
label: 'Google: pausado — re-tentando',
|
||||
dot: 'bg-amber-500',
|
||||
className: 'text-amber-700',
|
||||
},
|
||||
'not-configured': {
|
||||
label: 'Google: não configurado',
|
||||
dot: 'bg-muted-foreground',
|
||||
className: 'text-muted-foreground',
|
||||
},
|
||||
disabled: {
|
||||
label: 'Google: desativado',
|
||||
dot: 'bg-muted-foreground',
|
||||
className: 'text-muted-foreground',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* C114 — agenda page → app chrome bridge for the Google Calendar mirror
|
||||
* (same pattern as AgendaFeedChrome/C94): the status pill registers in the
|
||||
* desktop header cluster (after "Link de import"), the mobile FAB opens the
|
||||
* same dialog, and a paused mirror auto-retries once on mount.
|
||||
*/
|
||||
export const AgendaGoogleSyncChrome = ({
|
||||
initialState,
|
||||
onSyncNow,
|
||||
onSetDisabled,
|
||||
}: AgendaGoogleSyncChromeProps) => {
|
||||
const { setContext } = useCampaignQuickActionContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [state, setState] = useState(initialState)
|
||||
const autoRetriedRef = useRef(false)
|
||||
|
||||
const openSync = useCallback(() => setOpen(true), [])
|
||||
|
||||
useEffect(() => {
|
||||
setContext((current) => ({ ...current, openGoogleCalendarSync: openSync }))
|
||||
return () => {
|
||||
setContext((current) => ({ ...current, openGoogleCalendarSync: undefined }))
|
||||
}
|
||||
}, [setContext, openSync])
|
||||
|
||||
// "Re-tenta sem ação manual": a paused mirror tries once when the agenda
|
||||
// page loads (the staff is operating — the natural retry moment).
|
||||
const handleSyncNow = useCallback(async () => {
|
||||
const result = await onSyncNow()
|
||||
if (result.ok) setState(result)
|
||||
return result
|
||||
}, [onSyncNow])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'paused' && !autoRetriedRef.current) {
|
||||
autoRetriedRef.current = true
|
||||
void handleSyncNow()
|
||||
}
|
||||
}, [state.status, handleSyncNow])
|
||||
|
||||
const handleSetDisabled = useCallback(
|
||||
async (disabled: boolean) => {
|
||||
const result = await onSetDisabled(disabled)
|
||||
if (result.ok) setState(result)
|
||||
return result
|
||||
},
|
||||
[onSetDisabled],
|
||||
)
|
||||
|
||||
const pill = useMemo(() => {
|
||||
const copy = PILL_COPY[state.status]
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`hidden shrink-0 items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring md:inline-flex ${copy.className}`}
|
||||
aria-label={copy.label}
|
||||
title={copy.label}
|
||||
onClick={openSync}
|
||||
>
|
||||
<span className={`size-1.5 rounded-full ${copy.dot}`} aria-hidden />
|
||||
{copy.label}
|
||||
</button>
|
||||
)
|
||||
}, [state.status, openSync])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetCampaignHeaderAction id="google-calendar-sync">{pill}</SetCampaignHeaderAction>
|
||||
|
||||
<GoogleCalendarSyncDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
state={state}
|
||||
onSyncNow={handleSyncNow}
|
||||
onSetDisabled={handleSetDisabled}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
291
src/components/campaign/activity/GoogleCalendarSyncDialog.tsx
Normal file
291
src/components/campaign/activity/GoogleCalendarSyncDialog.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { CopyIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react'
|
||||
|
||||
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/Drawer'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
|
||||
type GoogleCalendarSyncDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
state: GoogleCalendarSyncActionResult
|
||||
onSyncNow: () => Promise<GoogleCalendarSyncActionResult>
|
||||
onSetDisabled: (disabled: boolean) => Promise<GoogleCalendarSyncActionResult>
|
||||
}
|
||||
|
||||
const NOT_CONFIGURED_RUNBOOK: readonly string[] = [
|
||||
'Crie o calendário "Agenda da Campanha" na conta Google da campanha.',
|
||||
'No Google, compartilhe o calendário como público (qualquer pessoa com o link pode ver).',
|
||||
'Dê permissão de edição ("fazer alterações em eventos") à service account do Teqo.',
|
||||
'Um administrador configura o ID do calendário no Painel e a chave da service account nas variáveis de ambiente.',
|
||||
]
|
||||
|
||||
const copyIcon = <CopyIcon className="h-4 w-4" />
|
||||
|
||||
/**
|
||||
* C114 — Google Calendar mirror dialog (estados da jornada do canvas:
|
||||
* não configurado / sincronizado / pausado / desativado). Bottom sheet on
|
||||
* mobile, dialog on desktop — same chrome as the iCal feed dialog (C94).
|
||||
*/
|
||||
export const GoogleCalendarSyncDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
state,
|
||||
onSyncNow,
|
||||
onSetDisabled,
|
||||
}: GoogleCalendarSyncDialogProps) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
const resetTransientState = () => {
|
||||
setCopied(false)
|
||||
setActionError(null)
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!state.addLink) return
|
||||
await navigator.clipboard.writeText(state.addLink)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleSyncNow = async () => {
|
||||
if (isBusy) return
|
||||
setIsBusy(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
const result = await onSyncNow()
|
||||
if (!result.ok) setActionError(result.message ?? 'Não foi possível sincronizar.')
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetDisabled = async (disabled: boolean) => {
|
||||
if (isBusy) return
|
||||
setIsBusy(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
const result = await onSetDisabled(disabled)
|
||||
if (!result.ok) setActionError(result.message ?? 'Não foi possível atualizar.')
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const linkBlock = state.addLink ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="google-calendar-link">
|
||||
Link do calendário (copie e envie à equipe)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="google-calendar-link"
|
||||
value={state.addLink}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copiar link"
|
||||
>
|
||||
{copyIcon}
|
||||
</Button>
|
||||
</div>
|
||||
{copied && (
|
||||
<p className="text-xs text-green-600" aria-live="polite">
|
||||
Link copiado!
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Quem segue o calendário recebe aviso conforme as próprias configurações do Google.
|
||||
</p>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const instructions = (
|
||||
<div className="rounded-lg bg-muted p-4 text-sm">
|
||||
<p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p>
|
||||
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
|
||||
<li>Abra o Google Calendar no computador</li>
|
||||
<li>
|
||||
No menu lateral, clique em {'"'}
|
||||
{'+'}
|
||||
{'"'} ao lado de {'"'}Outras agendas{'"'}
|
||||
</li>
|
||||
<li>
|
||||
Selecione {'"'}Por URL{'"'}
|
||||
</li>
|
||||
<li>
|
||||
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
|
||||
</li>
|
||||
</ol>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Apple Calendar e Outlook assinam a URL iCal pública do mesmo calendário.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const syncButton = (
|
||||
<Button type="button" onClick={handleSyncNow} disabled={isBusy}>
|
||||
<RefreshCwIcon className={`mr-2 h-4 w-4 ${isBusy ? 'animate-spin' : ''}`} />
|
||||
{isBusy ? 'Sincronizando...' : 'Sincronizar agora'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
let body: React.ReactNode
|
||||
|
||||
if (state.status === 'not-configured') {
|
||||
body = (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
|
||||
<p className="mb-2 font-medium text-amber-900">Ainda não configurado</p>
|
||||
<p className="text-amber-800">
|
||||
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o
|
||||
espelho, um administrador precisa concluir a configuração:
|
||||
</p>
|
||||
<ol className="mt-2 list-inside list-decimal space-y-1 text-amber-800">
|
||||
{NOT_CONFIGURED_RUNBOOK.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else if (state.status === 'disabled') {
|
||||
body = (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">
|
||||
A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é
|
||||
enviado ao Google enquanto estiver desativada.
|
||||
</div>
|
||||
{actionError && <p className="text-sm text-red-600">{actionError}</p>}
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => void handleSetDisabled(false)} disabled={isBusy}>
|
||||
Reativar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
const paused = state.status === 'paused'
|
||||
body = (
|
||||
<div className="space-y-4">
|
||||
{paused ? (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
|
||||
<p className="mb-1 font-medium text-amber-900">Pausado — re-tentando</p>
|
||||
<p className="text-amber-800">
|
||||
A última tentativa de sincronização falhou. O Teqo continua funcionando; o espelho é
|
||||
tentado de novo automaticamente ao salvar uma atividade ou ao abrir esta página.
|
||||
</p>
|
||||
{state.lastErrorAt ? (
|
||||
<p className="mt-2 text-xs text-amber-700">
|
||||
Última tentativa: {formatBahiaDateTimeLabel(state.lastErrorAt)} —{' '}
|
||||
{state.lastError ?? 'erro desconhecido'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg bg-green-50 p-3 text-sm text-green-800">
|
||||
Sincronizado — as mudanças da agenda já refletiram no Google.
|
||||
{state.lastSuccessAt
|
||||
? ` Última sincronização: ${formatBahiaDateTimeLabel(state.lastSuccessAt)}.`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{linkBlock}
|
||||
{instructions}
|
||||
|
||||
{actionError && <p className="text-sm text-red-600">{actionError}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{paused ? (
|
||||
<Button asChild variant="outline">
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="mr-2 h-4 w-4" />
|
||||
Abrir Google Calendar
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{syncButton}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleSetDisabled(true)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Desativar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const description =
|
||||
'Compromissos do Teqo refletem no calendário Google compartilhado em minutos. Quem segue recebe aviso conforme as próprias configurações.'
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) resetTransientState()
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange} showSwipeHandle>
|
||||
<DrawerContent className="max-h-[85vh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>Agenda da Campanha no Google</DrawerTitle>
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto px-4 pb-2">{body}</div>
|
||||
<DrawerFooter className="border-t">
|
||||
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Agenda da Campanha no Google</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,10 +8,10 @@ import {
|
|||
type LeaderSupporterFormState,
|
||||
} from '@/app/(campaign)/campanha/actions/leaderSupporter'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
|
||||
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
|
||||
import { FormattedInput } from '@/components/FormattedInput'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
|
|
@ -26,7 +26,6 @@ import {
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
|
||||
import { municipalityComboboxOptions } from '@/utilities/territory/territoryComboboxOptions'
|
||||
|
||||
|
|
@ -55,7 +54,6 @@ export const LeaderContactForm = ({
|
|||
|
||||
useCampaignFormSuccessToast(state)
|
||||
|
||||
const phoneField = errorProps(state.fieldErrors, 'phone')
|
||||
const nameField = errorProps(state.fieldErrors, 'name')
|
||||
const cityField = errorProps(state.fieldErrors, 'city')
|
||||
const municipalityField = errorProps(state.fieldErrors, 'municipality')
|
||||
|
|
@ -88,33 +86,19 @@ export const LeaderContactForm = ({
|
|||
) : null}
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={phoneField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-phone">Celular *</FieldLabel>
|
||||
<FormattedInput
|
||||
id="leader-contact-phone"
|
||||
name="phone"
|
||||
required
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
autoFocus
|
||||
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
|
||||
format={formatBrazilianPhoneInput}
|
||||
sanitize={sanitizeBrazilianPhoneInput}
|
||||
aria-invalid={phoneField.invalid}
|
||||
aria-describedby={phoneField.describedBy}
|
||||
className="min-h-11 rounded-[6px]"
|
||||
/>
|
||||
{phoneField.error ? (
|
||||
<FieldError id="leader-contact-phone-error">{phoneField.error}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<PhonesFieldEditor
|
||||
name="phones"
|
||||
minRows={1}
|
||||
label="Celulares (o primeiro é o principal) *"
|
||||
defaultValues={values?.phones ?? []}
|
||||
error={state.fieldErrors?.phones}
|
||||
/>
|
||||
|
||||
<Field data-invalid={nameField.invalid}>
|
||||
<FieldLabel htmlFor="leader-contact-name">Nome *</FieldLabel>
|
||||
<Input
|
||||
id="leader-contact-name"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
defaultValue={values?.name}
|
||||
autoComplete="name"
|
||||
|
|
@ -148,7 +132,6 @@ export const LeaderContactForm = ({
|
|||
<NativeSelect
|
||||
id="leader-contact-municipality"
|
||||
name="municipality"
|
||||
required
|
||||
defaultValue={values?.municipality ?? ''}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
|
||||
>
|
||||
|
|
@ -193,7 +176,6 @@ export const LeaderContactForm = ({
|
|||
id="leader-contact-consent"
|
||||
name="consentAccepted"
|
||||
value="true"
|
||||
required
|
||||
aria-invalid={Boolean(consentError)}
|
||||
aria-describedby={consentError ? 'leader-contact-consent-error' : undefined}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,19 +2,21 @@
|
|||
|
||||
import { updateLeadershipContactFormAction } from '@/app/(campaign)/campanha/(app)/liderancas/formActions'
|
||||
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
|
||||
type LeadershipContactSectionProps = {
|
||||
leadershipId: number
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
/** Every number of the ficha, order = priority (C112) — primary first. */
|
||||
phones: string[]
|
||||
}
|
||||
|
||||
export const LeadershipContactSection = ({
|
||||
leadershipId,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
phones,
|
||||
}: LeadershipContactSectionProps) => (
|
||||
<section aria-labelledby="leadership-contact-title" className="flex flex-col gap-3">
|
||||
<h2 id="leadership-contact-title" className="text-base font-medium">
|
||||
|
|
@ -50,16 +52,16 @@ export const LeadershipContactSection = ({
|
|||
</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-sm text-muted-foreground">Celular</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
Celulares <span className="font-normal">(o primeiro é o principal)</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<CampaignInlineEditableCell
|
||||
<PhonesFieldEditor
|
||||
defaultValues={phones}
|
||||
label="Celulares"
|
||||
saveAction={updateLeadershipContactFormAction}
|
||||
recordId={leadershipId}
|
||||
recordIdField="leadershipId"
|
||||
field="phone"
|
||||
value={phone}
|
||||
label="Celular"
|
||||
formAction={updateLeadershipContactFormAction}
|
||||
readBehavior="copy"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useActionState } from 'react'
|
||||
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import {
|
||||
RelationMultiSelect,
|
||||
type RelationOption,
|
||||
|
|
@ -46,7 +47,6 @@ export const LeadershipForm = ({
|
|||
<Input
|
||||
id="leadership-name"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={120}
|
||||
className="min-h-11"
|
||||
|
|
@ -56,20 +56,12 @@ export const LeadershipForm = ({
|
|||
) : null}
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leadership-phone">Celular (com DDD)</FieldLabel>
|
||||
<Input
|
||||
id="leadership-phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
required
|
||||
autoComplete="tel-national"
|
||||
className="min-h-11"
|
||||
/>
|
||||
{fieldError(state.fieldErrors, 'phone') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'phone')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<PhonesFieldEditor
|
||||
name="phones"
|
||||
minRows={1}
|
||||
label="Celulares (o primeiro é o principal)"
|
||||
error={fieldError(state.fieldErrors, 'phones')}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leadership-email">E-mail (opcional)</FieldLabel>
|
||||
<Input id="leadership-email" name="email" type="email" className="min-h-11" />
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const statusPresentation = {
|
|||
engajado: { variant: 'support-engaged' },
|
||||
a_abordar: { variant: 'support-to-approach' },
|
||||
em_disputa: { variant: 'support-disputed' },
|
||||
lembranca: { variant: 'support-remembered' },
|
||||
negativo: { variant: 'support-negative' },
|
||||
} as const
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
updateLeadershipWizardFormAction,
|
||||
} from '@/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
|
|
@ -66,7 +67,6 @@ export const WizardLeadershipForm = ({
|
|||
ref={nameRef}
|
||||
id="wizard-leadership-name"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={120}
|
||||
defaultValue={leadership?.name ?? ''}
|
||||
|
|
@ -78,21 +78,13 @@ export const WizardLeadershipForm = ({
|
|||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="wizard-leadership-phone">Celular</FieldLabel>
|
||||
<Input
|
||||
id="wizard-leadership-phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
required
|
||||
autoComplete="tel-national"
|
||||
defaultValue={leadership?.phone ?? ''}
|
||||
className="min-h-11"
|
||||
/>
|
||||
{fieldError(state.fieldErrors, 'phone') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'phone')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<PhonesFieldEditor
|
||||
name="phones"
|
||||
minRows={1}
|
||||
label="Celulares (o primeiro é o principal)"
|
||||
defaultValues={leadership?.phones ?? []}
|
||||
error={fieldError(state.fieldErrors, 'phones')}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="wizard-leadership-email">E-mail</FieldLabel>
|
||||
<Input
|
||||
|
|
|
|||
89
src/components/campaign/people/PeopleAssessoradoCell.tsx
Normal file
89
src/components/campaign/people/PeopleAssessoradoCell.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { RelationChipCellCopy } from '@/components/campaign/shared/RelationChipCell'
|
||||
import {
|
||||
RelationOptionCell,
|
||||
type RelationCellOption,
|
||||
} from '@/components/campaign/shared/RelationOptionCell'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
const ASSESSORADO_COPY: RelationChipCellCopy = {
|
||||
searchPlaceholder: 'Buscar assessor…',
|
||||
searchLabel: 'Buscar assessor',
|
||||
suggestionsLabel: 'Sugestões de assessores',
|
||||
emptyDrawerMessage: 'Nenhum assessor vinculado.',
|
||||
savingMessage: 'Salvando assessores…',
|
||||
savedMessage: 'Assessores salvos.',
|
||||
removedMessage: (count: number) =>
|
||||
count === 1 ? 'Assessor removido.' : `${count} assessores removidos.`,
|
||||
}
|
||||
|
||||
type PeopleAssessoradoCellProps = {
|
||||
/** The person (`contactID`) — the write is person-centric, not per entity. */
|
||||
ownerId: number | null
|
||||
ownerName: string
|
||||
/** Assigned advisors, names resolved server-side (union of the person's entities). */
|
||||
assessorados: Array<{ id: number; name: string }>
|
||||
/** The addable staff catalog (`loadEligibleAdvisorOptions`). Empty when read-only. */
|
||||
options: RelationCellOption[]
|
||||
commitAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
/**
|
||||
* Read-only for staff who may see but not assign (B156 rule): linked chips
|
||||
* lose their href — `/campanha/assessores/[id]` is unrestricted-only.
|
||||
*/
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* C116 — the "Assessorado" column of `/campanha/pessoas`: the union of the
|
||||
* person's advisor links (leadership + dobradinha), edited person-centrically —
|
||||
* the commit writes the advisor delta on EVERY entity the person has, in one
|
||||
* transaction. Same `RelationOptionCell` machine as the C99/B156 advisor cells,
|
||||
* in the `quiet` transparent form.
|
||||
*/
|
||||
export const PeopleAssessoradoCell = ({
|
||||
ownerId,
|
||||
ownerName,
|
||||
assessorados,
|
||||
options,
|
||||
commitAction,
|
||||
readOnly = false,
|
||||
}: PeopleAssessoradoCellProps) => {
|
||||
const buildFormData = useCallback(
|
||||
(changedIds: number[], assigned: boolean) => {
|
||||
const formData = new FormData()
|
||||
formData.set('contactId', String(ownerId))
|
||||
formData.set('advisorId', String(changedIds[0]))
|
||||
formData.set('assigned', assigned ? 'true' : 'false')
|
||||
return formData
|
||||
},
|
||||
[ownerId],
|
||||
)
|
||||
|
||||
return (
|
||||
<RelationOptionCell
|
||||
ownerId={ownerId}
|
||||
ownerName={ownerName}
|
||||
items={assessorados.map((advisor) => ({
|
||||
id: advisor.id,
|
||||
label: advisor.name,
|
||||
...(readOnly ? {} : { href: `/campanha/assessores/${advisor.id}` }),
|
||||
}))}
|
||||
options={options}
|
||||
buildFormData={buildFormData}
|
||||
commitAction={commitAction}
|
||||
copy={ASSESSORADO_COPY}
|
||||
drawerTitle="Assessores da pessoa"
|
||||
triggerLabel={`Editar assessores de ${ownerName}`}
|
||||
updateErrorMessage="Não foi possível atualizar os assessores. Tente novamente."
|
||||
readOnly={readOnly}
|
||||
quiet
|
||||
overflowToggleLabel={(count) => `+${count}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
112
src/components/campaign/people/PeopleMunicipalityCell.tsx
Normal file
112
src/components/campaign/people/PeopleMunicipalityCell.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
|
||||
import {
|
||||
buildMunicipalityPortfolioChips,
|
||||
type MunicipalityPortfolioIndexEntry,
|
||||
} from '@/lib/municipalityPortfolio'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type PeopleMunicipalityCellProps = {
|
||||
/** The entity the write targets (`leadership.id`, `stateDeputy.id` or the staff account). */
|
||||
ownerId: number | null
|
||||
/** Whose relation this is — spoken in the aria-labels and the Drawer. */
|
||||
ownerName: string
|
||||
municipalityIds: number[]
|
||||
municipalityIndex: readonly MunicipalityPortfolioIndexEntry[]
|
||||
/** Ids the actor may ADD (administered carteira for an advisor). */
|
||||
addableIds?: ReadonlySet<number>
|
||||
/** Floor the relation enforces server-side — 1 for `leadership.municipalities`. */
|
||||
minItems?: number
|
||||
/** Read-only chips (actor may see the relation but not edit it). */
|
||||
readOnly?: boolean
|
||||
commitAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
drawerTitle: string
|
||||
updateErrorMessage: string
|
||||
}
|
||||
|
||||
/**
|
||||
* C116 — the people-list municipality columns (Assessora, Lidera, Aliada em):
|
||||
* the shared `MunicipalityPortfolioCell` in its `quiet` form (transparent,
|
||||
* always-input paradigm) plus the batch-chip expansion state — clicking a
|
||||
* territory / "Salvador (19)" chip expands it into its member municipalities
|
||||
* IN PLACE, and leaving the cell with the mouse collapses complete batches
|
||||
* again. Expansion is local presentation: it never touches the commit cycle.
|
||||
*/
|
||||
export const PeopleMunicipalityCell = ({
|
||||
ownerId,
|
||||
ownerName,
|
||||
municipalityIds,
|
||||
municipalityIndex,
|
||||
addableIds,
|
||||
minItems,
|
||||
readOnly = false,
|
||||
commitAction,
|
||||
drawerTitle,
|
||||
updateErrorMessage,
|
||||
}: PeopleMunicipalityCellProps) => {
|
||||
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const idsRef = useRef(municipalityIds)
|
||||
idsRef.current = municipalityIds
|
||||
|
||||
const toggleExpand = useCallback((chipKey: string) => {
|
||||
setExpandedKeys((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(chipKey)) next.delete(chipKey)
|
||||
else next.add(chipKey)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Leave-with-mouse collapses the expanded batches whose members are ALL
|
||||
* still assigned ("se a pessoa ainda tiver todas as cidades do território,
|
||||
* colapsa de novo"). A batch whose member was removed via its chip's X no
|
||||
* longer exists as a complete group — the chip builder drops it, so the key
|
||||
* falls out here and the members keep rendering individually.
|
||||
*/
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setExpandedKeys((current) => {
|
||||
if (current.size === 0) return current
|
||||
const chips = buildMunicipalityPortfolioChips(idsRef.current, municipalityIndex)
|
||||
const assigned = new Set(idsRef.current)
|
||||
let changed = false
|
||||
const next = new Set(current)
|
||||
for (const key of current) {
|
||||
const chip = chips.find((candidate) => candidate.key === key)
|
||||
if (!chip || (chip.kind !== 'territory' && chip.kind !== 'city')) continue
|
||||
if (chip.municipalityIds.every((id) => assigned.has(id))) {
|
||||
next.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : current
|
||||
})
|
||||
}, [municipalityIndex])
|
||||
|
||||
return (
|
||||
<div onMouseLeave={handleMouseLeave}>
|
||||
<MunicipalityPortfolioCell
|
||||
ownerId={ownerId}
|
||||
ownerName={ownerName}
|
||||
municipalityIds={municipalityIds}
|
||||
municipalityIndex={municipalityIndex}
|
||||
addableIds={addableIds}
|
||||
minItems={minItems}
|
||||
commitAction={commitAction}
|
||||
drawerTitle={drawerTitle}
|
||||
updateErrorMessage={updateErrorMessage}
|
||||
quiet
|
||||
expandedKeys={expandedKeys}
|
||||
onChipClick={toggleExpand}
|
||||
overflowToggleLabel={(count) => `+${count}`}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro
|
|||
|
||||
const SAVE_DEBOUNCE_MS = 500
|
||||
|
||||
export type CampaignInlineEditableField = 'name' | 'email' | 'phone' | 'party'
|
||||
export type CampaignInlineEditableField = 'name' | 'email' | 'phone' | 'party' | 'city'
|
||||
|
||||
type CampaignInlineEditableCellProps = {
|
||||
recordId: number
|
||||
|
|
@ -36,6 +36,15 @@ type CampaignInlineEditableCellProps = {
|
|||
editTrigger?: 'pencil' | 'cell'
|
||||
readBehavior?: 'text' | 'copy'
|
||||
saveOnChange?: boolean
|
||||
/**
|
||||
* C116 — "edit where you see": the input is ALWAYS mounted, borderless and
|
||||
* tintless (no edit state, no label/border/background change). Saves on
|
||||
* blur/Enter, Escape discards, empty shows a "—" placeholder. With `href` +
|
||||
* `field === 'name'` the value renders as a LINK above the invisible input —
|
||||
* the draft lives in the link's text, so clicking the link navigates and
|
||||
* typing edits in the same place (mechanism locked at the C116 gate).
|
||||
*/
|
||||
permanent?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +54,17 @@ const inputTypeForField = (field: CampaignInlineEditableField): 'text' | 'email'
|
|||
return 'text'
|
||||
}
|
||||
|
||||
/** The discreet keyboard-focus indicator of the permanent mode (a11y guardrail). */
|
||||
const permanentFocusClassName =
|
||||
'rounded-sm focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:outline-none'
|
||||
|
||||
/**
|
||||
* Permanent cells display the phone already formatted — the raw 11 digits stay
|
||||
* on the server, so the draft and the save baseline share the display.
|
||||
*/
|
||||
const displayDraftFor = (field: CampaignInlineEditableField, raw: string): string =>
|
||||
field === 'phone' && raw ? formatBrazilianPhoneInput(raw) : raw
|
||||
|
||||
export const CampaignInlineEditableCell = ({
|
||||
recordId,
|
||||
recordIdField,
|
||||
|
|
@ -56,6 +76,7 @@ export const CampaignInlineEditableCell = ({
|
|||
editTrigger = 'pencil',
|
||||
readBehavior = 'text',
|
||||
saveOnChange = true,
|
||||
permanent = false,
|
||||
className,
|
||||
}: CampaignInlineEditableCellProps) => {
|
||||
const router = useRouter()
|
||||
|
|
@ -73,8 +94,9 @@ export const CampaignInlineEditableCell = ({
|
|||
useEffect(() => {
|
||||
if (inputRef.current && document.activeElement === inputRef.current) return
|
||||
const next = value ?? ''
|
||||
setDraft(next)
|
||||
lastSaved.current = next.trim()
|
||||
const display = displayDraftFor(field, next)
|
||||
setDraft(display)
|
||||
lastSaved.current = display.trim()
|
||||
}, [field, recordId, value])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -176,6 +198,85 @@ export const CampaignInlineEditableCell = ({
|
|||
startEditing()
|
||||
}
|
||||
|
||||
/**
|
||||
* C116 — "edit where you see": input always mounted, no edit state. The
|
||||
* name-link cell renders the LINK above an invisible input (the draft lives
|
||||
* in the link's text, the input value is never the display — locked
|
||||
* mechanism); every other field is a borderless input that reads as text.
|
||||
*/
|
||||
if (permanent) {
|
||||
const isNameLink = href !== undefined && field === 'name'
|
||||
const inputClassName = cn(
|
||||
isNameLink
|
||||
? 'absolute inset-0 z-0 h-full w-full cursor-text border-transparent bg-transparent px-1 text-transparent caret-transparent shadow-none'
|
||||
: 'min-h-10 w-full border-transparent bg-transparent px-1 shadow-none',
|
||||
permanentFocusClassName,
|
||||
)
|
||||
return (
|
||||
<div className={cn('relative min-w-0 w-full', isNameLink && 'min-h-10', className)}>
|
||||
{isNameLink ? (
|
||||
<Link
|
||||
href={href}
|
||||
// `w-auto`: the link spans ONLY its text, so the rest of the cell
|
||||
// stays the input's clickable area (click on the text navigates,
|
||||
// click beside it edits — the locked C116 mechanism).
|
||||
className="relative z-10 inline-flex min-h-10 max-w-full items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="truncate">{draft || value || ''}</span>
|
||||
</Link>
|
||||
) : null}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type={inputTypeForField(field)}
|
||||
value={draft}
|
||||
placeholder={isNameLink ? undefined : '—'}
|
||||
aria-label={label}
|
||||
aria-busy={isPending}
|
||||
className={inputClassName}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setDraft(displayDraftFor(field, value ?? ''))
|
||||
setErrorMessage(null)
|
||||
setShowSavedFeedback(false)
|
||||
// Blur saves the reverted draft — a no-op the equality check catches.
|
||||
event.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
{...(field === 'phone' ? { inputMode: 'numeric' as const } : {})}
|
||||
/>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className="pointer-events-none absolute top-1/2 right-2 z-20 size-3.5 -translate-y-1/2"
|
||||
aria-label={`Salvando ${label.toLowerCase()}`}
|
||||
/>
|
||||
) : null}
|
||||
{showSavedFeedback ? (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="absolute top-1/2 right-2 z-20 -translate-y-1/2 bg-background/90 px-1 text-xs text-muted-foreground"
|
||||
>
|
||||
Salvo.
|
||||
</span>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<p role="alert" className="mt-1 text-xs text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={cn('relative min-w-40', className)}>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '@/components/campaign/shared/RelationChipCell'
|
||||
import {
|
||||
buildMunicipalityPortfolioChips,
|
||||
expandMunicipalityPortfolioChips,
|
||||
scopedPortfolioIndex,
|
||||
searchMunicipalityPortfolio,
|
||||
type MunicipalityPortfolioChip,
|
||||
|
|
@ -26,7 +27,7 @@ const hitDescription = (hit: MunicipalityPortfolioSearchHit): string => {
|
|||
}
|
||||
|
||||
const toRelationChip = (chip: MunicipalityPortfolioChip): RelationChip =>
|
||||
chip.kind === 'territory'
|
||||
chip.kind === 'territory' || chip.kind === 'city'
|
||||
? {
|
||||
key: chip.key,
|
||||
label: chip.label,
|
||||
|
|
@ -76,6 +77,16 @@ type MunicipalityPortfolioCellProps = {
|
|||
) => Promise<CampaignFormActionState>
|
||||
drawerTitle: string
|
||||
updateErrorMessage: string
|
||||
/** C116 quiet cell — see `RelationChipCell.quiet`. */
|
||||
quiet?: boolean
|
||||
/** C116 expanded batch keys (territory / zone-city) — see `expandMunicipalityPortfolioChips`. */
|
||||
expandedKeys?: ReadonlySet<string>
|
||||
/** C116 — click on a collapsed batch chip (key like `territory:Irecê`), for the wrapper to expand it. */
|
||||
onChipClick?: (chipKey: string) => void
|
||||
/** C116 — "+N" overflow label — see `RelationChipCell.overflowToggleLabel`. */
|
||||
overflowToggleLabel?: (hiddenCount: number) => string
|
||||
/** C116 — read-only chips (actor may see the relation but not edit it). */
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -97,6 +108,11 @@ export const MunicipalityPortfolioCell = ({
|
|||
commitAction,
|
||||
drawerTitle,
|
||||
updateErrorMessage,
|
||||
quiet = false,
|
||||
expandedKeys,
|
||||
onChipClick,
|
||||
overflowToggleLabel,
|
||||
readOnly = false,
|
||||
}: MunicipalityPortfolioCellProps) => {
|
||||
/**
|
||||
* `addableIds` also hides: an advisor's `canUpdateMunicipality` is scoped to
|
||||
|
|
@ -124,8 +140,15 @@ export const MunicipalityPortfolioCell = ({
|
|||
)
|
||||
|
||||
const buildChips = useCallback(
|
||||
(ids: number[]) => buildMunicipalityPortfolioChips(ids, municipalityIndex).map(toRelationChip),
|
||||
[municipalityIndex],
|
||||
(ids: number[]) => {
|
||||
const base = buildMunicipalityPortfolioChips(ids, municipalityIndex)
|
||||
return (
|
||||
expandedKeys
|
||||
? expandMunicipalityPortfolioChips(base, expandedKeys, municipalityIndex)
|
||||
: base
|
||||
).map(toRelationChip)
|
||||
},
|
||||
[municipalityIndex, expandedKeys],
|
||||
)
|
||||
|
||||
const searchHits = useCallback(
|
||||
|
|
@ -178,6 +201,10 @@ export const MunicipalityPortfolioCell = ({
|
|||
triggerLabel={`Editar municípios de ${ownerName}`}
|
||||
updateErrorMessage={updateErrorMessage}
|
||||
copy={copy}
|
||||
quiet={quiet}
|
||||
overflowToggleLabel={overflowToggleLabel}
|
||||
onChipClick={onChipClick ? (chip) => onChipClick(chip.key) : undefined}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
285
src/components/campaign/shared/PhonesFieldEditor.tsx
Normal file
285
src/components/campaign/shared/PhonesFieldEditor.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
'use client'
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { FormattedInput } from '@/components/FormattedInput'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type PhoneRow = { key: string; value: string }
|
||||
|
||||
let rowKey = 0
|
||||
const nextRowKey = (): string => `phone-${++rowKey}`
|
||||
const EMPTY_ROW_KEY = 'phone-empty'
|
||||
|
||||
const displayValue = (value: string): string => formatBrazilianPhoneInput(value)
|
||||
|
||||
/**
|
||||
* The repeatable phone list editor (C112): one masked input per number, order
|
||||
* = priority (first = primary), with remove and ↑/↓ reorder — no labels, no
|
||||
* drag (product cut). In a create/edit form it renders plain inputs named
|
||||
* `phones` for the form action to collect; with `saveAction` it becomes the
|
||||
* ficha's inline editor (Salvar/Cancelar + refresh on success).
|
||||
*/
|
||||
export const PhonesFieldEditor = ({
|
||||
name = 'phones',
|
||||
defaultValues = [],
|
||||
minRows = 0,
|
||||
label = 'Celulares',
|
||||
error,
|
||||
saveAction,
|
||||
recordId,
|
||||
recordIdField,
|
||||
saveLabel = 'Salvar',
|
||||
cancelLabel = 'Cancelar',
|
||||
className,
|
||||
}: {
|
||||
name?: string
|
||||
defaultValues?: string[]
|
||||
/** Rows below this count cannot be removed (create flows that require a phone). */
|
||||
minRows?: number
|
||||
label?: string
|
||||
error?: string | string[] | undefined
|
||||
/** Inline mode: submits the full list through the per-surface contact update action. */
|
||||
saveAction?: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
recordId?: number
|
||||
recordIdField?: string
|
||||
saveLabel?: string
|
||||
cancelLabel?: string
|
||||
className?: string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const [rows, setRows] = useState<PhoneRow[]>(() => {
|
||||
const initial = defaultValues.map((value) => ({ key: nextRowKey(), value }))
|
||||
while (initial.length < minRows) initial.push({ key: nextRowKey(), value: '' })
|
||||
return initial
|
||||
})
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(!saveAction)
|
||||
const rowsRef = useRef(rows)
|
||||
rowsRef.current = rows
|
||||
const appliedDefaultsRef = useRef<string[]>(defaultValues)
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveAction) return
|
||||
// Apply the server values only when they actually changed (a post-save
|
||||
// refresh or an unrelated refresh of the page must not drop what the
|
||||
// user is typing).
|
||||
if (defaultValues.join('|') === appliedDefaultsRef.current.join('|')) return
|
||||
appliedDefaultsRef.current = defaultValues
|
||||
setRows((current) => {
|
||||
const existing = new Map(current.map((row) => [row.value, row]))
|
||||
const next = defaultValues.map((value) => existing.get(value) ?? { key: nextRowKey(), value })
|
||||
return next.length > 0 || current.length === 0 ? next : current
|
||||
})
|
||||
}, [defaultValues, saveAction])
|
||||
|
||||
const updateRow = (key: string, value: string): void => {
|
||||
setRows((current) => current.map((row) => (row.key === key ? { ...row, value } : row)))
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const addRow = (): void => {
|
||||
setRows((current) => [...current, { key: nextRowKey(), value: '' }])
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const removeRow = (key: string): void => {
|
||||
setRows((current) =>
|
||||
current.length > minRows ? current.filter((row) => row.key !== key) : current,
|
||||
)
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const moveRow = (index: number, direction: -1 | 1): void => {
|
||||
setRows((current) => {
|
||||
const target = index + direction
|
||||
if (target < 0 || target >= current.length) return current
|
||||
const next = [...current]
|
||||
;[next[index], next[target]] = [next[target]!, next[index]!]
|
||||
return next
|
||||
})
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const errorId = 'phones-field-error'
|
||||
const hasError = saveError !== null || (Array.isArray(error) ? error.length > 0 : Boolean(error))
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!saveAction || recordId === undefined) return
|
||||
const formData = new FormData()
|
||||
if (recordIdField) formData.set(recordIdField, String(recordId))
|
||||
formData.set('field', 'phones')
|
||||
for (const row of rowsRef.current) {
|
||||
formData.append(name, row.value)
|
||||
}
|
||||
setIsPending(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
const result = await saveAction({}, formData)
|
||||
setIsPending(false)
|
||||
if (result.status === 'success') {
|
||||
setIsEditing(false)
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
const message =
|
||||
result.fieldErrors?.phones?.[0] ??
|
||||
result.fieldErrors?.[name]?.[0] ??
|
||||
result.fieldErrors?.form?.[0] ??
|
||||
result.message
|
||||
const refusal = message ?? 'Não foi possível salvar.'
|
||||
setSaveError(refusal)
|
||||
toast.error(refusal)
|
||||
} catch {
|
||||
setIsPending(false)
|
||||
const message = 'Não foi possível salvar. Tente novamente.'
|
||||
setSaveError(message)
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const rowInputs = rows.map((row, index) => (
|
||||
<div key={row.key} className="flex items-center gap-1.5">
|
||||
<FormattedInput
|
||||
name={name}
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
aria-label={`Celular ${index + 1}`}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={hasError ? `${errorId}` : undefined}
|
||||
value={displayValue(row.value)}
|
||||
format={formatBrazilianPhoneInput}
|
||||
sanitize={sanitizeBrazilianPhoneInput}
|
||||
className={campaignInlineInputClassName}
|
||||
onChange={(event) => updateRow(row.key, event.currentTarget.value)}
|
||||
/>
|
||||
{rows.length > 1 ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 shrink-0"
|
||||
aria-label="Mover para cima (torna principal antes dos demais)"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveRow(index, -1)}
|
||||
>
|
||||
<ArrowUpIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 shrink-0"
|
||||
aria-label="Mover para baixo"
|
||||
disabled={index === rows.length - 1}
|
||||
onClick={() => moveRow(index, 1)}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 shrink-0"
|
||||
aria-label={`Remover celular ${index + 1}`}
|
||||
disabled={rows.length <= minRows}
|
||||
onClick={() => removeRow(row.key)}
|
||||
>
|
||||
<Trash2Icon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
|
||||
const addButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-9 w-fit shrink-0 justify-start px-2 text-sm text-muted-foreground"
|
||||
aria-label="Adicionar telefone"
|
||||
onClick={addRow}
|
||||
>
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
Adicionar telefone
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (saveAction && !isEditing) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ul className="flex flex-col gap-1.5" aria-label={label}>
|
||||
{(rows.length ? rows : [{ key: EMPTY_ROW_KEY, value: '' }]).map((row, index) => (
|
||||
<li key={row.key} className="flex items-center gap-1.5 text-sm tabular-nums">
|
||||
<span className="w-6 text-right text-muted-foreground">{index + 1}.</span>
|
||||
<span className="min-w-40">
|
||||
{row.value ? formatBrazilianPhoneInput(row.value) : '—'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-1 size-11"
|
||||
aria-label={`Editar ${label.toLowerCase()}`}
|
||||
onClick={() => {
|
||||
setIsEditing(true)
|
||||
setSaveError(null)
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Editar</span>
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{rowInputs}
|
||||
{addButton}
|
||||
</div>
|
||||
{hasError ? (
|
||||
<FieldError id={errorId}>
|
||||
{saveError ?? (Array.isArray(error) ? error[0] : error)}
|
||||
</FieldError>
|
||||
) : null}
|
||||
{saveAction ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button type="button" disabled={isPending} onClick={() => void save()}>
|
||||
{saveLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
setIsEditing(false)
|
||||
setSaveError(null)
|
||||
}}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
{isPending ? <Spinner className="size-4" aria-label="Salvando telefones" /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -166,6 +166,27 @@ type RelationChipCellProps = {
|
|||
* see the relation but not edit it (staff viewing `StateDeputy.advisors`).
|
||||
*/
|
||||
readOnly?: boolean
|
||||
|
||||
// C116 quiet/expand surface ─────────────────────────────────────────────────
|
||||
/**
|
||||
* Transparent cell (C116 "edit where you see"): no hover/focus background
|
||||
* tint — the cell looks like plain text. Keyboard focus keeps a discreet
|
||||
* indicator on the search input only (accessibility guardrail), never a box
|
||||
* or tint around the cell.
|
||||
*/
|
||||
quiet?: boolean
|
||||
/**
|
||||
* C116 — click on a link-less (batch) chip fires this instead of nothing,
|
||||
* letting a wrapper expand the batch into its member chips in place. Chips
|
||||
* WITH a link keep navigating. Only the inline row editor is wired: the
|
||||
* coarse Drawer keeps its list with the permanent remove buttons.
|
||||
*/
|
||||
onChipClick?: (chip: RelationChip) => void
|
||||
/**
|
||||
* C116 — replaces the overflow toggle's collapsed label ("Ver mais…") with a
|
||||
* "+N" chip label (e.g. "+3"), keeping the B170 measure/collapse contract.
|
||||
*/
|
||||
overflowToggleLabel?: (hiddenCount: number) => string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -203,6 +224,9 @@ export const RelationChipCell = ({
|
|||
createLabel,
|
||||
createErrorMessage = updateErrorMessage,
|
||||
readOnly = false,
|
||||
quiet = false,
|
||||
onChipClick,
|
||||
overflowToggleLabel,
|
||||
}: RelationChipCellProps) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -352,6 +376,18 @@ export const RelationChipCell = ({
|
|||
: null
|
||||
const listOptions = createHit ? [...hits, createHit] : hits
|
||||
|
||||
/**
|
||||
* C116 — the "typed text IS the match" rule: a suggestion whose normalized
|
||||
* label exactly equals the query (e.g. "Feira de Santana"). Space or Enter
|
||||
* then commits THAT suggestion instead of the ARIA pre-selected first hit —
|
||||
* without it, "Salvador" + Enter would silently add "Salvador — ZE 1" while
|
||||
* every zone matches at word start. The create option can never be the exact
|
||||
* match (it only exists when there are no hits).
|
||||
*/
|
||||
const exactMatchOption = listOptions.find(
|
||||
(option) => normalizeSearchPhrase(option.label) === normalizeSearchPhrase(trimmedQuery),
|
||||
)
|
||||
|
||||
const optionsKey = listOptions.map((option) => option.key).join('|')
|
||||
useEffect(() => {
|
||||
setActiveIndex(0)
|
||||
|
|
@ -626,10 +662,10 @@ export const RelationChipCell = ({
|
|||
setActiveIndex(event.key === 'Home' ? 0 : listOptions.length - 1)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
if (event.key === 'Enter' || (event.key === ' ' && exactMatchOption)) {
|
||||
if (listOptions.length === 0) return
|
||||
event.preventDefault()
|
||||
pickOption(activeIndex)
|
||||
pickOption(exactMatchOption ? listOptions.indexOf(exactMatchOption) : activeIndex)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
|
|
@ -676,6 +712,16 @@ export const RelationChipCell = ({
|
|||
? copy.savedMessage
|
||||
: ''
|
||||
|
||||
/**
|
||||
* C116 — the collapsed overflow toggle may render as a "+N" chip instead of
|
||||
* the "Ver mais…" link. `visibleChipCount === null` means the first
|
||||
* measurement is still in flight, when the toggle is invisible anyway.
|
||||
*/
|
||||
const collapsedOverflowLabel =
|
||||
!showAllChips && overflowToggleLabel && visibleChipCount !== null
|
||||
? overflowToggleLabel(chips.length - visibleChipCount)
|
||||
: null
|
||||
|
||||
/**
|
||||
* Mounted while measuring so its width is reserved on the last line, but kept
|
||||
* out of the tab order and the accessibility tree until the overflow it
|
||||
|
|
@ -691,7 +737,10 @@ export const RelationChipCell = ({
|
|||
aria-hidden={measuring || undefined}
|
||||
className={cn(
|
||||
// `min-h-6`: 24px is the SC 2.5.8 target floor for a text button.
|
||||
'inline-flex min-h-6 items-center px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
|
||||
'inline-flex min-h-6 items-center',
|
||||
collapsedOverflowLabel
|
||||
? 'rounded-md border border-input px-1.5 text-xs font-normal text-muted-foreground tabular-nums hover:bg-muted/60'
|
||||
: 'px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
|
||||
measuring && 'invisible pointer-events-none',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
|
|
@ -705,7 +754,7 @@ export const RelationChipCell = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
{showAllChips ? 'Ver menos' : 'Ver mais…'}
|
||||
{showAllChips ? 'Ver menos' : (collapsedOverflowLabel ?? 'Ver mais…')}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
|
|
@ -744,6 +793,22 @@ export const RelationChipCell = ({
|
|||
{chipBody(chip)}
|
||||
</Link>
|
||||
</Badge>
|
||||
) : onChipClick ? (
|
||||
/* C116 — a link-less batch chip becomes a button: the wrapper
|
||||
expands/collapses it in place. `stopPropagation` keeps the cell
|
||||
click (focus + suggestions) out of the way. */
|
||||
<Badge variant="secondary" className="max-w-full font-normal" asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer truncate underline-offset-4 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onChipClick(chip)
|
||||
}}
|
||||
>
|
||||
{chipBody(chip)}
|
||||
</button>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="max-w-full gap-1 font-normal">
|
||||
{chipBody(chip)}
|
||||
|
|
@ -953,7 +1018,12 @@ export const RelationChipCell = ({
|
|||
// "nothing happened" until the next navigation proves otherwise.
|
||||
aria-busy={isPending || undefined}
|
||||
// Same box in both pointer modes so nothing reflows when the row is touched.
|
||||
className="relative min-w-56 rounded-md border border-transparent p-1 outline-none pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40"
|
||||
className={cn(
|
||||
'relative min-w-56 rounded-md border border-transparent p-1 outline-none',
|
||||
// C116 quiet: no tint at all — the cell reads as plain text (the locked
|
||||
// paradigm); keyboard focus keeps only the input's discreet ring.
|
||||
!quiet && 'pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40',
|
||||
)}
|
||||
// Reached only on a fine pointer: the coarse overlay below covers the cell
|
||||
// and stops the click, so the pointer policy stays in the media queries.
|
||||
onClick={(event) => {
|
||||
|
|
@ -1018,7 +1088,12 @@ export const RelationChipCell = ({
|
|||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={chips.length ? 'Adicionar…' : copy.searchPlaceholder}
|
||||
aria-label={copy.searchLabel}
|
||||
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
|
||||
className={cn(
|
||||
'hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block',
|
||||
// C116 quiet: the ONLY focus indicator — a discreet ring on
|
||||
// the input itself, never a box or tint around the cell.
|
||||
quiet && 'rounded-sm focus-visible:ring-1 focus-visible:ring-ring/50',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ type RelationOptionCellProps = {
|
|||
measureOverflow?: boolean
|
||||
/** Read-only rendering (B156): linked chips, no search/remove/Drawer affordances. */
|
||||
readOnly?: boolean
|
||||
/** C116 quiet cell — see `RelationChipCell.quiet`. */
|
||||
quiet?: boolean
|
||||
/** C116 — "+N" overflow label — see `RelationChipCell.overflowToggleLabel`. */
|
||||
overflowToggleLabel?: (hiddenCount: number) => string
|
||||
}
|
||||
|
||||
const itemLabel = (item: RelationCellItem): string =>
|
||||
|
|
@ -91,6 +95,8 @@ export const RelationOptionCell = ({
|
|||
updateErrorMessage,
|
||||
measureOverflow = true,
|
||||
readOnly = false,
|
||||
quiet = false,
|
||||
overflowToggleLabel,
|
||||
}: RelationOptionCellProps) => {
|
||||
const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items])
|
||||
// Additions resolve through `options` (the addable catalog), same asymmetry
|
||||
|
|
@ -149,6 +155,8 @@ export const RelationOptionCell = ({
|
|||
copy={copy}
|
||||
measureOverflow={measureOverflow}
|
||||
readOnly={readOnly}
|
||||
quiet={quiet}
|
||||
overflowToggleLabel={overflowToggleLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ import { InputGroup, InputGroupButton } from '@/components/ui/input-group'
|
|||
import { Marker, MarkerContent } from '@/components/ui/marker'
|
||||
import { Message, MessageAvatar, MessageContent } from '@/components/ui/message'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { externalLinkTarget } from '@/lib/ai/markdownLinks'
|
||||
import {
|
||||
externalLinkTarget,
|
||||
isCampaignInternalLink,
|
||||
shouldCloseDrawerOnLinkClick,
|
||||
} from '@/lib/ai/markdownLinks'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext'
|
||||
|
|
@ -30,35 +34,52 @@ import { getSollinhaOpeningQuestions } from '@/lib/sollinhaOpeningQuestions'
|
|||
* only ever emits `/campanha…` paths — navigate through `next/link` (B188), so
|
||||
* following a Sollinha link keeps the layout (and the conversation) mounted
|
||||
* instead of reloading the page. Anything else keeps the plain anchor.
|
||||
* B198 — on mobile the same-tab internal navigation closes the drawer in the
|
||||
* touch, so the destination is visible on arrival; external and new-tab
|
||||
* navigations leave the drawer open for the origin tab's conversation.
|
||||
*/
|
||||
const APP_INTERNAL_LINK = /^\/campanha(?:\/|$)/
|
||||
|
||||
/** B192 — narrower drawer surface: fewer follow-up chips than on desktop. */
|
||||
const FOLLOW_UP_MOBILE_LIMIT = 2
|
||||
|
||||
const markdownComponents: Components = {
|
||||
a: ({ node: _node, href, children, ...rest }) => {
|
||||
const external = typeof href === 'string' ? externalLinkTarget(href) : null
|
||||
if (typeof href === 'string' && APP_INTERNAL_LINK.test(href)) {
|
||||
return (
|
||||
<Link href={href} {...rest}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a href={href} {...rest} {...external}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const CampaignAIChat = ({ className }: { className?: string }) => {
|
||||
const ctx = useAISidebar()
|
||||
const [input, setInput] = useState('')
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// B198 — the markdown link renderer needs the context (viewport + close),
|
||||
// so the module-level components object moved in here: same shape as before,
|
||||
// plus the mobile same-tab navigation closing the drawer on touch. The memo
|
||||
// keeps the renderer identity stable across message renders; it recomputes
|
||||
// only on a breakpoint crossing (`isMobileViewport`) or context churn.
|
||||
const isMobileViewport = ctx?.isMobile ?? false
|
||||
const closeChat = ctx?.setOpen
|
||||
const markdownComponents = useMemo<Components>(() => {
|
||||
return {
|
||||
a: ({ node: _node, href, children, ...rest }) => {
|
||||
const external = typeof href === 'string' ? externalLinkTarget(href) : null
|
||||
if (typeof href === 'string' && isCampaignInternalLink(href)) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
{...rest}
|
||||
onClick={(e) => {
|
||||
if (shouldCloseDrawerOnLinkClick(href, isMobileViewport, e)) closeChat?.(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a href={href} {...rest} {...external}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
}
|
||||
}, [isMobileViewport, closeChat])
|
||||
|
||||
const mic = useMicTranscript()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,19 +2,21 @@
|
|||
|
||||
import { updateStateDeputyContactFormAction } from '@/app/(campaign)/campanha/(app)/dobradinhas/formActions'
|
||||
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
|
||||
type StateDeputyContactSectionProps = {
|
||||
stateDeputyId: number
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
/** Every number of the ficha, order = priority (C112) — primary first. */
|
||||
phones: string[]
|
||||
}
|
||||
|
||||
export const StateDeputyContactSection = ({
|
||||
stateDeputyId,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
phones,
|
||||
}: StateDeputyContactSectionProps) => (
|
||||
<section aria-labelledby="state-deputy-contact-title" className="flex flex-col gap-3">
|
||||
<h2 id="state-deputy-contact-title" className="text-base font-medium">
|
||||
|
|
@ -31,6 +33,7 @@ export const StateDeputyContactSection = ({
|
|||
value={name}
|
||||
label="Nome"
|
||||
formAction={updateStateDeputyContactFormAction}
|
||||
href={`/campanha/dobradinhas/${stateDeputyId}`}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
|
@ -44,19 +47,21 @@ export const StateDeputyContactSection = ({
|
|||
value={email}
|
||||
label="E-mail"
|
||||
formAction={updateStateDeputyContactFormAction}
|
||||
readBehavior="copy"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-sm text-muted-foreground">Telefone</dt>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
Telefones <span className="font-normal">(o primeiro é o principal)</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<CampaignInlineEditableCell
|
||||
<PhonesFieldEditor
|
||||
defaultValues={phones}
|
||||
label="Telefones"
|
||||
saveAction={updateStateDeputyContactFormAction}
|
||||
recordId={stateDeputyId}
|
||||
recordIdField="stateDeputyId"
|
||||
field="phone"
|
||||
value={phone}
|
||||
label="Telefone"
|
||||
formAction={updateStateDeputyContactFormAction}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import { useActionState, useState } from 'react'
|
|||
|
||||
import type { SupporterFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
|
||||
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
|
||||
import { FormattedInput } from '@/components/FormattedInput'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
|
|
@ -24,7 +24,6 @@ import { Input } from '@/components/ui/input'
|
|||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
|
||||
import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
|
||||
import { supporterVoteIntentionLabels } from '@/utilities/supporter/supporterUi'
|
||||
|
|
@ -118,7 +117,6 @@ export const SupporterForm = ({
|
|||
})
|
||||
|
||||
const nameField = errorProps(state.fieldErrors, 'name')
|
||||
const phoneField = errorProps(state.fieldErrors, 'phone')
|
||||
const emailField = errorProps(state.fieldErrors, 'email')
|
||||
const cityField = errorProps(state.fieldErrors, 'city')
|
||||
const municipalityField = errorProps(state.fieldErrors, 'municipality')
|
||||
|
|
@ -140,7 +138,6 @@ export const SupporterForm = ({
|
|||
<Input
|
||||
id="supporter-name"
|
||||
name="name"
|
||||
required
|
||||
minLength={2}
|
||||
defaultValue={values?.name}
|
||||
autoComplete="name"
|
||||
|
|
@ -153,25 +150,12 @@ export const SupporterForm = ({
|
|||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={phoneField.invalid}>
|
||||
<FieldLabel htmlFor="supporter-phone">Celular *</FieldLabel>
|
||||
<FormattedInput
|
||||
id="supporter-phone"
|
||||
name="phone"
|
||||
required
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
|
||||
format={formatBrazilianPhoneInput}
|
||||
sanitize={sanitizeBrazilianPhoneInput}
|
||||
aria-invalid={phoneField.invalid}
|
||||
aria-describedby={phoneField.describedBy}
|
||||
className="min-h-11 rounded-[6px]"
|
||||
/>
|
||||
{phoneField.error ? (
|
||||
<FieldError id="supporter-phone-error">{phoneField.error}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<PhonesFieldEditor
|
||||
name="phones"
|
||||
minRows={1}
|
||||
label="Celulares (o primeiro é o principal)"
|
||||
error={fieldError(state.fieldErrors, 'phones')}
|
||||
/>
|
||||
|
||||
<Field data-invalid={emailField.invalid}>
|
||||
<FieldLabel htmlFor="supporter-email">E-mail</FieldLabel>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ const badgeVariants = cva(
|
|||
'bg-support-to-approach text-support-to-approach-foreground [a]:hover:bg-support-to-approach/80',
|
||||
'support-disputed':
|
||||
'bg-support-disputed text-support-disputed-foreground [a]:hover:bg-support-disputed/80',
|
||||
'support-remembered':
|
||||
'bg-support-remembered text-support-remembered-foreground [a]:hover:bg-support-remembered/80',
|
||||
'support-negative':
|
||||
'bg-support-negative text-support-negative-foreground [a]:hover:bg-support-negative/80',
|
||||
'estimate-confirmed':
|
||||
|
|
|
|||
21
src/lib/activityDescription.ts
Normal file
21
src/lib/activityDescription.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Shared description parts for campaign activities — the iCal feed and the
|
||||
* C114 Google mirror render the same facts (municipality, locality, tags,
|
||||
* deputy flag) with their own consumers' separators/escapes. No PII: no
|
||||
* leadership names, phones or emails.
|
||||
*/
|
||||
import type { Activity } from '@/payload-types'
|
||||
|
||||
export type ActivityDescriptionSource = Pick<Activity, 'locality' | 'tags' | 'deputyPresent'>
|
||||
|
||||
export const buildActivityDescriptionParts = (
|
||||
activity: ActivityDescriptionSource,
|
||||
municipalityName?: string,
|
||||
): string[] => {
|
||||
const parts: string[] = []
|
||||
if (municipalityName) parts.push(`Município: ${municipalityName}`)
|
||||
if (activity.locality) parts.push(`Local: ${activity.locality}`)
|
||||
if (activity.tags?.length) parts.push(`Tags: ${activity.tags.join(', ')}`)
|
||||
if (activity.deputyPresent) parts.push('Deputado presente')
|
||||
return parts
|
||||
}
|
||||
|
|
@ -1,4 +1,12 @@
|
|||
import { Calendar, ClipboardList, ListChecks, MapPinned, Pencil, Plus } from 'lucide-react'
|
||||
import {
|
||||
Calendar,
|
||||
CalendarCog,
|
||||
ClipboardList,
|
||||
ListChecks,
|
||||
MapPinned,
|
||||
Pencil,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
CAMPAIGN_WIZARD_ACTION_SLUGS,
|
||||
|
|
@ -42,6 +50,17 @@ const listQuickActions = (context: CampaignQuickActionContext): readonly Campaig
|
|||
},
|
||||
]
|
||||
: []),
|
||||
...(context.openGoogleCalendarSync
|
||||
? [
|
||||
{
|
||||
id: 'google-calendar-mirror',
|
||||
label: 'Agenda da Campanha',
|
||||
icon: CalendarCog,
|
||||
description: 'Espelhar a agenda oficial no Google Calendar da campanha',
|
||||
onAction: context.openGoogleCalendarSync,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'plan-tour',
|
||||
label: 'Planejar giro',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@ export type ExternalLinkTarget = {
|
|||
rel: 'noopener noreferrer'
|
||||
}
|
||||
|
||||
/**
|
||||
* App-internal destinations the assistant emits — the B162 link catalog only
|
||||
* ever produces `/campanha…` paths (B187/B188).
|
||||
*/
|
||||
const CAMPAIGN_INTERNAL_LINK = /^\/campanha(?:\/|$)/
|
||||
|
||||
export const isCampaignInternalLink = (href: string): boolean => CAMPAIGN_INTERNAL_LINK.test(href)
|
||||
|
||||
/**
|
||||
* Decides whether a markdown href rendered by the Sollinha chat should open in
|
||||
* a new tab. Only absolute http(s) URLs (and protocol-relative ones) qualify —
|
||||
|
|
@ -16,3 +24,34 @@ export const externalLinkTarget = (href: string): ExternalLinkTarget | null => {
|
|||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The click metadata that decides same-tab vs new-tab navigation (B198). */
|
||||
export type LinkClickMeta = {
|
||||
button: number
|
||||
altKey: boolean
|
||||
ctrlKey: boolean
|
||||
metaKey: boolean
|
||||
shiftKey: boolean
|
||||
defaultPrevented: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* B198 — a mobile link click on the mobile drawer closes it only for the
|
||||
* navigation that replaces the page in the SAME tab: an internal link touched
|
||||
* with a plain left click. Modified clicks (cmd/ctrl/shift/alt), middle clicks
|
||||
* and already-prevented clicks open the destination in a new tab (or nowhere)
|
||||
* — the origin tab keeps the drawer so the conversation is where it was. The
|
||||
* desktop panel never closes (the accept keeps it open while navigating).
|
||||
*/
|
||||
export const shouldCloseDrawerOnLinkClick = (
|
||||
href: string,
|
||||
isMobile: boolean,
|
||||
event: LinkClickMeta,
|
||||
): boolean => {
|
||||
if (!isMobile) return false
|
||||
if (!isCampaignInternalLink(href)) return false
|
||||
if (event.defaultPrevented) return false
|
||||
if (event.button !== 0) return false
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export type CampaignQuickActionContext = {
|
|||
* crossed server→client.
|
||||
*/
|
||||
openCalendarFeed?: () => void
|
||||
/** C114 — opens the Google Calendar mirror dialog (same bridge as above). */
|
||||
openGoogleCalendarSync?: () => void
|
||||
}
|
||||
|
||||
export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({})
|
||||
|
|
|
|||
|
|
@ -176,6 +176,24 @@ export const repeatedFormTexts = (formData: FormData, field: string): string[] =
|
|||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* The ficha's phone list from the repeatable editor: order is preserved (=
|
||||
* priority, first = primary) and empty values are dropped. Duplicates are NOT
|
||||
* dropped here — the zod `contactPhonesSchema` refine reports them so the
|
||||
* form can show the "Telefone repetido" refusal instead of silently merging.
|
||||
*/
|
||||
export const repeatedPhoneFormValues = (formData: FormData, field: string): string[] => {
|
||||
const values: string[] = []
|
||||
for (const value of formData.getAll(field)) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new FormDataBoundaryError(field, 'Arquivos não são aceitos neste campo.')
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (trimmed) values.push(trimmed)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export const checkboxFormValue = (formData: FormData, field: string): boolean => {
|
||||
const entry = formEntry(formData, field)
|
||||
if (!entry.present) return false
|
||||
|
|
|
|||
189
src/lib/googleCalendarEventMapping.ts
Normal file
189
src/lib/googleCalendarEventMapping.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* C114 — pure mapping between campaign activities and Google Calendar events.
|
||||
*
|
||||
* The Google event id is DETERMINISTIC (`teqo` + base32hex of the activity
|
||||
* id): Google ids only allow base32hex characters (lowercase a–v, digits 0–9)
|
||||
* — no underscores, no uppercase — and the deterministic id makes the whole
|
||||
* reconciliation stateless (upsert by id, content-compare to decide updates).
|
||||
* Teqo stays the single source of truth; canceled activities are excluded by
|
||||
* the engine (their Google event is deleted), never rendered in Google.
|
||||
*
|
||||
* This module is pure and client-safe; only the engine/client in
|
||||
* `src/utilities/googleCalendarSync.ts` touches the network.
|
||||
*/
|
||||
import type { Activity } from '@/payload-types'
|
||||
|
||||
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
|
||||
import { buildActivityDescriptionParts } from '@/lib/activityDescription'
|
||||
|
||||
const GOOGLE_EVENT_ID_PREFIX = 'teqo'
|
||||
|
||||
/** RFC 2938 base32hex alphabet (0–9, a–v) — the only charset Google event ids accept. */
|
||||
const BASE32HEX_ALPHABET = '0123456789abcdefghijklmnopqrstuv'
|
||||
|
||||
/**
|
||||
* Brazil standard time is fixed UTC−03:00 (no DST since 2019), so the offset
|
||||
* is a constant — the same invariant `campaignTime` builds on.
|
||||
*/
|
||||
const BAHIA_FIXED_OFFSET_HOURS = -3
|
||||
const BAHIA_FIXED_OFFSET_SUFFIX = '-03:00'
|
||||
|
||||
/**
|
||||
* Google requires `end` strictly after `start` on timed events; an activity
|
||||
* without `endAt` (a point-in-time commitment) becomes a 1-hour event — the
|
||||
* iCal feed mirrors this as DTEND = DTSTART, which Google cannot represent.
|
||||
*/
|
||||
const DEFAULT_TIMED_EVENT_DURATION_MINUTES = 60
|
||||
|
||||
export const googleEventIdForActivity = (activityId: number): string => {
|
||||
if (!Number.isInteger(activityId) || activityId < 0) {
|
||||
throw new Error('ID de atividade inválido para evento do Google.')
|
||||
}
|
||||
|
||||
let remainder = activityId
|
||||
let encoded = ''
|
||||
do {
|
||||
encoded = BASE32HEX_ALPHABET[remainder % 32] + encoded
|
||||
remainder = Math.floor(remainder / 32)
|
||||
} while (remainder > 0)
|
||||
|
||||
return `${GOOGLE_EVENT_ID_PREFIX}${encoded}`
|
||||
}
|
||||
|
||||
/** Inverse of `googleEventIdForActivity` — null for foreign/invalid ids. */
|
||||
export const decodeGoogleEventActivityId = (eventId: string): number | null => {
|
||||
if (!eventId.startsWith(GOOGLE_EVENT_ID_PREFIX)) return null
|
||||
const encoded = eventId.slice(GOOGLE_EVENT_ID_PREFIX.length)
|
||||
if (encoded.length === 0) return null
|
||||
|
||||
let value = 0
|
||||
for (const char of encoded) {
|
||||
const digit = BASE32HEX_ALPHABET.indexOf(char)
|
||||
if (digit === -1) return null
|
||||
value = value * 32 + digit
|
||||
if (!Number.isSafeInteger(value)) return null
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC ISO instant → RFC 3339 dateTime in Bahia wall time with the fixed
|
||||
* −03:00 offset (what the Calendar API expects; the timeZone field stays
|
||||
* unnecessary because the offset is explicit).
|
||||
*/
|
||||
const formatBahiaOffsetDateTime = (iso: string): string => {
|
||||
const shifted = new Date(new Date(iso).getTime() + BAHIA_FIXED_OFFSET_HOURS * 3_600_000)
|
||||
const parts = shifted.toISOString().slice(0, 19).split('T')
|
||||
const [date, time] = parts
|
||||
const [year, month, day] = date.split('-')
|
||||
const [hour, minute, second] = time.split(':')
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}${BAHIA_FIXED_OFFSET_SUFFIX}`
|
||||
}
|
||||
|
||||
const addMinutes = (iso: string, minutes: number): string =>
|
||||
new Date(new Date(iso).getTime() + minutes * 60_000).toISOString()
|
||||
|
||||
export const activityMunicipalityIdOf = (
|
||||
municipality: Activity['municipality'],
|
||||
): number | undefined => (typeof municipality === 'number' ? municipality : municipality?.id)
|
||||
|
||||
export type GoogleCalendarEventPayload = {
|
||||
id: string
|
||||
summary: string
|
||||
description: string
|
||||
location?: string
|
||||
start: { dateTime: string } | { date: string }
|
||||
end: { dateTime: string } | { date: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Event description without unnecessary PII: municipality, locality, tags and
|
||||
* the deputy flag — the same parts the iCal feed renders, joined with real
|
||||
* newlines for Google. No leadership names, no phones, no emails.
|
||||
*/
|
||||
export const buildGoogleCalendarDescription = (
|
||||
activity: Pick<Activity, 'locality' | 'tags' | 'deputyPresent'>,
|
||||
municipalityName?: string,
|
||||
): string => buildActivityDescriptionParts(activity, municipalityName).join('\n')
|
||||
|
||||
export const buildGoogleEventPayload = (
|
||||
activity: Pick<
|
||||
Activity,
|
||||
'id' | 'title' | 'startAt' | 'endAt' | 'allDay' | 'locality' | 'tags' | 'deputyPresent'
|
||||
>,
|
||||
municipalityName?: string,
|
||||
): GoogleCalendarEventPayload => {
|
||||
// C14 guarantees startAt beyond draft; a null start has no Google shape —
|
||||
// fail loudly in the mapping (the engine never feeds one: the window query
|
||||
// only returns dated activities).
|
||||
if (!activity.startAt) {
|
||||
throw new Error('Atividade sem data de início não pode virar evento do Google.')
|
||||
}
|
||||
|
||||
const summary = municipalityName ? `[${municipalityName}] ${activity.title}` : activity.title
|
||||
const description = buildGoogleCalendarDescription(activity, municipalityName)
|
||||
|
||||
const allDay = Boolean(activity.allDay)
|
||||
const start = allDay
|
||||
? { date: allDayCivilDateOf(activity.startAt) }
|
||||
: { dateTime: formatBahiaOffsetDateTime(activity.startAt) }
|
||||
const end = allDay
|
||||
? { date: allDayExclusiveEndDate(activity.endAt ?? activity.startAt) }
|
||||
: {
|
||||
dateTime: formatBahiaOffsetDateTime(
|
||||
activity.endAt ?? addMinutes(activity.startAt, DEFAULT_TIMED_EVENT_DURATION_MINUTES),
|
||||
),
|
||||
}
|
||||
|
||||
const locality = activity.locality?.trim()
|
||||
const location = locality || municipalityName || undefined
|
||||
|
||||
return {
|
||||
id: googleEventIdForActivity(activity.id),
|
||||
summary,
|
||||
description,
|
||||
...(location ? { location } : {}),
|
||||
start,
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
/** The remote shape the Calendar API echoes on list/insert — a narrow view of it. */
|
||||
export type GoogleRemoteEvent = {
|
||||
id?: string
|
||||
summary?: string
|
||||
description?: string
|
||||
location?: string
|
||||
start?: { dateTime?: string; date?: string; timeZone?: string }
|
||||
end?: { dateTime?: string; date?: string; timeZone?: string }
|
||||
}
|
||||
|
||||
const startEndInstantEquals = (
|
||||
remote: { dateTime?: string; date?: string } | undefined,
|
||||
wanted: GoogleCalendarEventPayload['start'],
|
||||
): boolean => {
|
||||
if (!remote) return false
|
||||
if ('dateTime' in wanted) {
|
||||
// Compare absolute instants: the calendar's own timezone may echo the
|
||||
// dateTime with a different offset — the instant is what we control.
|
||||
const remoteMs = Date.parse(remote.dateTime ?? '')
|
||||
const wantedMs = Date.parse(wanted.dateTime)
|
||||
return !Number.isNaN(remoteMs) && !Number.isNaN(wantedMs) && remoteMs === wantedMs
|
||||
}
|
||||
return remote.date === wanted.date
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the remote event already carries exactly what we would write —
|
||||
* the decision rule that makes repeated reconciliation passes converge
|
||||
* without touching Google (no update churn, no modified-date spam).
|
||||
*/
|
||||
export const googleEventContentEquals = (
|
||||
remote: GoogleRemoteEvent,
|
||||
payload: GoogleCalendarEventPayload,
|
||||
): boolean =>
|
||||
(remote.summary ?? '') === payload.summary &&
|
||||
(remote.description ?? '') === payload.description &&
|
||||
(remote.location ?? '') === (payload.location ?? '') &&
|
||||
startEndInstantEquals(remote.start, payload.start) &&
|
||||
startEndInstantEquals(remote.end, payload.end)
|
||||
28
src/lib/googleCalendarLink.ts
Normal file
28
src/lib/googleCalendarLink.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* C114 — public "add this calendar" links for the campaign's shared Google
|
||||
* calendar. The calendar itself is created in the campaign's Google account
|
||||
* (never by a service account — Google's recommendation) and shared publicly
|
||||
* by link (ops runbook); these URLs are what the team receives:
|
||||
*
|
||||
* - Google Calendar: the `cid` webcal URL (the established add-by-URL flow the
|
||||
* iCal feed dialog already uses, `calendar/r/settings/addbyurl`).
|
||||
* - Apple Calendar / Outlook: subscribe straight to the webcal URL the Google
|
||||
* calendar exposes for public calendars.
|
||||
*
|
||||
* Both require the calendar to be public in the owner's sharing settings —
|
||||
* the link IS the credential, same model as the iCal feed secret.
|
||||
*/
|
||||
const GOOGLE_CALENDAR_ADD_BASE = 'https://calendar.google.com/calendar/r'
|
||||
const GOOGLE_CALENDAR_ICAL_BASE = 'https://calendar.google.com/calendar/ical'
|
||||
|
||||
export const buildGoogleCalendarWebcalUrl = (calendarId: string): string =>
|
||||
`webcal://calendar.google.com/calendar/ical/${encodeURIComponent(calendarId)}/public/basic.ics`
|
||||
|
||||
export const buildGoogleCalendarAddLink = (calendarId: string): string => {
|
||||
const webcal = buildGoogleCalendarWebcalUrl(calendarId)
|
||||
return `${GOOGLE_CALENDAR_ADD_BASE}?cid=${encodeURIComponent(webcal)}`
|
||||
}
|
||||
|
||||
/** The public iCal URL of the shared calendar — same `cid` target, plain HTTPS. */
|
||||
export const buildGoogleCalendarPublicIcalUrl = (calendarId: string): string =>
|
||||
`${GOOGLE_CALENDAR_ICAL_BASE}/${encodeURIComponent(calendarId)}/public/basic.ics`
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
getMunicipalityCatalogEntry,
|
||||
municipalityCatalog,
|
||||
municipalityCatalogEntriesForCity,
|
||||
ZONE_MUNICIPALITY_CITIES,
|
||||
type MunicipalityCatalogEntry,
|
||||
} from '@/lib/municipalityCatalog'
|
||||
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
|
|
@ -31,6 +32,7 @@ export type ResolvedPortfolioEntry = {
|
|||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
city: string
|
||||
region: BahiaIdentityTerritory
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +44,14 @@ export type MunicipalityPortfolioChip =
|
|||
territory: BahiaIdentityTerritory
|
||||
municipalityIds: number[]
|
||||
}
|
||||
| {
|
||||
/** A zone-city (Salvador) with ALL of its zones assigned — "Salvador (19)". */
|
||||
kind: 'city'
|
||||
key: string
|
||||
label: string
|
||||
city: string
|
||||
municipalityIds: number[]
|
||||
}
|
||||
| {
|
||||
kind: 'municipality'
|
||||
key: string
|
||||
|
|
@ -97,6 +107,7 @@ type PortfolioIndexDerivations = {
|
|||
byId: Map<number, ResolvedPortfolioEntry>
|
||||
idBySlug: Map<string, number>
|
||||
idsByTerritory: Map<BahiaIdentityTerritory, number[]>
|
||||
idsByCity: Map<string, number[]>
|
||||
normalizedNames: Map<number, string>
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +134,7 @@ const portfolioIndexDerivations = (
|
|||
byId: new Map(),
|
||||
idBySlug: new Map(),
|
||||
idsByTerritory: new Map(),
|
||||
idsByCity: new Map(),
|
||||
normalizedNames: new Map(),
|
||||
}
|
||||
for (const entry of index) {
|
||||
|
|
@ -137,6 +149,7 @@ const portfolioIndexDerivations = (
|
|||
id: entry.id,
|
||||
slug: entry.slug,
|
||||
name: catalogEntry.name,
|
||||
city: catalogEntry.city,
|
||||
region: catalogEntry.region,
|
||||
}
|
||||
derived.byId.set(resolved.id, resolved)
|
||||
|
|
@ -145,6 +158,9 @@ const portfolioIndexDerivations = (
|
|||
const territoryIds = derived.idsByTerritory.get(resolved.region)
|
||||
if (territoryIds) territoryIds.push(resolved.id)
|
||||
else derived.idsByTerritory.set(resolved.region, [resolved.id])
|
||||
const cityIds = derived.idsByCity.get(resolved.city)
|
||||
if (cityIds) cityIds.push(resolved.id)
|
||||
else derived.idsByCity.set(resolved.city, [resolved.id])
|
||||
}
|
||||
|
||||
derivationsByIndex.set(index, derived)
|
||||
|
|
@ -208,7 +224,11 @@ const municipalityIdsForTseZone = (zoneNumber: number, idBySlug: Map<string, num
|
|||
}
|
||||
|
||||
/**
|
||||
* Collapse complete territory memberships into a single territory chip.
|
||||
* Collapse complete territory memberships into a single territory chip, and a
|
||||
* complete zone-city (all of Salvador's 19 zones) into one "Salvador (N)" chip.
|
||||
* The city group is checked FIRST: the zones share the city's territory, so
|
||||
* without it the complete set would collapse into the territory chip
|
||||
* ("Metropolitano de Salvador (19)") instead of the intended "Salvador (19)".
|
||||
*
|
||||
* Labels and slugs come from `index` — the complete catalog by construction — so
|
||||
* an id it does not know cannot be rendered (no name, no link) and is dropped
|
||||
|
|
@ -218,10 +238,27 @@ export const buildMunicipalityPortfolioChips = (
|
|||
assignedMunicipalityIds: readonly number[],
|
||||
index: readonly MunicipalityPortfolioIndexEntry[],
|
||||
): MunicipalityPortfolioChip[] => {
|
||||
const { byId, idsByTerritory } = portfolioIndexDerivations(index)
|
||||
const { byId, idsByTerritory, idsByCity } = portfolioIndexDerivations(index)
|
||||
const remaining = new Set(assignedMunicipalityIds)
|
||||
const chips: MunicipalityPortfolioChip[] = []
|
||||
|
||||
for (const city of ZONE_MUNICIPALITY_CITIES) {
|
||||
const cityIds = idsByCity.get(city)
|
||||
if (!cityIds || cityIds.length === 0) continue
|
||||
// A zone-city group never fits in fewer ids than it has (19 for Salvador).
|
||||
if (cityIds.length > remaining.size) continue
|
||||
if (!cityIds.every((id) => remaining.has(id))) continue
|
||||
|
||||
chips.push({
|
||||
kind: 'city',
|
||||
key: `city:${city}`,
|
||||
label: city,
|
||||
city,
|
||||
municipalityIds: [...cityIds],
|
||||
})
|
||||
for (const id of cityIds) remaining.delete(id)
|
||||
}
|
||||
|
||||
for (const territory of bahiaIdentityTerritories) {
|
||||
const territoryIds = idsByTerritory.get(territory)
|
||||
if (!territoryIds || territoryIds.length === 0) continue
|
||||
|
|
@ -365,3 +402,35 @@ export const searchMunicipalityPortfolio = (
|
|||
|
||||
return hits
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace expanded batch chips (territory / zone-city) with their member
|
||||
* municipality chips, in place, keeping every other chip untouched. Pure
|
||||
* presentation: nothing here writes — the members are real municipalities, so
|
||||
* they carry the slug for the detail link and their own `ids` for removal.
|
||||
*/
|
||||
export const expandMunicipalityPortfolioChips = (
|
||||
chips: readonly MunicipalityPortfolioChip[],
|
||||
expandedKeys: ReadonlySet<string>,
|
||||
index: readonly MunicipalityPortfolioIndexEntry[],
|
||||
): MunicipalityPortfolioChip[] => {
|
||||
if (expandedKeys.size === 0) return [...chips]
|
||||
const { byId } = portfolioIndexDerivations(index)
|
||||
return chips.flatMap((chip) => {
|
||||
if (!expandedKeys.has(chip.key)) return [chip]
|
||||
if (chip.kind !== 'territory' && chip.kind !== 'city') return [chip]
|
||||
const members: MunicipalityPortfolioChip[] = []
|
||||
for (const id of chip.municipalityIds) {
|
||||
const entry = byId.get(id)
|
||||
if (!entry) continue
|
||||
members.push({
|
||||
kind: 'municipality',
|
||||
key: `municipality:${id}`,
|
||||
label: entry.name,
|
||||
municipalityId: id,
|
||||
slug: entry.slug,
|
||||
})
|
||||
}
|
||||
return members
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ const BRAZILIAN_MOBILE_PHONE_LENGTH = 11
|
|||
/** One refusal copy for the URL builder, the zod primitive and the Contact hook — rewording in one layer only would give the same refusal two phrasings. */
|
||||
export const BRAZILIAN_PHONE_INVALID_MESSAGE = 'Celular brasileiro inválido.'
|
||||
|
||||
/** A ficha may not hold the same number twice (the person already has it); sharing a number BETWEEN fichas stays allowed (C111). */
|
||||
export const BRAZILIAN_PHONE_DUPLICATE_MESSAGE = 'Telefone repetido na ficha.'
|
||||
|
||||
export const sanitizeBrazilianPhoneInput = (value: string): string => {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
const domesticDigits =
|
||||
|
|
@ -61,3 +64,35 @@ export const whatsAppHrefForPhone = (phone: string | null): string | null => {
|
|||
if (!phone || !normalizeBrazilianPhone(phone)) return null
|
||||
return buildWhatsAppUrl(phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* The person's primary phone from the `Contact.phones` array: order is
|
||||
* priority (first = primary), so the row/list/WhatsApp/convite readers all go
|
||||
* through this single translation and never touch the array shape.
|
||||
*/
|
||||
export const primaryPhoneOf = (
|
||||
phones: ReadonlyArray<{ value?: string | null }> | null | undefined,
|
||||
): string | null => phones?.[0]?.value || null
|
||||
|
||||
/** Every number of the ficha in priority order, empties dropped. */
|
||||
export const phoneValuesOf = (
|
||||
phones: ReadonlyArray<{ value?: string | null }> | null | undefined,
|
||||
): string[] =>
|
||||
(phones ?? []).map((entry) => entry.value).filter((value): value is string => Boolean(value))
|
||||
|
||||
/**
|
||||
* Edit the primary phone preserving the rest: the next primary goes first
|
||||
* (any earlier occurrence removed — the number the person already had moves,
|
||||
* never duplicates), and an empty `primary` removes the current first number
|
||||
* so the rest shifts up.
|
||||
*/
|
||||
export const reorderWithPrimaryPhone = (
|
||||
phones: ReadonlyArray<{ value?: string | null }> | null | undefined,
|
||||
primary: string | null,
|
||||
): string[] => {
|
||||
const current = (phones ?? [])
|
||||
.map((entry) => entry.value)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
const rest = current.filter((phone) => phone !== primary)
|
||||
return primary ? [primary, ...rest] : current.slice(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { CitiesByState } from '@/lib/cities'
|
||||
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
|
|
@ -44,6 +45,20 @@ const nullableContactPhone = contactPhoneInput.transform((value) =>
|
|||
value === '' || value === undefined ? null : value,
|
||||
)
|
||||
|
||||
/**
|
||||
* The ficha's phone list: valid mobiles, never the same number twice within
|
||||
* the SAME person (C112 — the mesa blocks it at the form); sharing a number
|
||||
* BETWEEN persons stays free (C111). Order = priority (first = primary).
|
||||
*/
|
||||
export const contactPhonesSchema = z.array(brazilianMobile).superRefine((phones, context) => {
|
||||
if (new Set(phones).size !== phones.length) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/** Per-field Contact write shared by campaign person joins. */
|
||||
export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
|
||||
z.object({
|
||||
|
|
@ -61,6 +76,16 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
|
|||
field: z.literal('phone'),
|
||||
phone: nullableContactPhone,
|
||||
}),
|
||||
z.object({
|
||||
id: positiveRelationshipId,
|
||||
field: z.literal('phones'),
|
||||
phones: contactPhonesSchema,
|
||||
}),
|
||||
z.object({
|
||||
id: positiveRelationshipId,
|
||||
field: z.literal('city'),
|
||||
city: z.string().trim().min(2, 'Cidade inválida').max(100, 'Cidade muito longa'),
|
||||
}),
|
||||
])
|
||||
|
||||
export type ContactFieldUpdateInput = z.input<typeof contactFieldUpdateSchema>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { contactFieldUpdateSchema } from '@/lib/schemas/contact'
|
||||
import { contactFieldUpdateSchema, contactPhonesSchema } from '@/lib/schemas/contact'
|
||||
import {
|
||||
brazilianMobile,
|
||||
optionalPersistedEmail,
|
||||
positiveRelationshipId,
|
||||
trimmedNullableText,
|
||||
|
|
@ -15,6 +14,7 @@ export const leadershipSupportStatuses = [
|
|||
'engajado',
|
||||
'a_abordar',
|
||||
'em_disputa',
|
||||
'lembranca',
|
||||
'negativo',
|
||||
] as const
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ export const leadershipCreateSchema = z.object({
|
|||
organizations: organizationsArraySchema.optional(),
|
||||
stateDeputies: stateDeputiesArraySchema.optional(),
|
||||
name: z.string().trim().min(2).max(120),
|
||||
phone: brazilianMobile,
|
||||
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
|
||||
email: optionalPersistedEmail,
|
||||
gender: z.enum(leadershipGenders).optional(),
|
||||
exclusive: z.boolean().default(true),
|
||||
|
|
@ -113,7 +113,7 @@ export type LeadershipInternalUpdateInput = z.input<typeof leadershipInternalUpd
|
|||
|
||||
const leadershipWizardFieldsSchema = z.object({
|
||||
name: z.string().trim().min(2).max(120),
|
||||
phone: brazilianMobile,
|
||||
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
|
||||
email: optionalPersistedEmail,
|
||||
exclusive: z.boolean().default(true),
|
||||
supportStatus: z.enum(leadershipSupportStatuses).default('a_abordar'),
|
||||
|
|
|
|||
39
src/lib/schemas/personCell.ts
Normal file
39
src/lib/schemas/personCell.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { positiveRelationshipId } from '@/lib/schemas/primitives'
|
||||
|
||||
/**
|
||||
* C116 — cell-edit contracts of `/campanha/pessoas`: per-field Contact writes
|
||||
* (via the shared `contactFieldUpdateSchema`), the Assessora carteira batch
|
||||
* (unrestricted-only, person with exactly ONE staff account) and the
|
||||
* person-centric Assessorado advisor delta.
|
||||
*/
|
||||
|
||||
export const personAssessoraMembershipSchema = z.object({
|
||||
contactId: positiveRelationshipId,
|
||||
municipalityIds: z.array(positiveRelationshipId).min(1),
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export const personAdvisorMembershipSchema = z.object({
|
||||
contactId: positiveRelationshipId,
|
||||
advisorId: positiveRelationshipId,
|
||||
assigned: z.boolean(),
|
||||
})
|
||||
|
||||
export type PersonAssessoraMembershipInput = z.input<typeof personAssessoraMembershipSchema>
|
||||
export type PersonAdvisorMembershipInput = z.input<typeof personAdvisorMembershipSchema>
|
||||
|
||||
/** Refusal messages shared verbatim between the server action and the cell. */
|
||||
export const PERSON_CELL_STAFF_MESSAGE =
|
||||
'Somente a coordenação e o candidato podem editar essa pessoa.'
|
||||
export const PERSON_CELL_NOT_IN_SCOPE_MESSAGE = 'Esta pessoa não está no seu escopo de edição.'
|
||||
export const PERSON_CONTACT_INVALID_MESSAGE = 'Esta pessoa não tem ficha de contato.'
|
||||
export const PERSON_ASSESSORA_UNRESTRICTED_MESSAGE =
|
||||
'Somente a coordenação e o candidato podem editar a carteira de assessores.'
|
||||
export const PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE =
|
||||
'Esta pessoa não tem perfil de assessor para vincular municípios.'
|
||||
export const PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE =
|
||||
'Esta pessoa tem mais de um perfil de assessor; edite os municípios por perfil no admin.'
|
||||
export const PERSON_ADVISORS_UNRESTRICTED_MESSAGE =
|
||||
'Somente a coordenação e o candidato podem editar os assessores.'
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
|
||||
import { contactPhonesSchema } from '@/lib/schemas/contact'
|
||||
import {
|
||||
brazilianMobile,
|
||||
optionalPersistedEmail,
|
||||
positiveRelationshipId,
|
||||
trimmedNullableText,
|
||||
|
|
@ -70,7 +70,7 @@ const optionalBahiaCity = z
|
|||
export const supporterCreateSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(2).max(120),
|
||||
phone: brazilianMobile,
|
||||
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
|
||||
email: optionalPersistedEmail,
|
||||
city: optionalBahiaCity,
|
||||
municipality: positiveRelationshipId.optional(),
|
||||
|
|
@ -108,7 +108,7 @@ export const supporterImportConfirmSchema = z.object({
|
|||
|
||||
export const leaderSupporterCreateSchema = z.object({
|
||||
name: z.string().trim().min(2).max(120),
|
||||
phone: brazilianMobile,
|
||||
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
|
||||
city: optionalBahiaCity,
|
||||
municipality: positiveRelationshipId,
|
||||
consentAccepted: z.literal(true, {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type { SupportStatus } from '@/lib/schemas/leadership'
|
|||
export type WizardLeadershipTileViewModel = {
|
||||
id: number
|
||||
name: string
|
||||
/** Every number of the ficha, order = priority (C112). */
|
||||
phones: string[]
|
||||
phone: string | null
|
||||
email: string | null
|
||||
supportStatus: SupportStatus | null
|
||||
|
|
|
|||
13337
src/migrations/20260811_111828_add_contact_phones_array.json
Normal file
13337
src/migrations/20260811_111828_add_contact_phones_array.json
Normal file
File diff suppressed because it is too large
Load diff
41
src/migrations/20260811_111828_add_contact_phones_array.ts
Normal file
41
src/migrations/20260811_111828_add_contact_phones_array.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
// Hand-edited on top of the generated statements: the generator re-emitted
|
||||
// `campaign_user.contact_id` / `leadership_rels.campaign_user_id` because the
|
||||
// 20260809_204728 snapshot (.json) does not reflect its hand-written SQL —
|
||||
// those columns already exist in every environment (C99/C100). Kept here: the
|
||||
// `contact_phones` join table (order = priority, index on `value`), a backfill
|
||||
// of the existing single `phone` into `phones[0]` BEFORE the column drops, and
|
||||
// the drop of `contact.phone` (+ its index). Production holds real PII: the
|
||||
// backfill is lossless, the drop happens only after it.
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "contact_phones" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"value" varchar
|
||||
);
|
||||
|
||||
ALTER TABLE "contact_phones" ADD CONSTRAINT "contact_phones_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "contact_phones_order_idx" ON "contact_phones" USING btree ("_order");
|
||||
CREATE INDEX "contact_phones_parent_id_idx" ON "contact_phones" USING btree ("_parent_id");
|
||||
CREATE INDEX "contact_phones_value_idx" ON "contact_phones" USING btree ("value");
|
||||
INSERT INTO "contact_phones" ("id", "_parent_id", "_order", "value")
|
||||
SELECT gen_random_uuid()::text, "id", 0, "phone" FROM "contact" WHERE "phone" IS NOT NULL;
|
||||
DROP INDEX "contact_phone_idx";
|
||||
ALTER TABLE "contact" DROP COLUMN "phone";`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
// Rollback is lossy BY DESIGN: only the primary (`_order` 0) is restored to
|
||||
// `contact.phone` — every appended number is discarded. Rollbacks are
|
||||
// best-effort here (C111-era policy); the forward path is lossless.
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "contact" ADD COLUMN "phone" varchar;
|
||||
UPDATE "contact" SET "phone" = "contact_phones"."value"
|
||||
FROM "contact_phones" WHERE "contact_phones"."_parent_id" = "contact"."id" AND "contact_phones"."_order" = 0;
|
||||
CREATE INDEX "contact_phone_idx" ON "contact" USING btree ("phone");
|
||||
ALTER TABLE "contact_phones" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "contact_phones" CASCADE;`)
|
||||
}
|
||||
43
src/migrations/20260811_133332_add_google_calendar_sync.ts
Normal file
43
src/migrations/20260811_133332_add_google_calendar_sync.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* C114 — `googleCalendarSync` collection: the configuration/state row of the
|
||||
* campaign→Google calendar mirror. Hand-written because `migrate:create`
|
||||
* re-emitted pre-existing `campaign_user.contact_id` /
|
||||
* `leadership_rels.campaign_user_id` statements (already applied by
|
||||
* `20260809_204728_add_campaign_user_contact_and_leadership_advisors` — those
|
||||
* would fail with "column already exists" on apply; kept out deliberately).
|
||||
*/
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "google_calendar_sync" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"calendar_id" varchar,
|
||||
"disabled_at" timestamp(3) with time zone,
|
||||
"last_synced_at" timestamp(3) with time zone,
|
||||
"last_success_at" timestamp(3) with time zone,
|
||||
"last_error_at" timestamp(3) with time zone,
|
||||
"last_error" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "google_calendar_sync_calendar_id_idx" ON "google_calendar_sync" USING btree ("calendar_id");
|
||||
CREATE INDEX "google_calendar_sync_disabled_at_idx" ON "google_calendar_sync" USING btree ("disabled_at");
|
||||
CREATE INDEX "google_calendar_sync_updated_at_idx" ON "google_calendar_sync" USING btree ("updated_at");
|
||||
CREATE INDEX "google_calendar_sync_created_at_idx" ON "google_calendar_sync" USING btree ("created_at");
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "google_calendar_sync_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_google_calendar_sync_fk" FOREIGN KEY ("google_calendar_sync_id") REFERENCES "public"."google_calendar_sync"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_google_calendar_sync_id_idx" ON "payload_locked_documents_rels" USING btree ("google_calendar_sync_id");
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_google_calendar_sync_fk";
|
||||
DROP INDEX "payload_locked_documents_rels_google_calendar_sync_id_idx";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "google_calendar_sync_id";
|
||||
DROP TABLE "google_calendar_sync" CASCADE;
|
||||
`)
|
||||
}
|
||||
13502
src/migrations/20260811_155116_add_lembranca_support_status.json
Normal file
13502
src/migrations/20260811_155116_add_lembranca_support_status.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,16 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TYPE "public"."enum_leadership_support_status" ADD VALUE 'lembranca' BEFORE 'negativo';`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "leadership" ALTER COLUMN "support_status" SET DATA TYPE text;
|
||||
ALTER TABLE "leadership" ALTER COLUMN "support_status" SET DEFAULT 'a_abordar'::text;
|
||||
DROP TYPE "public"."enum_leadership_support_status";
|
||||
CREATE TYPE "public"."enum_leadership_support_status" AS ENUM('engajado', 'a_abordar', 'em_disputa', 'negativo');
|
||||
ALTER TABLE "leadership" ALTER COLUMN "support_status" SET DEFAULT 'a_abordar'::"public"."enum_leadership_support_status";
|
||||
ALTER TABLE "leadership" ALTER COLUMN "support_status" SET DATA TYPE "public"."enum_leadership_support_status" USING "support_status"::"public"."enum_leadership_support_status";`)
|
||||
}
|
||||
|
|
@ -47,6 +47,9 @@ import * as migration_20260808_134911_add_calendar_feed from './20260808_134911_
|
|||
import * as migration_20260808_184113_remodel_activity_responsible from './20260808_184113_remodel_activity_responsible';
|
||||
import * as migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors from './20260809_204728_add_campaign_user_contact_and_leadership_advisors';
|
||||
import * as migration_20260810_010844_add_activity_all_day from './20260810_010844_add_activity_all_day';
|
||||
import * as migration_20260811_111828_add_contact_phones_array from './20260811_111828_add_contact_phones_array';
|
||||
import * as migration_20260811_133332_add_google_calendar_sync from './20260811_133332_add_google_calendar_sync';
|
||||
import * as migration_20260811_155116_add_lembranca_support_status from './20260811_155116_add_lembranca_support_status';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -294,4 +297,19 @@ export const migrations = [
|
|||
down: migration_20260810_010844_add_activity_all_day.down,
|
||||
name: '20260810_010844_add_activity_all_day',
|
||||
},
|
||||
{
|
||||
up: migration_20260811_111828_add_contact_phones_array.up,
|
||||
down: migration_20260811_111828_add_contact_phones_array.down,
|
||||
name: '20260811_111828_add_contact_phones_array',
|
||||
},
|
||||
{
|
||||
up: migration_20260811_133332_add_google_calendar_sync.up,
|
||||
down: migration_20260811_133332_add_google_calendar_sync.down,
|
||||
name: '20260811_133332_add_google_calendar_sync',
|
||||
},
|
||||
{
|
||||
up: migration_20260811_155116_add_lembranca_support_status.up,
|
||||
down: migration_20260811_155116_add_lembranca_support_status.down,
|
||||
name: '20260811_155116_add_lembranca_support_status',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export interface Config {
|
|||
municipalityUpdate: MunicipalityUpdate;
|
||||
activity: Activity;
|
||||
calendarFeed: CalendarFeed;
|
||||
googleCalendarSync: GoogleCalendarSync;
|
||||
electionTally: ElectionTally;
|
||||
electionCandidateVote: ElectionCandidateVote;
|
||||
electionCandidate: ElectionCandidate;
|
||||
|
|
@ -127,6 +128,7 @@ export interface Config {
|
|||
municipalityUpdate: MunicipalityUpdateSelect<false> | MunicipalityUpdateSelect<true>;
|
||||
activity: ActivitySelect<false> | ActivitySelect<true>;
|
||||
calendarFeed: CalendarFeedSelect<false> | CalendarFeedSelect<true>;
|
||||
googleCalendarSync: GoogleCalendarSyncSelect<false> | GoogleCalendarSyncSelect<true>;
|
||||
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
|
||||
electionCandidateVote: ElectionCandidateVoteSelect<false> | ElectionCandidateVoteSelect<true>;
|
||||
electionCandidate: ElectionCandidateSelect<false> | ElectionCandidateSelect<true>;
|
||||
|
|
@ -298,7 +300,12 @@ export interface Contact {
|
|||
id: number;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
phones?:
|
||||
| {
|
||||
value?: string | null;
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
|
||||
state:
|
||||
| 'AC'
|
||||
|
|
@ -583,7 +590,7 @@ export interface Leadership {
|
|||
stateDeputies?: (number | StateDeputy)[] | null;
|
||||
advisors?: (number | CampaignUser)[] | null;
|
||||
exclusive?: boolean | null;
|
||||
supportStatus: 'engajado' | 'a_abordar' | 'em_disputa' | 'negativo';
|
||||
supportStatus: 'engajado' | 'a_abordar' | 'em_disputa' | 'lembranca' | 'negativo';
|
||||
user?: (number | null) | CampaignUser;
|
||||
consent?: (number | null) | Consent;
|
||||
consentContentHash?: string | null;
|
||||
|
|
@ -885,6 +892,27 @@ export interface CalendarFeed {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "googleCalendarSync".
|
||||
*/
|
||||
export interface GoogleCalendarSync {
|
||||
id: number;
|
||||
/**
|
||||
* O calendarId do calendário "Agenda da Campanha" na conta Google da campanha (ex.: c_…@group.calendar.google.com). O calendário precisa estar público no link e a service account com permissão de edição.
|
||||
*/
|
||||
calendarId?: string | null;
|
||||
/**
|
||||
* Enquanto preenchido, o Teqo não escreve no Google.
|
||||
*/
|
||||
disabledAt?: string | null;
|
||||
lastSyncedAt?: string | null;
|
||||
lastSuccessAt?: string | null;
|
||||
lastErrorAt?: string | null;
|
||||
lastError?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Totais oficiais TSE por município e zona (dado público).
|
||||
*
|
||||
|
|
@ -1354,6 +1382,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'calendarFeed';
|
||||
value: number | CalendarFeed;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'googleCalendarSync';
|
||||
value: number | GoogleCalendarSync;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'electionTally';
|
||||
value: number | ElectionTally;
|
||||
|
|
@ -1853,6 +1885,20 @@ export interface CalendarFeedSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "googleCalendarSync_select".
|
||||
*/
|
||||
export interface GoogleCalendarSyncSelect<T extends boolean = true> {
|
||||
calendarId?: T;
|
||||
disabledAt?: T;
|
||||
lastSyncedAt?: T;
|
||||
lastSuccessAt?: T;
|
||||
lastErrorAt?: T;
|
||||
lastError?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "electionTally_select".
|
||||
|
|
@ -1977,7 +2023,12 @@ export interface PetitionSelect<T extends boolean = true> {
|
|||
export interface ContactSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
email?: T;
|
||||
phone?: T;
|
||||
phones?:
|
||||
| T
|
||||
| {
|
||||
value?: T;
|
||||
id?: T;
|
||||
};
|
||||
gender?: T;
|
||||
state?: T;
|
||||
city?: T;
|
||||
|
|
@ -2413,6 +2464,7 @@ export interface TaskCreateCollectionExport {
|
|||
| 'municipalityUpdate'
|
||||
| 'activity'
|
||||
| 'calendarFeed'
|
||||
| 'googleCalendarSync'
|
||||
| 'electionTally'
|
||||
| 'electionCandidateVote'
|
||||
| 'electionCandidate'
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { Contact } from './collections/Contact'
|
|||
import { ElectionCandidate } from './collections/ElectionCandidate'
|
||||
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
|
||||
import { ElectionTally } from './collections/ElectionTally'
|
||||
import { GoogleCalendarSync } from './collections/GoogleCalendarSync'
|
||||
import { Leadership } from './collections/Leadership'
|
||||
import { Media } from './collections/Media'
|
||||
import { Municipality } from './collections/Municipality'
|
||||
|
|
@ -107,6 +108,7 @@ export default buildConfig({
|
|||
MunicipalityUpdate,
|
||||
Activity,
|
||||
CalendarFeed,
|
||||
GoogleCalendarSync,
|
||||
ElectionTally,
|
||||
ElectionCandidateVote,
|
||||
ElectionCandidate,
|
||||
|
|
|
|||
46
src/utilities/access/googleCalendarSync.ts
Normal file
46
src/utilities/access/googleCalendarSync.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { Access, FieldAccess } from 'payload'
|
||||
|
||||
import {
|
||||
getFreshCampaignUser,
|
||||
isCampaignStaff,
|
||||
isPayloadAdmin,
|
||||
payloadAdminOnly,
|
||||
} from '@/utilities/access/shared'
|
||||
|
||||
/**
|
||||
* C114 — access for the `googleCalendarSync` configuration/state collection.
|
||||
* Staff (coordinator/advisor/candidate) read the sync state (pill) and toggle
|
||||
* `disabledAt`; only Payload admin sets the operational `calendarId`; the
|
||||
* system state fields are written solely by the sync engine through an
|
||||
* admin bypass — never by a user path.
|
||||
*/
|
||||
|
||||
export const canReadGoogleCalendarSync: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
export const canCreateGoogleCalendarSync: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
export const canUpdateGoogleCalendarSync: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
export const canDeleteGoogleCalendarSync: Access = payloadAdminOnly
|
||||
|
||||
/** `calendarId` — operational identity, admin-only (env-level trust). */
|
||||
export const canSetGoogleCalendarSyncConfigField: FieldAccess = ({ req }) =>
|
||||
isPayloadAdmin(req.user)
|
||||
|
||||
/** `disabledAt` — staff may pause/resume the sync from the agenda. */
|
||||
export const canSetGoogleCalendarSyncDisabled: FieldAccess = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
/** System state fields — only the sync engine writes them (overrideAccess). */
|
||||
export const canSetGoogleCalendarSyncSystemField: FieldAccess = () => false
|
||||
|
|
@ -42,7 +42,7 @@ export const searchActivityLeadershipOptions = async (
|
|||
{
|
||||
or: [
|
||||
{ 'contact.name': { contains: trimmed } },
|
||||
{ 'contact.phone': { contains: digits || trimmed } },
|
||||
{ 'contact.phones.value': { contains: digits || trimmed } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ const searchStateDeputyResponsibles = async (
|
|||
where: {
|
||||
or: [
|
||||
{ 'contact.name': { contains: trimmed } },
|
||||
{ 'contact.phone': { contains: digits || trimmed } },
|
||||
{ 'contact.phones.value': { contains: digits || trimmed } },
|
||||
],
|
||||
},
|
||||
depth: 1,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { formatAllDayRangeLabel } from '@/lib/activityAllDay'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { primaryPhoneOf } from '@/lib/phone'
|
||||
import { isPopulatedRelationship, populatedContactName, relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
activityResponsibleTypeLabels,
|
||||
|
|
@ -287,7 +288,7 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
|
|||
? {
|
||||
id: task.responsible.id,
|
||||
name: task.responsible.name,
|
||||
phone: task.responsible.phone ?? null,
|
||||
phone: primaryPhoneOf(task.responsible.phones),
|
||||
}
|
||||
: null,
|
||||
due: task.due ?? null,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { Contact } from '@/payload-types'
|
||||
|
||||
import { phoneValuesOf } from '@/lib/phone'
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
advisorListHrefForPage,
|
||||
|
|
@ -35,6 +38,10 @@ export type AdvisorRowViewModel = AdvisorAccountViewModel & {
|
|||
|
||||
/** The detail page renders real names and links, so it keeps the labels. */
|
||||
export type AdvisorDetailViewModel = AdvisorAccountViewModel & {
|
||||
/** The advisor's normalized ficha (C99) — target of the ficha phones editor. */
|
||||
contactID: number | null
|
||||
/** Every number of the ficha, order = priority (C112) — primary first. */
|
||||
fichaPhones: string[]
|
||||
municipalities: AdvisorMunicipalityViewModel[]
|
||||
}
|
||||
|
||||
|
|
@ -222,6 +229,7 @@ export const loadAdvisorDetail = async (
|
|||
email?: string | null
|
||||
phone?: string | null
|
||||
role: string
|
||||
contact?: number | Contact | null
|
||||
}
|
||||
try {
|
||||
// Intentional admin bypass — same rationale as `loadAdvisorListPageData`.
|
||||
|
|
@ -229,7 +237,7 @@ export const loadAdvisorDetail = async (
|
|||
collection: 'campaignUser',
|
||||
id: advisorId,
|
||||
depth: 0,
|
||||
select: { name: true, email: true, phone: true, role: true },
|
||||
select: { name: true, email: true, phone: true, role: true, contact: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
} catch {
|
||||
|
|
@ -238,12 +246,30 @@ export const loadAdvisorDetail = async (
|
|||
|
||||
if (advisor.role !== 'advisor') return null
|
||||
|
||||
const contactID = typeof advisor.contact === 'number' ? advisor.contact : null
|
||||
const fichaPhones =
|
||||
contactID === null
|
||||
? []
|
||||
: await payload
|
||||
.findByID({
|
||||
collection: 'contact',
|
||||
id: contactID,
|
||||
depth: 0,
|
||||
select: { phones: { value: true } },
|
||||
// Intentional admin bypass: same staff-scoped read rationale.
|
||||
overrideAccess: true,
|
||||
})
|
||||
.then((contact) => phoneValuesOf(contact.phones))
|
||||
.catch(() => [])
|
||||
|
||||
const municipalitiesByAdvisor = await municipalitiesByAdvisorIds(payload, [advisor.id])
|
||||
return {
|
||||
id: advisor.id,
|
||||
name: advisor.name,
|
||||
email: advisor.email ?? null,
|
||||
phone: advisor.phone ?? null,
|
||||
contactID,
|
||||
fichaPhones,
|
||||
municipalities: municipalitiesByAdvisor.get(advisor.id) ?? [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
|
|||
## Lideranças pendentes de abordagem
|
||||
- Use a ferramenta "getPendingLeaderships" para "quais lideranças ainda precisam ser abordadas em X", "lideranças sem assessor responsável" ou "quais municípios não têm liderança cadastrada".
|
||||
- A resposta da ferramenta traz o critério de pendência — declare-o sempre na sua resposta (ex.: "critério: status 'A abordar' ou 'Em disputa'; ou 'Engajado' sem compromisso de votos no escopo consultado").
|
||||
- Liderança com status "Lembrança" é favorável sem compromisso — não conta como pendente de abordagem; se perguntarem, explique isso e, se houver, aponte essas lideranças pela leitura normal de lideranças.
|
||||
- Escopos: território de identidade (aceita grafia sem acento, ex.: "Vale do Jiquiriça"), cidade ("Salvador" = as 19 zonas eleitorais juntas) ou município. Sem escopo, vale o escopo de acesso do usuário.
|
||||
- Quando a resposta trouxer "escopoRestrito: true", deixe claro que os resultados estão limitados aos municípios do portfólio do usuário; quando trouxer "truncado: true", sugira estreitar o escopo para ver o restante.
|
||||
- Ofereça links de navegação (buildCampaignLinks) para as lideranças citadas (por id) e municípios (por slug); assessores (por id) quando o perfil permitir.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue