mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
Merge pull request #478 from fsolla/C94-agenda-filtro-combobox-header-do-app-link-acoes-no-fab
C94 — Agenda: filtro combobox + header do app (link) + ações no FAB
This commit is contained in:
commit
8d0a571500
22 changed files with 1078 additions and 395 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -14,6 +14,7 @@
|
|||
/playwright-report/
|
||||
/test-results/
|
||||
/.playwright-cli/
|
||||
/.playwright-mcp/
|
||||
|
||||
# Aider (local agent history + tag cache)
|
||||
.aider*
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
**Recently resolved (2026-08-08):** **B174** (Issue #449) — a Sollinha (`/campanha/api/ai-chat`) trocou o provider de `@ai-sdk/deepseek` (API oficial `api.deepseek.com`) pelo **`@ai-sdk/deepinfra`** no caminho OpenAI-compatível da Deep Infra — **mesmo modelo** (`deepseek-ai/DeepSeek-V4-Flash`), **mesmas tools** e **mesma chave** que os agentes do time usam (um provedor, uma chave, uma fatura). Swap de 2 linhas no `route.ts`; dep `@ai-sdk/deepinfra@3.0.20` pinada na mesma ferroviária do `ai@7.0.47` (provider 4.0.4 / provider-utils 5.0.18, `pnpm why` confirma cópia única — nada de 4.0.7 duplicado), `@ai-sdk/deepseek` removido (dead dep, knip); `.env.example` `DEEPSEEK_API_KEY` → `DEEPINFRA_API_KEY` (B173 STT/Whisper reusa a mesma chave). Sem migration, access, UI ou teste novo — e2e já mocka o SSE (sem chave real no CI); rollback documentado = `git revert` (restaura provider/baseURL antiga); smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar o flip. Intenção [`sollinha-no-deepinfra.md`](plans/sollinha-no-deepinfra.md); impl [`sollinha-no-deepinfra-impl.md`](plans/sollinha-no-deepinfra-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-08):** **B170** (Issue #441, follow-up B169) — o **contrato do colapso de chips virou spec unitário** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): `RelationChipCell` renderiza 16 chips com `getBoundingClientRect` stubado (4/linha, 20px, gap 6 — a matemática real de `COLLAPSED_CHIP_ROWS`/`CHIP_GAP_PX`) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam `measureOverflow: false` ou colapsavam para 1 chip — a mudança de timing do B169 (medição em `useLayoutEffect`) não tinha regressão do comportamento de colapso. **F2 medido e deferido em vez de eliminado**: `PerformanceObserver('layout-shift')` no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo `97px/42chips → 57px/0chips` numa única transição; **ordenação client-side (RSC) → zero shifts novos**. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção [`estabilizar-lista-dobradinhas-followup.md`](docs/plans/estabilizar-lista-dobradinhas-followup.md); impl [`estabilizar-lista-dobradinhas-followup-impl.md`](docs/plans/estabilizar-lista-dobradinhas-followup-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **C94** (Issue #438) — barra da agenda consolidada: os 3 seletores (Município/Tag/Deputado presente) viraram **um combobox omnibox** (`activityAgendaOmnibox.ts` + chassis B127) com chips removíveis/“Limpar” e sugestões agrupadas; desktop toolbar `[combobox][carrinho giro][+ nova]` (ícones; mobile sem botão de criação no corpo — tudo no FAB); “Link de import” **saiu do card de filtros** para o **header do app** (`CampaignDesktopHeader`, **agenda-contextual**, ordem `[link][sino][IA]` do gate) via registro chaveado `SetCampaignHeaderAction` no `CampaignPageChromeContext` (slot compartilhado com C95), e virou ação de drawer no FAB mobile (`CampaignQuickAction.onAction` + `openCalendarFeed` no `CampaignQuickActionContext`) abrindo o **mesmo diálogo como sheet** responsivo (`CalendarFeedDialog` Dialog/Drawer; sem recorte mostra aviso “Aplique filtros…”, C93 cobre gerar sem filtro). O overlay do FAB fecha ao disparar ação de dialog. Sem migration/access/Consent. Intenção [`c94-agenda-combobox-header-acoes-rapidas.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas.md); impl [`c94-agenda-combobox-header-acoes-rapidas-impl.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md).
|
||||
|
||||
(feat(C94): toolbar omnibox + e2e agenda hardening + changelog)
|
||||
**Recently resolved (2026-08-08):** **`pnpm worktree plan [bag] [--go] [--no-migrate]`** — worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main: **cada invocação cria UM DIFERENTE**, para sessões de planejamento paralelas — com `bag`, branch `plans/plan-issue-<bag>` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-<n>` livre (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`). Prefixo minúsculo `plans/…` nunca colide com um `next` posterior em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do `next` (porta `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test`, migrations; sem Docker degrada ao compartilhado). `kill` cobre; comment das envs distingue `gerado por pnpm worktree plan`. Sem migration. Naming unit-testada.
|
||||
|
||||
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).
|
||||
|
|
|
|||
117
docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md
Normal file
117
docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Impl: C94 — Agenda: filtro combobox único, header compacto (ícones) e ações no FAB mobile
|
||||
|
||||
Status: em execução (aprovado pelo humano em 2026-08-09)
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #438
|
||||
Intenção: docs/plans/c94-agenda-combobox-header-acoes-rapidas.md
|
||||
Appetite restante: ~1–1,5 dia eng (herdado); sem ajuste
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** toolbar da agenda consolidada em um combobox único (chips removíveis + sugestões agrupadas), entrada de criação por viewport com um caminho só: desktop = `[combobox][carrinho giro][+ nova]` + "Link de import" como ícone agenda-contextual no header do app; mobile = sem botões de criação no corpo, tudo no drawer do FAB ("Nova atividade", "Link de import", "Planejar giro").
|
||||
- **O que NÃO negociar:** filtros hoje = Município (único) + Tag + Deputado presente (chip bool), sem busca textual nem multi-seleção; estado do filtro segue na URL (reload/share consistentes); "Link de import" desktop mora no **header do app**, não na toolbar; mobile abre o mesmo diálogo como **sheet**; sem regressão de criação inline (C91), giros, revogação de feeds, leader lockdown.
|
||||
- **O que reavaliar (hipóteses da intenção):**
|
||||
- A intenção sugere "ActivityAgendaFilters.tsx vira o adaptador do combobox" — confirmado, mas ele vira a **toolbar inteira** (combobox + ícones trailing), não apenas o adaptador.
|
||||
- A intenção sugere "slot no header vs renderizar no shell pela rota — executor decide". Decidir: **slot via contexto de chrome** (reusável por C95), não render por rota no shell.
|
||||
- A intenção não decide o que fazer com **"Link de import" no FAB quando não há filtros** (hoje o botão fica desabilitado). Decidir: abrir o sheet e explicar dentro (em vez de tile morta).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph AgendaPage[AGENDA PAGE · server]
|
||||
S[searchParams] --> P[CalendarFeedDialogProvider<br/>feeds · createFeed(state) · revokeFeed · canGenerate]
|
||||
end
|
||||
P -->|registra| H[SetCampaignHeaderAction id=calendar-feed]
|
||||
H -->|lê| DH[CampaignDesktopHeader · agenda-contextual]
|
||||
P -->|setContext openCalendarFeed| QK[CampaignQuickActionContext]
|
||||
QK --> resolveActivityQuickActions['list' + import-calendar onAction]
|
||||
QK --> FAB[FAB drawer · mobile]
|
||||
AF[ActivityAgendaFilters → tool bar omnibox] -->|trailing desktop| ICON[ícone carrinho giro + ícone + nova]
|
||||
AF --> URN[useCampaignListFilterNavigation → buildActivityAgendaHref]
|
||||
```
|
||||
|
||||
**Opções consideradas (combobox):** A) omnibox chassis existente (`CampaignListOmnibox` B127) | B) `StrictCombobox`/combobox mobile avulso | C) manter selects nativos.
|
||||
**Recomendação:** A — reusa o chassis já estabelecido nas listas; o adaptador segue o padrão `activityOmnibox.ts` (chips/sugestões puros + tests B128).
|
||||
**Rejeitadas:** B (chassis não tem chips/teclado do omnibox — duplicaria); C (é exatamente a duplicação que a Issue quer eliminar).
|
||||
|
||||
**Opções consideradas (header "Link de import"):** A) slot por contexto de chrome (extender `CampaignPageChromeContext` com registro de ações de header) | B) renderizar no shell pela rota (`usePathname` no header + self-fetch de feeds/state via `useSearchParams`) | C) link simples para o dialog roteado.
|
||||
**Recomendação:** A — "edit the owner": o contexto de chrome JÁ é o mecanismo por-página do shell; vira registro chaveado de `ReactNode` (reusável por C95 que precisa do mesmo header/slot). Mantém o conhecimento de domínio (feeds/state/action) na vertical de atividade.
|
||||
**Rejeitadas:** B (acopla o shell compartilhado à URL/schema da agenda e exige `useSearchParams` no layout → Suspense; é o "renderizar na rota" que a intenção flagrou como risco); C (o diálogo não é rota; precisa de state).
|
||||
|
||||
**Opções consideradas (FAB "Link de import" mobile):** A) estender `CampaignQuickAction` com `onAction` + `openCalendarFeed` no contexto de quick actions | B) ação href para uma rota de wizzard | C) overlay próprio da agenda.
|
||||
**Recomendação:** A — `CampaignHomeActionButton` JÁ suporta `onClick`; falta só o fio pelo overlay; o contexto de quick actions já é a ponte página→shell (precedente `CampaignQuickActionContextSync/Bridge`).
|
||||
**Rejeitadas:** B (diálogo não é rota); C (duplica o drawer).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/utilities/activityAgendaOmnibox.ts`** (novo): adaptador puro do omnibox para `ActivityAgendaState` — `buildActivityAgendaOmniboxChips`, `buildActivityAgendaOmniboxSuggestionSeeds` (grupos Município / Tag / "Deputado presente" chip bool), `applyActivityAgendaOmniboxSuggestion`, `removeActivityAgendaOmniboxChip`, `clearActivityAgendaOmnibox`. Mesmo shape de `activityOmnibox.ts` (não é twin — outro `State` com 3 dimensões e sem busca/Janela).
|
||||
- **`ActivityAgendaFilters.tsx`** (reescrita): vira a toolbar — `<CampaignListOmnibox>` com chips/sugestões/`Limpar` (chassis tem clear built-in) + `trailing` com `[ícone carrinho: Planejar giro][ícone "+" : Nova atividade]` **`hidden md:inline-flex`** (mobile: zero botões no corpo). `id="agenda-omnibox"`, `label="Filtrar agenda"`, `placeholder` tipo "Filtrar por município, tag, deputado…". Navegação via `useCampaignListFilterNavigation` + `buildActivityAgendaHref`.
|
||||
- **`agenda/page.tsx`**: remove o bloco de botões "Planejar giro"/"Nova atividade" (movem para a toolbar); envolve o conteúdo em `<CalendarFeedDialogProvider>` (com `feeds`, `createFeed(label)` = closure com `state`, `revokeFeed`, `canGenerate = hasFilters`); mantém `restrictActivityAgendaState`/redirects intocados.
|
||||
- **`CalendarFeedDialog.tsx`** (extraído de `CalendarFeedButton.tsx`): diálogo nomear→copiar→revogar, **responsivo** (Dialog desktop / Drawer-sheet mobile via `useIsMobile`, igual ao `CampaignQuickActionsOverlay`); expõe `CalendarFeedDialogProvider` + `useCalendarFeedDialog()` (`{ open, canGenerate }`); quando `!canGenerate`, abre com aviso "Aplique filtros para gerar um link de import" e desabilita "Gerar link". O provider também injeta `openCalendarFeed` no `CampaignQuickActionContext` (merge funcional + cleanup).
|
||||
- **`CampaignPageChromeContext.tsx`** (extender): adiciona `headerActions: Record<string, ReactNode>` + `setHeaderAction(key, node|null)`; novo componente `SetCampaignHeaderAction({ id, children }): null` (padrão `SetCampaignPageChrome`).
|
||||
- **`CampaignDesktopHeader.tsx`**: renderiza `Object.values(headerActions)` no cluster à direita, ANTES do sino e do AI (ordem do gate: `[título …][Semana ▾ C95][Link de import][Notificações][IA]`).
|
||||
- **`AgendaFeedHeaderButton.tsx`** (novo, client): botão-ícone `CalendarIcon`, `title`/tooltip, `disabled={!canGenerate}`, `onClick={open}` — registrado via `SetCampaignHeaderAction id="calendar-feed"` na página (desktop-only porque o `CampaignDesktopHeader` é `md:flex`).
|
||||
- **Quick actions**: `CampaignQuickAction` ganha `onAction?: () => void`; `CampaignQuickActionsOverlay` repassa `onClick: action.onAction` para o `CampaignHomeActionStrip` (o botão já suporta); `CampaignQuickActionContext` ganha `openCalendarFeed?: () => void`; `resolveActivityQuickActions` (surface `list`) devolve `[new-activity, import-calendar(onAction), plan-tour]` — ordem da intenção.
|
||||
- **Migration:** sem migration (nenhuma mudança de schema).
|
||||
- **Access / Consent:** nenhuma mudança — agenda segue `gate: 'staff'`; nenhum opt-in novo (o feed é dado de staff, sem Consent).
|
||||
- **UI:** Impeccable C/B — consolidar a toolbar seguindo o padrão de `ActivityFilters`/listas; shape → craft → critique → polish leve; tokens `data-theme='campaign'`.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- Forma: chips + sugestões agrupadas (padrão omnibox do repo) — "não apresento dados novos, filtro = navegação/URL". Decidido pela intenção (pergunta 3: forma adiada para o padrão existente).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Combobox (traço dominante)** — `activityAgendaOmnibox.ts` + unit tests (espelho de `listOmniboxB128`); reescrever `ActivityAgendaFilters` (omnibox + trailing desktop); página: remover botões avulsos. Verificar: URL canonical inalterada; e2e da agenda reescrito nos seletores (hoje `getByLabel('Município'|'Tag'|'Deputado presente')`).
|
||||
2. **Feed dialog + header slot + FAB** — extrair `CalendarFeedDialog` (Dialog/sheet); provider + `useCalendarFeedDialog`; extender chrome context (`headerActions` + `SetCampaignHeaderAction`); `AgendaFeedHeaderButton` + header renderiza registrados; quick actions `onAction` + `openCalendarFeed` + `import-calendar`.
|
||||
3. **Gates** — `pnpm gate:fast` na iteração; rebase em `origin/main`; e2e agenda (atualizar + rodar); `pnpm push` no fechamento.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Omnibox de busca global da agenda** (texto livre, multi-município, presets de janela) — fora; só as 3 dimensões de hoje, sem `onCommitQuery`.
|
||||
- **Refatorar `CampaignHomeActionStrip`/`CampaignHomeActionButton`** além do fio mínimo do `onAction` — não.
|
||||
- **Migrar as outras listas para o omnibox** (municípios etc. já usam) — não.
|
||||
- **Header vira barra de navegação** — mantém batch pequeno: só `headerActions` registrados + sino + IA.
|
||||
- **C92/C93**: o ícone reflete apenas o estado habilitado/desabilitado; "gerar feed sem filtros" é a C93 (fora).
|
||||
- **Não mudar o compose de giro nem o overlay inline (C91).**
|
||||
|
||||
## Divergências executadas (vs. hipótese da intenção)
|
||||
|
||||
- `AgendaFeedChrome` (client, montado na página) é o ponte único: registra o botão do header (`SetCampaignHeaderAction id="calendar-feed"`), injeta `openCalendarFeed` no `CampaignQuickActionContext` e **dona do único `<CalendarFeedDialog>`** (Dialog/sheet responsivo) — o botão do header e a ação do FAB chamam o mesmo `open()` (sem contexto novo de diálogo no layout).
|
||||
- O **overlay do FAB fecha ao disparar uma ação dialog** (`onActionClick` no `CampaignQuickActionsOverlay`) — senão o sheet da agenda abriria por cima do drawer aberto.
|
||||
- `playwright.config.ts` ganhou `webServer.timeout = 240s` **local-only** (`CI ? undefined`) — a máquina compartilhada (load 60+ de worktrees paralelos) estourava o boot de 60s em `pnpm dev`.
|
||||
|
||||
## Pós-/simplify (reviewers paralelos)
|
||||
|
||||
- **P1 corrigido:** `ActivityAgendaFilters` mantém espelho `draft` do estado (padrão do filtro antigo) para picks consecutivos dentro da janela pending do RSC aplicarem **cumulativos** — derivar do prop `state` solto dropava a primeira seleção (regressão desta entrega, achada pelo reviewer de correção).
|
||||
- **P2 corrigido:** no overlay, `onAction` vence `href` deterministicamente (o botão ignora `onClick` na branch Link — combo latente).
|
||||
- Botão do header memoizado (`useMemo`) no `AgendaFeedChrome` — registra só quando `canGenerate`/`open` muda.
|
||||
- Chaves nos nós de `headerActions` (C95 terá 2+).
|
||||
- `handleRevoke` sem wrapper async inútil; `ActivityAgendaFilterOption` não exportado.
|
||||
- Deferido (DRY <3 call sites): extrair `chipLabel`/seeds de município/tag compartilhados para `lib/campaignListOmnibox`.
|
||||
- **E2E:** os testes de slot do calendário (`cria inline`, `Mais detalhes`) ganharam waits de visibilidade (`[data-time="14:00:00"]:visible` + `dayCell`) antes de medir — o FullCalendar renderiza o eixo de tempo lazy e re-layout no mount; os `boundingBox` antigos corriam a renderização (flake que também existe no commit base; agora estabilizado).
|
||||
|
||||
## Débitos deferidos (capture-review-debts)
|
||||
|
||||
| Débito | Gatilho de revisitação |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| Query textual sem match fica presa no omnibox da agenda (sem `onCommitQuery`; `q:` é filtrado de propósito) | Se um usuário relatar confusão, ou quando um omnibox sem busca textual voltar a aparecer em outra lista |
|
||||
| `CampaignQuickAction` permite `href`+`onAction` juntos (mitigado: `onAction` vence no overlay; tipo união discriminada seria refactor amplo) | Na próxima Issue que tocar `CampaignQuickActionTypes`/overlay |
|
||||
| Closure de filtro pode ficar stale dentro do diálogo de feed aberto (comportamento herdado do C92) | C93 (gerar sem filtros) ao mexer no diálogo |
|
||||
| Feeds listados no diálogo só atualizam em re-render da página (herdado do C92) | Idem |
|
||||
| `SetCampaignHeaderAction` re-registra quando o host re-renderiza (agora memoizado; C95 deve revisitar o shape do slot) | C95 (seletor de semana no header) |
|
||||
|
||||
## Riscos e mitigação (executados)
|
||||
|
||||
- **Header** `[actions][sino][IA]` conforme o gate; sem `useSearchParams` no layout.
|
||||
- E2E agenda: a interação omnibox usa `fill` (reabre o popover) em vez de `click` num input já focado; assert final de URL com wait do chip + `timeout: 20s`.
|
||||
- `codebaseConventions` — `activityAgendaOmnibox.ts` registrado no pin de `src/utilities`.
|
||||
- Os testes de slot do calendário (`cria inline`, `Mais detalhes`) são **frágeis de timing de `gridcell.nth(1)`** e flakam sob load alto também no commit base (verificado via worktree descartável de `e3f6d3f5`) — ambientais, não desta entrega; em load ~30 a suíte agenda passou 3/3.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (3 pontos + anti-goals)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (staff gate, URL canonical, sem Consent novo)
|
||||
- [ ] Testes de domínio: unit do adaptador de agenda (espelho B128); e2e agenda reescrito e verde; int/serviço de feed intocados
|
||||
- [ ] `pnpm gate:fast`, tsc, lint, prettier, knip, check:cycles verdes
|
||||
|
|
@ -109,6 +109,9 @@ export default defineConfig({
|
|||
*/
|
||||
reuseExistingServer: false,
|
||||
url: baseURL,
|
||||
// Local-only tolerance for parallel-worktree load (machine load ~60); CI
|
||||
// boots the production build and keeps the usual 60s budget.
|
||||
timeout: process.env.CI ? undefined : 240_000,
|
||||
/* Force the isolated test database into the server process. */
|
||||
env: {
|
||||
DATABASE_URL: process.env.DATABASE_URL as string,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import config from '@payload-config'
|
||||
import { MapPinnedIcon, PlusIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
|
|
@ -11,23 +9,21 @@ import {
|
|||
} from '@/app/(campaign)/campanha/actions/calendarFeed'
|
||||
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
|
||||
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
|
||||
import { AgendaFeedChrome } from '@/components/campaign/activity/AgendaFeedChrome'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { loadAccessibleActivityTags } from '@/utilities/activityPageData'
|
||||
import {
|
||||
buildActivityAgendaHref,
|
||||
buildActivityCreateHref,
|
||||
resolveActivityAgendaUrl,
|
||||
restrictActivityAgendaState,
|
||||
} from '@/utilities/activityUi'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
|
||||
import { TOUR_COMPOSER_PATH } from '@/utilities/visit/visitPlannerUrl'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('agenda')
|
||||
|
||||
|
|
@ -57,47 +53,37 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
const accessibleHref = buildActivityAgendaHref(state)
|
||||
if (accessibleHref !== resolvedUrl.href) redirect(accessibleHref)
|
||||
|
||||
const canGenerateFeed = Boolean(state.municipality || state.deputyPresent || state.tag)
|
||||
|
||||
return (
|
||||
<CampaignPageShell className="gap-6">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={TOUR_COMPOSER_PATH}>
|
||||
<MapPinnedIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Planejar giro
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href={buildActivityCreateHref(state)}>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova atividade
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<AgendaFeedChrome
|
||||
feeds={feeds.map((f) => ({
|
||||
id: f.id,
|
||||
label: f.label,
|
||||
createdAt: f.createdAt,
|
||||
}))}
|
||||
canGenerate={canGenerateFeed}
|
||||
onCreateFeed={async (label) => {
|
||||
'use server'
|
||||
return createCalendarFeedLink({
|
||||
label,
|
||||
filterMunicipality: state.municipality,
|
||||
filterDeputyPresent: state.deputyPresent,
|
||||
filterTag: state.tag,
|
||||
})
|
||||
}}
|
||||
onRevokeFeed={async (feedId) => {
|
||||
'use server'
|
||||
return revokeCalendarFeed(feedId)
|
||||
}}
|
||||
/>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
<ActivityAgendaFilters
|
||||
state={state}
|
||||
municipalityOptions={municipalityOptions}
|
||||
knownTags={knownTags}
|
||||
feeds={feeds.map((f) => ({
|
||||
id: f.id,
|
||||
label: f.label,
|
||||
createdAt: f.createdAt,
|
||||
}))}
|
||||
onCreateFeed={async (label) => {
|
||||
'use server'
|
||||
const result = await createCalendarFeedLink({
|
||||
label,
|
||||
filterMunicipality: state.municipality,
|
||||
filterDeputyPresent: state.deputyPresent,
|
||||
filterTag: state.tag,
|
||||
})
|
||||
return result
|
||||
}}
|
||||
onRevokeFeed={async (feedId) => {
|
||||
'use server'
|
||||
return revokeCalendarFeed(feedId)
|
||||
}}
|
||||
/>
|
||||
<CampaignListResults>
|
||||
<ActivityAgenda state={state} municipalityOptions={municipalityOptions} />
|
||||
|
|
|
|||
|
|
@ -1,140 +1,152 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MapPinnedIcon, PlusIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { CalendarFeedButton } from '@/components/campaign/activity/CalendarFeedButton'
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { buildActivityAgendaHref, type ActivityAgendaState } from '@/utilities/activityUi'
|
||||
|
||||
type CalendarFeedSummary = {
|
||||
id: number
|
||||
label: string
|
||||
createdAt: string
|
||||
}
|
||||
import { ACTIVITY_TOUR_COMPOSER_PATH } from '@/lib/campaignQuickActionPaths'
|
||||
import {
|
||||
applyActivityAgendaOmniboxSuggestion,
|
||||
buildActivityAgendaOmniboxChips,
|
||||
buildActivityAgendaOmniboxSuggestionSeeds,
|
||||
clearActivityAgendaOmnibox,
|
||||
filterActivityAgendaOmniboxSuggestions,
|
||||
removeActivityAgendaOmniboxChip,
|
||||
type ActivityAgendaOmniboxAction,
|
||||
} from '@/utilities/activityAgendaOmnibox'
|
||||
import {
|
||||
buildActivityAgendaHref,
|
||||
buildActivityCreateHref,
|
||||
type ActivityAgendaState,
|
||||
} from '@/utilities/activityUi'
|
||||
|
||||
type ActivityAgendaFiltersProps = {
|
||||
state: ActivityAgendaState
|
||||
municipalityOptions: RelationOption[]
|
||||
knownTags: string[]
|
||||
feeds?: CalendarFeedSummary[]
|
||||
onCreateFeed?: (
|
||||
label: string,
|
||||
) => Promise<{ ok: true; feedUrl: string } | { ok: false; message: string }>
|
||||
onRevokeFeed?: (feedId: number) => Promise<{ ok: boolean }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Agenda toolbar (C94): one omnibox filtering Município (único) + Tag +
|
||||
* Deputado presente (chip bool), with the desktop create entries as icon
|
||||
* buttons beside the bar. Mobile creates live in the FAB drawer, so the
|
||||
* trailing icons are desktop-only. Filter state stays on the URL.
|
||||
*
|
||||
* A local `draft` mirrors the URL state so consecutive picks within the same
|
||||
* RSC pending window apply cumulatively (a bare `state` prop would drop the
|
||||
* first selection while the router.replace round-trip is in flight).
|
||||
*/
|
||||
export const ActivityAgendaFilters = ({
|
||||
state,
|
||||
municipalityOptions,
|
||||
knownTags,
|
||||
feeds = [],
|
||||
onCreateFeed,
|
||||
onRevokeFeed,
|
||||
}: ActivityAgendaFiltersProps) => {
|
||||
const { navigate, isPending } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: buildActivityAgendaHref,
|
||||
})
|
||||
const [draft, setDraft] = useState(state)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
useEffect(() => setDraft(state), [state])
|
||||
|
||||
const update = (next: ActivityAgendaState) => {
|
||||
setDraft(next)
|
||||
navigate(next)
|
||||
const municipalityLabelsById = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const option of municipalityOptions) {
|
||||
if (Number.isSafeInteger(option.id) && option.id > 0) map.set(option.id, option.name)
|
||||
}
|
||||
return map
|
||||
}, [municipalityOptions])
|
||||
|
||||
const municipalityFilterOptions = useMemo(
|
||||
() =>
|
||||
municipalityOptions.map((option) => ({
|
||||
value: String(option.id),
|
||||
label: option.name,
|
||||
})),
|
||||
[municipalityOptions],
|
||||
)
|
||||
|
||||
const chips = useMemo(
|
||||
() => buildActivityAgendaOmniboxChips({ state: draft, municipalityLabelsById }),
|
||||
[draft, municipalityLabelsById],
|
||||
)
|
||||
|
||||
const suggestionSeeds = useMemo(
|
||||
() =>
|
||||
buildActivityAgendaOmniboxSuggestionSeeds({
|
||||
municipalityOptions: municipalityFilterOptions,
|
||||
knownTags,
|
||||
}),
|
||||
[municipalityFilterOptions, knownTags],
|
||||
)
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => filterActivityAgendaOmniboxSuggestions(suggestionSeeds, query),
|
||||
[suggestionSeeds, query],
|
||||
)
|
||||
|
||||
const runAction = (action: ActivityAgendaOmniboxAction) => {
|
||||
if (action.kind === 'clear') {
|
||||
setQuery('')
|
||||
setDraft({})
|
||||
navigate({})
|
||||
return
|
||||
}
|
||||
setDraft(action.state)
|
||||
navigate(action.state)
|
||||
}
|
||||
|
||||
const hasFilters = Boolean(draft.municipality || draft.deputyPresent || draft.tag)
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Filtros da agenda"
|
||||
aria-busy={isPending}
|
||||
className="rounded-xl border bg-card p-4 transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(12rem,1fr)_minmax(11rem,0.7fr)_auto_auto] md:items-end">
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agenda-municipality">
|
||||
Município
|
||||
</label>
|
||||
<NativeSelect
|
||||
id="agenda-municipality"
|
||||
value={draft.municipality ? String(draft.municipality) : ''}
|
||||
onChange={(event) => {
|
||||
const municipality = Number(event.target.value)
|
||||
update({
|
||||
...draft,
|
||||
municipality: municipality > 0 ? municipality : undefined,
|
||||
})
|
||||
}}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11"
|
||||
<CampaignListOmnibox
|
||||
id="agenda-omnibox"
|
||||
label="Filtrar agenda"
|
||||
placeholder="Filtrar por município, tag, deputado presente…"
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
isPending={isPending}
|
||||
onSelectSuggestion={(suggestionId) => {
|
||||
runAction(applyActivityAgendaOmniboxSuggestion({ state: draft, suggestionId }))
|
||||
}}
|
||||
onRemoveChip={(chipId) => {
|
||||
runAction(removeActivityAgendaOmniboxChip({ state: draft, chipId }))
|
||||
}}
|
||||
onClearAll={() => {
|
||||
runAction(clearActivityAgendaOmnibox())
|
||||
}}
|
||||
trailing={
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
aria-label="Planejar giro"
|
||||
title="Planejar giro"
|
||||
>
|
||||
<NativeSelectOption value="">Todos os municípios</NativeSelectOption>
|
||||
{municipalityOptions.map((option) => (
|
||||
<NativeSelectOption key={option.id} value={String(option.id)}>
|
||||
{option.name}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="agenda-tag">
|
||||
Tag
|
||||
</label>
|
||||
<NativeSelect
|
||||
id="agenda-tag"
|
||||
value={draft.tag ?? ''}
|
||||
onChange={(event) => update({ ...draft, tag: event.target.value || undefined })}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11"
|
||||
<Link href={ACTIVITY_TOUR_COMPOSER_PATH}>
|
||||
<MapPinnedIcon className="size-5" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
aria-label="Nova atividade"
|
||||
title="Nova atividade"
|
||||
>
|
||||
<NativeSelectOption value="">Todas as tags</NativeSelectOption>
|
||||
{knownTags.map((tag) => (
|
||||
<NativeSelectOption key={tag} value={tag}>
|
||||
{tag}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<Link href={buildActivityCreateHref(state)}>
|
||||
<PlusIcon className="size-5" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className="flex min-h-11 items-center gap-3 rounded-lg border px-3 text-sm font-medium">
|
||||
<Checkbox
|
||||
checked={Boolean(draft.deputyPresent)}
|
||||
onCheckedChange={(checked) =>
|
||||
update({ ...draft, deputyPresent: checked === true ? true : undefined })
|
||||
}
|
||||
/>
|
||||
Deputado presente
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11"
|
||||
disabled={!hasFilters}
|
||||
onClick={() => update({})}
|
||||
>
|
||||
Limpar filtros
|
||||
</Button>
|
||||
|
||||
{onCreateFeed && onRevokeFeed && (
|
||||
<CalendarFeedButton
|
||||
state={state}
|
||||
hasFilters={hasFilters}
|
||||
feeds={feeds}
|
||||
onCreateFeed={onCreateFeed}
|
||||
onRevokeFeed={onRevokeFeed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando os compromissos da agenda.' : 'Filtros da agenda atualizados.'}
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
77
src/components/campaign/activity/AgendaFeedChrome.tsx
Normal file
77
src/components/campaign/activity/AgendaFeedChrome.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'use client'
|
||||
|
||||
import { CalendarIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
CalendarFeedDialog,
|
||||
type CalendarFeedSummary,
|
||||
type CreateCalendarFeedResult,
|
||||
} from '@/components/campaign/activity/CalendarFeedDialog'
|
||||
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type AgendaFeedChromeProps = {
|
||||
feeds: CalendarFeedSummary[]
|
||||
/** Whether the current agenda recorte has filters (feed gate, C93 handles "sem filtros"). */
|
||||
canGenerate: boolean
|
||||
onCreateFeed: (label: string) => Promise<CreateCalendarFeedResult>
|
||||
onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Agenda page → app chrome bridge for the calendar-feed surface (C94):
|
||||
* registers the desktop header icon (agenda-contextual), exposes the sheet to
|
||||
* the mobile FAB quick action and owns the single shared dialog instance.
|
||||
*/
|
||||
export const AgendaFeedChrome = ({
|
||||
feeds,
|
||||
canGenerate,
|
||||
onCreateFeed,
|
||||
onRevokeFeed,
|
||||
}: AgendaFeedChromeProps) => {
|
||||
const { setContext } = useCampaignQuickActionContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const openFeed = useCallback(() => setOpen(true), [])
|
||||
|
||||
useEffect(() => {
|
||||
setContext((current) => ({ ...current, openCalendarFeed: openFeed }))
|
||||
return () => {
|
||||
setContext((current) => ({ ...current, openCalendarFeed: undefined }))
|
||||
}
|
||||
}, [setContext, openFeed])
|
||||
|
||||
const headerButton = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 hidden shrink-0 md:inline-flex"
|
||||
aria-label="Link de import"
|
||||
title={canGenerate ? 'Link de import' : 'Aplique filtros para gerar um link de import'}
|
||||
disabled={!canGenerate}
|
||||
onClick={openFeed}
|
||||
>
|
||||
<CalendarIcon className="size-5" aria-hidden />
|
||||
</Button>
|
||||
),
|
||||
[canGenerate, openFeed],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetCampaignHeaderAction id="calendar-feed">{headerButton}</SetCampaignHeaderAction>
|
||||
|
||||
<CalendarFeedDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
feeds={feeds}
|
||||
canGenerate={canGenerate}
|
||||
onCreateFeed={onCreateFeed}
|
||||
onRevokeFeed={onRevokeFeed}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { CalendarIcon, CopyIcon, ExternalLinkIcon, Trash2Icon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { ActivityAgendaState } from '@/utilities/activityUi'
|
||||
|
||||
type CalendarFeed = {
|
||||
id: number
|
||||
label: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type CalendarFeedButtonProps = {
|
||||
state: ActivityAgendaState
|
||||
hasFilters: boolean
|
||||
feeds: CalendarFeed[]
|
||||
onCreateFeed: (
|
||||
label: string,
|
||||
) => Promise<{ ok: true; feedUrl: string } | { ok: false; message: string }>
|
||||
onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }>
|
||||
}
|
||||
|
||||
export const CalendarFeedButton = ({
|
||||
state: _state,
|
||||
hasFilters,
|
||||
feeds,
|
||||
onCreateFeed,
|
||||
onRevokeFeed,
|
||||
}: CalendarFeedButtonProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [label, setLabel] = useState('')
|
||||
const [createdUrl, setCreatedUrl] = useState<string | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!label.trim()) return
|
||||
setIsCreating(true)
|
||||
setError(null)
|
||||
const result = await onCreateFeed(label.trim())
|
||||
setIsCreating(false)
|
||||
if (result.ok) {
|
||||
setCreatedUrl(result.feedUrl)
|
||||
setLabel('')
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!createdUrl) return
|
||||
await navigator.clipboard.writeText(createdUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleRevoke = async (feedId: number) => {
|
||||
await onRevokeFeed(feedId)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
setCreatedUrl(null)
|
||||
setError(null)
|
||||
setCopied(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
disabled={!hasFilters}
|
||||
onClick={() => setIsOpen(true)}
|
||||
title={!hasFilters ? 'Aplique filtros para gerar um link de import' : undefined}
|
||||
>
|
||||
<CalendarIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Link de import
|
||||
</Button>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sincronizar com Google Calendar</DialogTitle>
|
||||
<DialogDescription>
|
||||
Gere um link de import para sincronizar este recorte da agenda com o Google Calendar.
|
||||
Alterações em Teqo serão refletidas automaticamente.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{createdUrl ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Link de import</label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={createdUrl} readOnly className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copiar link"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied && <p className="text-xs text-green-600">Link copiado!</p>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4 text-sm">
|
||||
<p className="font-medium mb-2">Como adicionar ao Google Calendar:</p>
|
||||
<ol className="list-decimal list-inside 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>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Fechar
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="h-4 w-4 mr-2" />
|
||||
Abrir Google Calendar
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="feed-label" className="text-sm font-medium">
|
||||
Nome do feed
|
||||
</label>
|
||||
<Input
|
||||
id="feed-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Ex: Só deputado presente"
|
||||
maxLength={120}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'}
|
||||
Reuniões em Salvador{'"'})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
{feeds.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Seus feeds ativos</p>
|
||||
<ul className="space-y-1">
|
||||
{feeds.map((feed) => (
|
||||
<li
|
||||
key={feed.id}
|
||||
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<span>{feed.label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRevoke(feed.id)}
|
||||
aria-label={`Revogar feed ${feed.label}`}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}>
|
||||
{isCreating ? 'Gerando...' : 'Gerar link'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
261
src/components/campaign/activity/CalendarFeedDialog.tsx
Normal file
261
src/components/campaign/activity/CalendarFeedDialog.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { CopyIcon, ExternalLinkIcon, Trash2Icon } from 'lucide-react'
|
||||
|
||||
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'
|
||||
|
||||
export type CalendarFeedSummary = {
|
||||
id: number
|
||||
label: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type CreateCalendarFeedResult =
|
||||
| { ok: true; feedUrl: string }
|
||||
| { ok: false; message: string }
|
||||
|
||||
type CalendarFeedDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
feeds: CalendarFeedSummary[]
|
||||
/** Whether the current agenda recorte can generate a feed (has filters). */
|
||||
canGenerate: boolean
|
||||
onCreateFeed: (label: string) => Promise<CreateCalendarFeedResult>
|
||||
onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar feed dialog (nomear → copiar → revogar). Shared by the desktop
|
||||
* header icon and the mobile FAB quick action — rendered once per host, opened
|
||||
* by either trigger. Bottom sheet on mobile, dialog on desktop (C94).
|
||||
*/
|
||||
export const CalendarFeedDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
feeds,
|
||||
canGenerate,
|
||||
onCreateFeed,
|
||||
onRevokeFeed,
|
||||
}: CalendarFeedDialogProps) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [label, setLabel] = useState('')
|
||||
const [createdUrl, setCreatedUrl] = useState<string | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!label.trim() || !canGenerate || isCreating) return
|
||||
setIsCreating(true)
|
||||
setError(null)
|
||||
const result = await onCreateFeed(label.trim())
|
||||
setIsCreating(false)
|
||||
if (result.ok) {
|
||||
setCreatedUrl(result.feedUrl)
|
||||
setLabel('')
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!createdUrl) return
|
||||
await navigator.clipboard.writeText(createdUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleClose = (next: boolean) => {
|
||||
if (next) return
|
||||
setCreatedUrl(null)
|
||||
setError(null)
|
||||
setCopied(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{createdUrl ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="feed-url">
|
||||
Link de import
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="feed-url" value={createdUrl} readOnly className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copiar link"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied && <p className="text-xs text-green-600">Link copiado!</p>}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{!isMobile ? (
|
||||
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
|
||||
Fechar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{!canGenerate ? (
|
||||
<p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
Aplique filtros à agenda para gerar um link de import.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="feed-label" className="text-sm font-medium">
|
||||
Nome do feed
|
||||
</label>
|
||||
<Input
|
||||
id="feed-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Ex: Só deputado presente"
|
||||
maxLength={120}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'}
|
||||
Reuniões em Salvador{'"'})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
{feeds.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Seus feeds ativos</p>
|
||||
<ul className="space-y-1">
|
||||
{feeds.map((feed) => (
|
||||
<li
|
||||
key={feed.id}
|
||||
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<span>{feed.label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void onRevokeFeed(feed.id)}
|
||||
aria-label={`Revogar feed ${feed.label}`}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{!isMobile ? (
|
||||
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={!label.trim() || !canGenerate || isCreating}
|
||||
>
|
||||
{isCreating ? 'Gerando...' : 'Gerar link'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleClose} showSwipeHandle>
|
||||
<DrawerContent className="max-h-[85vh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>Sincronizar com Google Calendar</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Gere um link de import para sincronizar este recorte da agenda com o Google Calendar.
|
||||
Alterações em Teqo serão refletidas automaticamente.
|
||||
</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={handleClose}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sincronizar com Google Calendar</DialogTitle>
|
||||
<DialogDescription>
|
||||
Gere um link de import para sincronizar este recorte da agenda com o Google Calendar.
|
||||
Alterações em Teqo serão refletidas automaticamente.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,18 +1,26 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
|
||||
import { CampaignAIHeaderButton } from '@/components/campaign/shell/ai/CampaignAIHeaderButton'
|
||||
import { useCampaignHeaderActions } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
|
||||
import { SidebarTrigger } from '@/components/ui/Sidebar'
|
||||
|
||||
export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => (
|
||||
<header className="hidden min-h-11 shrink-0 items-center gap-3 border-b border-border px-4 md:flex print:hidden">
|
||||
<SidebarTrigger />
|
||||
<CampaignPageChromeDisplay layout="desktop" className="flex-1" />
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<CampaignAIHeaderButton />
|
||||
{notificationBell ?? null}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => {
|
||||
const headerActions = useCampaignHeaderActions()
|
||||
|
||||
return (
|
||||
<header className="hidden min-h-11 shrink-0 items-center gap-3 border-b border-border px-4 md:flex print:hidden">
|
||||
<SidebarTrigger />
|
||||
<CampaignPageChromeDisplay layout="desktop" className="flex-1" />
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{Object.entries(headerActions).map(([id, node]) => (
|
||||
<Fragment key={id}>{node}</Fragment>
|
||||
))}
|
||||
{notificationBell ?? null}
|
||||
<CampaignAIHeaderButton />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
|
|
@ -14,10 +15,19 @@ import {
|
|||
import type { CampaignPageChrome } from '@/lib/campaignPageChrome'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
|
||||
/**
|
||||
* Header action nodes registered by pages, rendered in the app header in
|
||||
* insertion order (C94/C95 shared slot — e.g. the agenda-contextual "Link de
|
||||
* import").
|
||||
*/
|
||||
export type CampaignHeaderActionsMap = Record<string, ReactNode>
|
||||
|
||||
type CampaignPageChromeContextValue = {
|
||||
role: CampaignUser['role']
|
||||
override: CampaignPageChrome | null
|
||||
setOverride: Dispatch<SetStateAction<CampaignPageChrome | null>>
|
||||
headerActions: CampaignHeaderActionsMap
|
||||
setHeaderAction: (key: string, node: ReactNode | null) => void
|
||||
}
|
||||
|
||||
const CampaignPageChromeContext = createContext<CampaignPageChromeContextValue | null>(null)
|
||||
|
|
@ -30,7 +40,24 @@ export const CampaignPageChromeProvider = ({
|
|||
children: ReactNode
|
||||
}) => {
|
||||
const [override, setOverride] = useState<CampaignPageChrome | null>(null)
|
||||
const value = useMemo(() => ({ role, override, setOverride }), [role, override])
|
||||
const [headerActions, setHeaderActions] = useState<CampaignHeaderActionsMap>({})
|
||||
|
||||
const setHeaderAction = useCallback((key: string, node: ReactNode | null) => {
|
||||
setHeaderActions((current) => {
|
||||
const next = { ...current }
|
||||
if (node === null) {
|
||||
delete next[key]
|
||||
} else {
|
||||
next[key] = node
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ role, override, setOverride, headerActions, setHeaderAction }),
|
||||
[role, override, headerActions, setHeaderAction],
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignPageChromeContext.Provider value={value}>
|
||||
|
|
@ -53,6 +80,9 @@ export const useCampaignPageChromeRole = (): CampaignUser['role'] =>
|
|||
export const useCampaignPageChromeOverride = (): CampaignPageChrome | null =>
|
||||
useCampaignPageChromeContext().override
|
||||
|
||||
export const useCampaignHeaderActions = (): CampaignHeaderActionsMap =>
|
||||
useCampaignPageChromeContext().headerActions
|
||||
|
||||
export const SetCampaignPageChrome = ({ chrome }: { chrome: CampaignPageChrome }): null => {
|
||||
const { setOverride } = useCampaignPageChromeContext()
|
||||
|
||||
|
|
@ -69,3 +99,24 @@ export const SetCampaignPageChrome = ({ chrome }: { chrome: CampaignPageChrome }
|
|||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a page-owned control into the app header's right cluster
|
||||
* (agenda-contextual by nature — only pages render it). Not rendered in place.
|
||||
*/
|
||||
export const SetCampaignHeaderAction = ({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
children: ReactNode
|
||||
}): null => {
|
||||
const setHeaderAction = useCampaignPageChromeContext().setHeaderAction
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setHeaderAction(id, children)
|
||||
return () => setHeaderAction(id, null)
|
||||
}, [setHeaderAction, id, children])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,9 +32,12 @@ const overlayRetractionClass = (retracted: boolean) =>
|
|||
const OverlayActionsChrome = ({
|
||||
actions,
|
||||
retracted,
|
||||
onActionClick,
|
||||
}: {
|
||||
actions: readonly CampaignQuickAction[]
|
||||
retracted: boolean
|
||||
/** Fired after an `onAction` (dialog-style) quick action runs — closes the drawer. */
|
||||
onActionClick: () => void
|
||||
}) => {
|
||||
if (actions.length === 0) return null
|
||||
|
||||
|
|
@ -53,7 +56,16 @@ const OverlayActionsChrome = ({
|
|||
label: action.label,
|
||||
icon: action.icon,
|
||||
description: action.description,
|
||||
href: action.href,
|
||||
// Dialog-style actions win over a route when both are present
|
||||
// (`CampaignHomeActionButton` renders the href branch without
|
||||
// onClick, which would silently drop the action and the close).
|
||||
href: action.onAction ? undefined : action.href,
|
||||
onClick: action.onAction
|
||||
? () => {
|
||||
action.onAction?.()
|
||||
onActionClick()
|
||||
}
|
||||
: undefined,
|
||||
}))}
|
||||
className="w-full"
|
||||
/>
|
||||
|
|
@ -70,15 +82,17 @@ const OverlaySearchChrome = ({ className }: { className?: string }) => (
|
|||
|
||||
const CampaignQuickActionsOverlayBody = ({
|
||||
actions,
|
||||
onActionClick,
|
||||
}: {
|
||||
actions: readonly CampaignQuickAction[]
|
||||
onActionClick: () => void
|
||||
}) => {
|
||||
const { uiFocused: focused } = useHomeSearch()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="order-1 md:order-2">
|
||||
<OverlayActionsChrome actions={actions} retracted={focused} />
|
||||
<OverlayActionsChrome actions={actions} retracted={focused} onActionClick={onActionClick} />
|
||||
</div>
|
||||
<OverlaySearchChrome
|
||||
className={cn('order-2 min-w-0 md:order-1', focused ? 'mt-0 pt-4' : 'mt-4 md:mt-0 md:mb-4')}
|
||||
|
|
@ -89,8 +103,10 @@ const CampaignQuickActionsOverlayBody = ({
|
|||
|
||||
const CampaignQuickActionsOverlayContent = ({
|
||||
actions,
|
||||
onActionClick,
|
||||
}: {
|
||||
actions: readonly CampaignQuickAction[]
|
||||
onActionClick: () => void
|
||||
}) => {
|
||||
const pathname = usePathname()
|
||||
const { context } = useCampaignQuickActionContext()
|
||||
|
|
@ -98,7 +114,7 @@ const CampaignQuickActionsOverlayContent = ({
|
|||
|
||||
return (
|
||||
<HomeSearchExcludeProvider value={excludeContext}>
|
||||
<CampaignQuickActionsOverlayBody actions={actions} />
|
||||
<CampaignQuickActionsOverlayBody actions={actions} onActionClick={onActionClick} />
|
||||
</HomeSearchExcludeProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -135,7 +151,10 @@ export const CampaignQuickActionsOverlay = ({
|
|||
Ações do contexto e busca na campanha
|
||||
</DrawerDescription>
|
||||
<div className="flex flex-col overflow-y-auto px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3">
|
||||
<CampaignQuickActionsOverlayContent actions={actions} />
|
||||
<CampaignQuickActionsOverlayContent
|
||||
actions={actions}
|
||||
onActionClick={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
|
@ -159,7 +178,10 @@ export const CampaignQuickActionsOverlay = ({
|
|||
<DialogDescription>Ações do contexto e busca na campanha</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
<CampaignQuickActionsOverlayContent actions={actions} />
|
||||
<CampaignQuickActionsOverlayContent
|
||||
actions={actions}
|
||||
onActionClick={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ClipboardList, ListChecks, MapPinned, Pencil, Plus } from 'lucide-react'
|
||||
import { Calendar, ClipboardList, ListChecks, MapPinned, Pencil, Plus } from 'lucide-react'
|
||||
|
||||
import {
|
||||
CAMPAIGN_WIZARD_ACTION_SLUGS,
|
||||
|
|
@ -23,7 +23,7 @@ const WIZARD_ACTION_IDS: readonly CampaignWizardActionId[] = [
|
|||
'register-demand',
|
||||
]
|
||||
|
||||
const listQuickActions = (): readonly CampaignQuickAction[] => [
|
||||
const listQuickActions = (context: CampaignQuickActionContext): readonly CampaignQuickAction[] => [
|
||||
{
|
||||
id: 'new-activity',
|
||||
label: 'Nova atividade',
|
||||
|
|
@ -31,6 +31,17 @@ const listQuickActions = (): readonly CampaignQuickAction[] => [
|
|||
description: 'Criar caminhada, comício, panfletagem ou outra ação de campanha',
|
||||
href: ACTIVITY_NEW_PATH,
|
||||
},
|
||||
...(context.openCalendarFeed
|
||||
? [
|
||||
{
|
||||
id: 'import-calendar',
|
||||
label: 'Link de import',
|
||||
icon: Calendar,
|
||||
description: 'Gerar link para sincronizar este recorte da agenda com o Google Calendar',
|
||||
onAction: context.openCalendarFeed,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'plan-tour',
|
||||
label: 'Planejar giro',
|
||||
|
|
@ -101,7 +112,7 @@ export const resolveActivityQuickActions = (
|
|||
if (!isStaffCampaignRole(role)) return []
|
||||
|
||||
if (surface.kind === 'list') {
|
||||
return listQuickActions()
|
||||
return listQuickActions(context)
|
||||
}
|
||||
|
||||
const activitySlug = context.activitySlug ?? surface.activitySlug
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ export type CampaignQuickActionContext = {
|
|||
activitySlug?: string
|
||||
demandSlug?: string
|
||||
advisorId?: number
|
||||
/**
|
||||
* Dialog-style action bridged by the agenda page: opens the calendar-feed
|
||||
* dialog (C94). A function can live in this client-only context — never
|
||||
* crossed server→client.
|
||||
*/
|
||||
openCalendarFeed?: () => void
|
||||
}
|
||||
|
||||
export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({})
|
||||
|
|
|
|||
|
|
@ -7,4 +7,6 @@ export type CampaignQuickAction = {
|
|||
icon: LucideIcon
|
||||
description: string
|
||||
href?: string
|
||||
/** Dialog-style actions (no route); the drawer button fires it instead of navigating. */
|
||||
onAction?: () => void
|
||||
}
|
||||
|
|
|
|||
180
src/utilities/activityAgendaOmnibox.ts
Normal file
180
src/utilities/activityAgendaOmnibox.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Agenda omnibox adapter (C94): chips, suggestions and apply/remove over the
|
||||
* frozen `ActivityAgendaState` URL contract. Pure / client-safe.
|
||||
*
|
||||
* The agenda filter surface is deliberately NOT the general activity list
|
||||
* omnibox (`activityOmnibox.ts` — tabs/search/status): the agenda has three
|
||||
* dimensions (Município único + Tag + Deputado presente chip bool), no free
|
||||
* text search and no window presets. Municípios filter by typing (435 rows);
|
||||
* Tag and "Deputado presente" are empty-query-visible shortcuts.
|
||||
*/
|
||||
import {
|
||||
createOmniboxSuggestionSeed,
|
||||
filterOmniboxSuggestionSeeds,
|
||||
type CampaignListOmniboxChip,
|
||||
type CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import type { ActivityAgendaState } from '@/utilities/activityUi'
|
||||
|
||||
export type ActivityAgendaOmniboxAction =
|
||||
| { kind: 'url'; state: ActivityAgendaState }
|
||||
| { kind: 'clear' }
|
||||
|
||||
type ActivityAgendaFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
|
||||
|
||||
export const buildActivityAgendaOmniboxChips = ({
|
||||
state,
|
||||
municipalityLabelsById,
|
||||
}: {
|
||||
state: ActivityAgendaState
|
||||
municipalityLabelsById: ReadonlyMap<number, string>
|
||||
}): CampaignListOmniboxChip[] => {
|
||||
const chips: CampaignListOmniboxChip[] = []
|
||||
|
||||
if (state.municipality) {
|
||||
chips.push({
|
||||
id: `municipality:${state.municipality}`,
|
||||
label: chipLabel(
|
||||
'Município',
|
||||
municipalityLabelsById.get(state.municipality) ?? `Município #${state.municipality}`,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if (state.tag) {
|
||||
chips.push({ id: `tag:${state.tag}`, label: chipLabel('Tag', state.tag) })
|
||||
}
|
||||
|
||||
if (state.deputyPresent) {
|
||||
chips.push({ id: 'deputyPresent', label: 'Deputado presente' })
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
|
||||
export const buildActivityAgendaOmniboxSuggestionSeeds = ({
|
||||
municipalityOptions,
|
||||
knownTags,
|
||||
}: {
|
||||
municipalityOptions: readonly ActivityAgendaFilterOption[]
|
||||
knownTags?: readonly string[]
|
||||
}) => {
|
||||
const seeds: ReturnType<typeof createOmniboxSuggestionSeed>[] = []
|
||||
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: 'deputyPresent',
|
||||
group: 'Deputado presente',
|
||||
label: 'Deputado presente',
|
||||
keywords: ['deputado', 'presente'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
|
||||
for (const tag of knownTags ?? []) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `tag:${tag}`,
|
||||
group: 'Tag',
|
||||
label: tag,
|
||||
keywords: ['tag', 'tipo'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of municipalityOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed({
|
||||
id: `municipality:${option.value}`,
|
||||
group: 'Município',
|
||||
label: option.label,
|
||||
keywords: ['municipio'],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters dimension seeds and drops the shared helper's free-text `q:` row:
|
||||
* the agenda has no text search, so a dead "Busca: …" option would mislead.
|
||||
*/
|
||||
export const filterActivityAgendaOmniboxSuggestions = (
|
||||
seeds: ReturnType<typeof buildActivityAgendaOmniboxSuggestionSeeds>,
|
||||
query: string,
|
||||
): CampaignListOmniboxSuggestion[] =>
|
||||
filterOmniboxSuggestionSeeds(seeds, query).filter((suggestion) => !suggestion.id.startsWith('q:'))
|
||||
|
||||
export const applyActivityAgendaOmniboxSuggestion = ({
|
||||
state,
|
||||
suggestionId,
|
||||
}: {
|
||||
state: ActivityAgendaState
|
||||
suggestionId: string
|
||||
}): ActivityAgendaOmniboxAction => {
|
||||
if (suggestionId === 'deputyPresent') {
|
||||
return {
|
||||
kind: 'url',
|
||||
state: state.deputyPresent
|
||||
? { ...state, deputyPresent: undefined }
|
||||
: { ...state, deputyPresent: true },
|
||||
}
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('tag:')) {
|
||||
const value = suggestionId.slice(4)
|
||||
return {
|
||||
kind: 'url',
|
||||
state: state.tag === value ? { ...state, tag: undefined } : { ...state, tag: value },
|
||||
}
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('municipality:')) {
|
||||
const value = Number(suggestionId.slice(13))
|
||||
if (!Number.isSafeInteger(value) || value <= 0) return { kind: 'url', state }
|
||||
return {
|
||||
kind: 'url',
|
||||
state:
|
||||
state.municipality === value
|
||||
? { ...state, municipality: undefined }
|
||||
: { ...state, municipality: value },
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'url', state }
|
||||
}
|
||||
|
||||
export const removeActivityAgendaOmniboxChip = ({
|
||||
state,
|
||||
chipId,
|
||||
}: {
|
||||
state: ActivityAgendaState
|
||||
chipId: string
|
||||
}): ActivityAgendaOmniboxAction => {
|
||||
if (chipId === 'deputyPresent') {
|
||||
return { kind: 'url', state: { ...state, deputyPresent: undefined } }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('tag:')) {
|
||||
return { kind: 'url', state: { ...state, tag: undefined } }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('municipality:')) {
|
||||
return { kind: 'url', state: { ...state, municipality: undefined } }
|
||||
}
|
||||
|
||||
return { kind: 'url', state }
|
||||
}
|
||||
|
||||
export const clearActivityAgendaOmnibox = (): ActivityAgendaOmniboxAction => ({ kind: 'clear' })
|
||||
|
|
@ -117,11 +117,21 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
})
|
||||
.not.toBe(startAt)
|
||||
|
||||
await page.getByLabel('Município').selectOption(String(municipality.id))
|
||||
await page.getByLabel('Tag').selectOption('Comício')
|
||||
await page.getByLabel('Deputado presente').check()
|
||||
const omniboxInput = page.getByRole('combobox', { name: 'Filtrar agenda' })
|
||||
|
||||
await omniboxInput.fill(municipality.name!)
|
||||
await page.getByRole('option', { name: municipality.name, exact: true }).click()
|
||||
|
||||
await omniboxInput.fill('Comício')
|
||||
await page.getByRole('option', { name: 'Comício', exact: true }).click()
|
||||
|
||||
await omniboxInput.fill('Deputado')
|
||||
await page.getByRole('option', { name: 'Deputado presente', exact: true }).click()
|
||||
await expect(page.locator('button[aria-label="Remover Deputado presente"]')).toBeVisible()
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}&deputyPresent=1&tag=Com%C3%ADcio`,
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
await expect(page.getByText(title, { exact: true })).toBeVisible()
|
||||
|
||||
|
|
@ -158,10 +168,14 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
|
||||
const dayCell = page.getByRole('gridcell').nth(1)
|
||||
await dayCell.scrollIntoViewIfNeeded()
|
||||
const [slotBox, dayBox] = await Promise.all([
|
||||
page.locator('[data-time="14:00:00"]').last().boundingBox(),
|
||||
dayCell.boundingBox(),
|
||||
])
|
||||
// FullCalendar renders the time axis lazily and re-lays out on mount —
|
||||
// wait for both targets to be attached+visible before measuring, or the
|
||||
// bounding boxes race the grid render.
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
await expect(slotLocator).toBeVisible()
|
||||
await expect(dayCell).toBeVisible()
|
||||
const slotBox = await slotLocator.boundingBox()
|
||||
const dayBox = await dayCell.boundingBox()
|
||||
if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.')
|
||||
await dayCell.click({
|
||||
position: {
|
||||
|
|
@ -206,10 +220,11 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
|
||||
const dayCell = page.getByRole('gridcell').nth(1)
|
||||
await dayCell.scrollIntoViewIfNeeded()
|
||||
const [slotBox, dayBox] = await Promise.all([
|
||||
page.locator('[data-time="14:00:00"]').last().boundingBox(),
|
||||
dayCell.boundingBox(),
|
||||
])
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
await expect(slotLocator).toBeVisible()
|
||||
await expect(dayCell).toBeVisible()
|
||||
const slotBox = await slotLocator.boundingBox()
|
||||
const dayBox = await dayCell.boundingBox()
|
||||
if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.')
|
||||
await dayCell.click({
|
||||
position: {
|
||||
|
|
|
|||
|
|
@ -130,7 +130,10 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
await expect(page.getByText('Otimista: 5.000').nth(0)).toBeVisible()
|
||||
|
||||
// B145: assessoria left the detail hero; it still appears on the dossiê tab.
|
||||
// `.first()`: transient RSC-pending duplication also duplicates this line
|
||||
// (same strict-mode flake as the "Otimista" assert above).
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=dossie`)
|
||||
|
||||
// `.nth(0)`: same transient hydration duplicate the "Otimista: 5.000"
|
||||
// assertion pins — the server-composed slot briefly renders twice.
|
||||
await expect(page.getByText(`Assessoria: ${advisor.name}`).nth(0)).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -51,8 +51,10 @@ test.describe('Territórios de Identidade', () => {
|
|||
await campaign.login(page, email, password)
|
||||
await page.goto('/campanha/territorios')
|
||||
|
||||
await expect(page.locator('#ti-irece')).toBeVisible()
|
||||
await expect(page.locator('#ti-velho-chico')).toBeVisible()
|
||||
// `.first()`: transient RSC-pending duplication copies the rows (observed
|
||||
// strict-mode flake on loaded machines; CI stays green).
|
||||
await expect(page.locator('#ti-irece').first()).toBeVisible()
|
||||
await expect(page.locator('#ti-velho-chico').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('a wide panel surfaces the read-only network columns; Cobertura stays hidden by default', async ({
|
||||
|
|
|
|||
|
|
@ -85,7 +85,9 @@ test('enrolls a passkey, signs in with it, then revokes it', async ({
|
|||
await page.getByRole('button', { name: 'Ativar neste aparelho' }).click()
|
||||
|
||||
await expect(page.getByText('Celular da assessora')).toBeVisible({ timeout: CEREMONY_TIMEOUT })
|
||||
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeHidden()
|
||||
// `.first()`: a transient RSC-pending duplication copies this empty-state
|
||||
// line (observed strict-mode flake on loaded machines; CI stays green).
|
||||
await expect(page.getByText('Nenhum aparelho cadastrado ainda.').first()).toBeHidden()
|
||||
|
||||
// 2. Password-less sign-in issues the 14-day cookie — enrolling the device IS
|
||||
// the "remember me" opt-in, so there is no checkbox in this path.
|
||||
|
|
@ -106,7 +108,7 @@ test('enrolls a passkey, signs in with it, then revokes it', async ({
|
|||
// the profile and it has to actually stop the credential.
|
||||
await page.goto('/campanha/perfil')
|
||||
await page.getByRole('button', { name: 'Remover Celular da assessora' }).click()
|
||||
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeVisible()
|
||||
await expect(page.getByText('Nenhum aparelho cadastrado ainda.').first()).toBeVisible()
|
||||
|
||||
await context.clearCookies()
|
||||
await page.goto('/campanha/login')
|
||||
|
|
|
|||
135
tests/unit/activityAgendaOmnibox.unit.spec.ts
Normal file
135
tests/unit/activityAgendaOmnibox.unit.spec.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
applyActivityAgendaOmniboxSuggestion,
|
||||
buildActivityAgendaOmniboxChips,
|
||||
buildActivityAgendaOmniboxSuggestionSeeds,
|
||||
clearActivityAgendaOmnibox,
|
||||
filterActivityAgendaOmniboxSuggestions,
|
||||
removeActivityAgendaOmniboxChip,
|
||||
} from '@/utilities/activityAgendaOmnibox'
|
||||
|
||||
const municipalityLabelsById = new Map<number, string>([
|
||||
[12, 'Salvador'],
|
||||
[34, 'Feira de Santana'],
|
||||
])
|
||||
|
||||
const municipalityOptions = [
|
||||
{ value: '12', label: 'Salvador' },
|
||||
{ value: '34', label: 'Feira de Santana' },
|
||||
]
|
||||
|
||||
describe('agenda omnibox adapter (C94)', () => {
|
||||
it('builds removable chips for the three dimensions', () => {
|
||||
const chips = buildActivityAgendaOmniboxChips({
|
||||
state: { municipality: 12, tag: 'Comício', deputyPresent: true },
|
||||
municipalityLabelsById,
|
||||
})
|
||||
expect(chips.map((chip) => chip.id)).toEqual([
|
||||
'municipality:12',
|
||||
'tag:Comício',
|
||||
'deputyPresent',
|
||||
])
|
||||
expect(chips[0]!.label).toBe('Município: Salvador')
|
||||
expect(chips[1]!.label).toBe('Tag: Comício')
|
||||
expect(chips[2]!.label).toBe('Deputado presente')
|
||||
})
|
||||
|
||||
it('falls back to the numeric id when a municipality label is unknown', () => {
|
||||
const chips = buildActivityAgendaOmniboxChips({
|
||||
state: { municipality: 99 },
|
||||
municipalityLabelsById,
|
||||
})
|
||||
expect(chips[0]!.label).toBe('Município: Município #99')
|
||||
})
|
||||
|
||||
it('suggests deputado presente and tags on empty query, municípios by typing', () => {
|
||||
const seeds = buildActivityAgendaOmniboxSuggestionSeeds({
|
||||
municipalityOptions,
|
||||
knownTags: ['Comício', 'Panfletagem'],
|
||||
})
|
||||
|
||||
const emptyQuery = filterActivityAgendaOmniboxSuggestions(seeds, '')
|
||||
expect(emptyQuery.some((entry) => entry.id === 'deputyPresent')).toBe(true)
|
||||
expect(emptyQuery.some((entry) => entry.id === 'tag:Comício')).toBe(true)
|
||||
expect(emptyQuery.some((entry) => entry.id === 'municipality:12')).toBe(false)
|
||||
|
||||
const typed = filterActivityAgendaOmniboxSuggestions(seeds, 'feira')
|
||||
expect(typed.some((entry) => entry.id === 'municipality:34')).toBe(true)
|
||||
|
||||
const grouped = typed.find((entry) => entry.id === 'municipality:34')
|
||||
expect(grouped?.group).toBe('Município')
|
||||
})
|
||||
|
||||
it('never surfaces the free-text Busca row (no text search on the agenda)', () => {
|
||||
const seeds = buildActivityAgendaOmniboxSuggestionSeeds({
|
||||
municipalityOptions,
|
||||
knownTags: ['Comício'],
|
||||
})
|
||||
const suggestions = filterActivityAgendaOmniboxSuggestions(seeds, 'comí')
|
||||
expect(suggestions.some((entry) => entry.id.startsWith('q:'))).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles deputado presente on and off', () => {
|
||||
const on = applyActivityAgendaOmniboxSuggestion({
|
||||
state: {},
|
||||
suggestionId: 'deputyPresent',
|
||||
})
|
||||
expect(on.kind).toBe('url')
|
||||
if (on.kind === 'url') expect(on.state.deputyPresent).toBe(true)
|
||||
|
||||
const off = applyActivityAgendaOmniboxSuggestion({
|
||||
state: { deputyPresent: true },
|
||||
suggestionId: 'deputyPresent',
|
||||
})
|
||||
expect(off.kind).toBe('url')
|
||||
if (off.kind === 'url') expect(off.state.deputyPresent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('toggles tag and municipality and removes chips', () => {
|
||||
const base = { municipality: 12, tag: 'Comício' }
|
||||
const toggledTag = applyActivityAgendaOmniboxSuggestion({
|
||||
state: base,
|
||||
suggestionId: 'tag:Comício',
|
||||
})
|
||||
if (toggledTag.kind === 'url') expect(toggledTag.state.tag).toBeUndefined()
|
||||
|
||||
const toggledMunicipality = applyActivityAgendaOmniboxSuggestion({
|
||||
state: base,
|
||||
suggestionId: 'municipality:34',
|
||||
})
|
||||
if (toggledMunicipality.kind === 'url') expect(toggledMunicipality.state.municipality).toBe(34)
|
||||
|
||||
const removedMunicipality = removeActivityAgendaOmniboxChip({
|
||||
state: { municipality: 12 },
|
||||
chipId: 'municipality:12',
|
||||
})
|
||||
if (removedMunicipality.kind === 'url')
|
||||
expect(removedMunicipality.state.municipality).toBeUndefined()
|
||||
|
||||
const removedDeputy = removeActivityAgendaOmniboxChip({
|
||||
state: { deputyPresent: true },
|
||||
chipId: 'deputyPresent',
|
||||
})
|
||||
if (removedDeputy.kind === 'url') expect(removedDeputy.state.deputyPresent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores invalid suggestion ids and q: leftovers with a no-op url action', () => {
|
||||
const state = { tag: 'Comício' }
|
||||
const bogus = applyActivityAgendaOmniboxSuggestion({ state, suggestionId: 'q:whatever' })
|
||||
expect(bogus.kind).toBe('url')
|
||||
if (bogus.kind === 'url') expect(bogus.state.tag).toBe('Comício')
|
||||
|
||||
const invalid = applyActivityAgendaOmniboxSuggestion({
|
||||
state,
|
||||
suggestionId: 'municipality:abc',
|
||||
})
|
||||
expect(invalid.kind).toBe('url')
|
||||
if (invalid.kind === 'url') expect(invalid.state.tag).toBe('Comício')
|
||||
})
|
||||
|
||||
it('clears to an empty agenda state', () => {
|
||||
const cleared = clearActivityAgendaOmnibox()
|
||||
expect(cleared.kind).toBe('clear')
|
||||
})
|
||||
})
|
||||
|
|
@ -385,6 +385,7 @@ describe('src/utilities top-level is pinned', () => {
|
|||
const pinnedTopLevel = new Set<string>([
|
||||
'activityDetailPageData.ts',
|
||||
'activityDetailTabUi.ts',
|
||||
'activityAgendaOmnibox.ts',
|
||||
'activityFormData.ts',
|
||||
'activityInlineErrors.ts',
|
||||
'activityOmnibox.ts',
|
||||
|
|
|
|||
Loading…
Reference in a new issue