Merge pull request #492 from fsolla/C97-agenda-seletor-de-dia-horario-shadcn-no-popover-de-criac
C97 — Agenda: seletor de dia + horário (shadcn) no popover de criação rápida
This commit is contained in:
commit
c13e84af5e
13 changed files with 670 additions and 36 deletions
|
|
@ -12,6 +12,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**Recently resolved (2026-08-09):** **B180** (Issue #474) — as tools eleitorais legadas do Sollinha (`getTopDeputies`, `getMunicipalityVotes`) ganharam o **gate fail-closed de leader lockdown** que a B177 introduziu só na tool nova: `assertCanReadElectionData(ctx.user)` no topo do `execute` → `{ error: 'Leitura de dados eleitorais negada.' }` antes de qualquer query (o route `api/ai-chat` expõe todas as tools a todos os papéis). O padrão chegou a **3 call sites** → extraído um helper compartilhado `electionDataGate(ctx)` (`src/utilities/ai/tools/electionDataGate.ts`, `true | { error }`), e `getLeadingMunicipalities` refatorado para usá-lo (mensagem com fonte única). Unit tests novos (`electionToolsLockdown.unit.spec.ts`, 11): helper (leader nega/staff passa), as 3 tools com leader + payload stub intocado (fail-closed antes de query, inclusive no path SQL de terceiro candidato) e staff-proceeds por papel (coordinator/advisor/candidate). Sem migration/Consent/UI/prompt. Intenção [`sollinha-tools-eleitorais-leader-lockdown.md`](plans/sollinha-tools-eleitorais-leader-lockdown.md); impl [`sollinha-tools-eleitorais-leader-lockdown-impl.md`](plans/sollinha-tools-eleitorais-leader-lockdown-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **C97** (Issue #482) — Início/Término do overlay de criação rápida da agenda (C91) trocaram o `datetime-local` nativo (que renderiza AM/PM no idioma do browser e só deixava escolher o dia de forma confiável) por um **seletor shadcn de dia+horário 24h por construção**: `ActivityDateTimeField` (trigger com rótulo civil `dd/mm/aaaa às hh:mm` + popover com `Calendar` react-day-picker 9.7 `timeZone="America/Bahia"` locale pt-BR e selects nativos Hora 00–23 / Minuto em passos de 15). Contrato de valor inalterado (`YYYY-MM-DDTHH:mm` civil → `parseBahiaDateTimeInput` no submit), prefill do slot mantido, validações intactas; dia trocado no calendário preserva hora e vice-versa; popover com `max-h-[22rem]` + scroll (clippava no topo em viewports baixos — nav do calendário ficava inalcançável). Componente shadcn canônico `src/components/ui/Calendar.tsx` (variante radix-nova, lucide icons; `--cell-size` via `.cn-calendar` no styles.css) + helpers puros em `campaignTime.ts` (`formatBahiaCivilDateTimeLabel`, `hourOptions`/`minuteOptionsForStep`/`timeStepMinutes`, `floorToMinuteStep` — prefill de slot é múltiplo de 30, floor é defensivo). Única dep nova: `react-day-picker@^9.7.0` (date-fns transitivo). E2E do fluxo inline agora muda o horário pelo seletor e prova o chip "15:00 – 16:30" (24h) na grade. Sem migration/access/Consent. **Fatia mínima aprovada no gate**: eixo/chips do FullCalendar continuam `meridiem: 'short'` ("2 PM") e `formatBahiaDateTimeLabel` sem `hourCycle` explícito — follow-ups registrados no impl plan. Intenção [`c97-agenda-popover-seletor-data-hora.md`](plans/c97-agenda-popover-seletor-data-hora.md); impl [`c97-agenda-popover-seletor-data-hora-impl.md`](plans/c97-agenda-popover-seletor-data-hora-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **B176** (Issue #458) — `/campanha/municipios` ganhou três dimensões de filtro na omnibox — **Dobradinha** (nome do contato), **Liderança** (nome do contato) e **Partido** (sigla) — com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" (precedente "Sem nível"). Params aditivos `stateDeputy`/`leadership`/`party` na URL congelada (sentinelas `sem_dobradinha`/`sem_lideranca`, reuso de `sem_partido`); `buildMunicipalityListWhere` ganhou um catálogo request-scoped fail-closed (`municipalityRelationSets.ts`, lazy: metade liderança só com filtro ativo) que vira as leituras reversa (`leadership.municipalities`) e cruzada (`stateDeputy.party`) em `where` — sem migration, paginação nativa mantida; facets estendidas (`stateDeputyIDs`/`leadershipOptions`/`parties`) e mapa compartilha o catálogo. **Coluna Dobradinhas liberada ao Assessor** (staff-wide): servidor já estava scoped (B37/B157 `reloadStaffActor` + `canUpdateMunicipality`), a mudança foi só de gate de UI (lista/mobile/picker) + catálogo full-staff para a célula (decisão do gate humano 2026-08-09). Extrato do par "sentinela + `exists:false`" em `campaignListUrl` (`NO_PARTY_FILTER_VALUE`, `splitAbsenceFilterValues`, `collapseListWhereOrBranches`) com 5 call sites. E2E: assessor vê/edita a coluna no portfólio; filtros pelas três dimensões; teste de coluna-restrita invertido. Intenção [`filtros-municipios-dobradinha-lideranca.md`](plans/filtros-municipios-dobradinha-lideranca.md); impl [`filtros-municipios-dobradinha-lideranca-impl.md`](plans/filtros-municipios-dobradinha-lideranca-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-09):** **B174 reverso** (sem Issue — pedido direto) — a Sollinha (`/campanha/api/ai-chat`) voltou do `@ai-sdk/deepinfra` para o **`@ai-sdk/deepseek`** (API oficial `api.deepseek.com`): o cache-hit do V4-Flash direto custa **$0.0028/M** contra **$0.018/M** na Deep Infra (~6,4× mais barato), e a Sollinha tem prefixo estável (system prompt + tools + histórico) — o hit ratio alto derruba o custo efetivo mesmo com miss ($0.14 vs $0.09) e output ($0.28 vs $0.18) mais caros. Mesmo modelo (`deepseek-v4-flash`), mesmas tools; `@ai-sdk/deepseek` **pinado em `3.0.19`** (não `^3.0.19`, que flutuaria a 3.0.26 e puxaria a ferroviária `provider@4.0.7` duplicada — o lock ficou na `4.0.4/5.0.18` do `ai@7.0.47`, verificado no lockfile). **STT fica na Deep Infra** (B173): `whisper-large-v3` a $0.00045/min de áudio é o mais barato verificado (Groq $0.0019, OpenAI $0.006); `.env.example` agora documenta **as duas chaves** — `DEEPSEEK_API_KEY` (LLM) e `DEEPINFRA_API_KEY` (STT). Aviso da DeepSeek: aumento de preço anunciado ("em breve"). Sem migration, access, UI ou teste novo; smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar.
|
||||
|
|
|
|||
117
docs/plans/c97-agenda-popover-seletor-data-hora-impl.md
Normal file
117
docs/plans/c97-agenda-popover-seletor-data-hora-impl.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Impl: Agenda — seletor de dia + horário (shadcn) no popover de criação rápida
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #482
|
||||
Intenção: docs/plans/c97-agenda-popover-seletor-data-hora.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** no overlay de criação rápida da agenda (popover desktop + bottom sheet mobile), Início e Término deixam de usar `datetime-local` e passam a usar um seletor shadcn de dia (calendário) **e** horário (passos selecionáveis); prefill do slot mantido e ajustável; salvar grava o horário efetivamente escolhido; fuso America/Bahia inalterado; fluxo pós-salvar inalterado. **Gate:** fatia mínima aprovada pelo humano — o AM/PM reportado é o `datetime-local` nativo do popover (renderiza no idioma do browser); o controle novo é 24h **por construção** (trigger `dd/mm/aaaa às hh:mm` + selects Hora 00–23 / Minuto 00–45, string civil pura).
|
||||
- **O que NÃO negociar:** prefill do slot clicado (semana/dia = slot 30 min; mês = 09:00–10:00); validações atuais (Início obrigatório; Término > Início); America/Bahia como fronteira de fuso (helpers de `campaignTime.ts`); overlay não vira o formulário completo; nada de all-day; passos de hora **visíveis e selecionáveis**.
|
||||
- **O que reavaliar:** a intenção aponta o seletor da doc shadcn (Calendar + horário). A composição de referência da shadcn usa `input type="time"` com o picker nativo escondido (digitado) — isso **não** satisfaz o aceite "passos visíveis e selecionáveis". A forma técnica é livre: manter o calendário da shadcn, trocar a parte de horário por selects de passos.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[ActivityInlineCreate] --> B[ActivityDateTimeField<br/>trigger + Popover]
|
||||
B --> C[ui/calendar.tsx<br/>react-day-picker 9<br/>timeZone America/Bahia]
|
||||
B --> D[NativeSelect hora + minuto<br/>passo 15 min]
|
||||
B --> E[civil string YYYY-MM-DDTHH:mm<br/>= contrato atual]
|
||||
E --> F[submit/validação atuais<br/>inalterados]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (recomendada) | B | C | D
|
||||
**Recomendação:** **A** — manter o contrato de valor civil `YYYY-MM-DDTHH:mm` (idêntico ao `datetime-local` atual), com um campo novo que renderiza trigger + popover shadcn (Calendar + 2 `NativeSelect` de hora/minuto em passos de 15 min). Zero mudança no caminho de submit/validação/parse (`parseBahiaDateTimeInput`), no prefill (`activitySlotPrefill`), em `activityUi.ts` ou no fluxo pós-salvar.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Dependência do calendário.**
|
||||
|
||||
- Opções: A) `react-day-picker@^9.7.0` (versão pinada pela shadcn v4 — `apps/v4/package.json`) | B) `react-day-picker@10` (latest) | C) grade de mês própria.
|
||||
- Recomendação: A — o componente shadcn canônico `calendar.tsx` (variante radix-nova, compatível com o `components.json` do repo `style: radix-nova`) usa exatamente a API v9 (`DayPicker`, `getDefaultClassNames`, `DayButton`, `captionLayout`); `date-fns@4.1.0` vem como dependência transitiva, sem adicionar dep direta.
|
||||
- Rejeitadas: B porque a API v10 não é a do componente canônico e o `timeZone`/`DayButton` mudariam o código para uma variante não referenciada; C porque calendário acessível/i18n é rabbit hole (a11y de teclado, pt-BR, `today`, ranges) — reusar a peça shadcn é o padrão do repo.
|
||||
|
||||
**D2 — Origem do `src/components/ui/calendar.tsx`.**
|
||||
|
||||
- Opções: A) adaptar à mão o fonte canônico radix-nova (substituir `IconPlaceholder` por `lucide-react`, trocar imports `@/`) | B) `pnpm dlx shadcn add calendar`.
|
||||
- Recomendação: A — determinístico num worktree, sem risco de o CLI reescrever `components.json`/styles ou instalar versão divergente.
|
||||
- Rejeitada: B porque o CLI v4 pode resolver `react-day-picker@10` ou tocar arquivos fora do escopo; a adaptação manual do fonte pinado é 100% reproduzível.
|
||||
|
||||
**D3 — Controle de horário (passos).**
|
||||
|
||||
- Opções: A) dois `NativeSelect` (Hora 00–23 + Minuto 00/15/30/45, passo 15) | B) `input type="time"` com indicador nativo escondido (composição da doc shadcn) | C) steppers segmentados hora/minuto com botões +/− | D) um select com 48+ combinações.
|
||||
- Recomendação: A — passos visíveis e selecionáveis (aceite), nativo no mobile (roleta do SO), `NativeSelect` já existe em `src/components/ui/native-select.tsx` (mesma linguagem visual dos inputs do repo), testável em e2e sem depender de implementação de picker nativo do browser.
|
||||
- Rejeitadas: B porque é digitação, não seleção — não cumpre o aceite, e no mobile reabre o picker nativo pouco confiável que o item está eliminando; C porque é a superfície a11y mais cara (foco, aria, rolagem) sem ganho de produto; D porque 48 opções num select é pior de navegar que dois selects.
|
||||
- Passo: 15 min (recomendação de produto da intenção). Prefill de slot é sempre múltiplo de 15 (slots de 30 min; mês 09:00–10:00); se um valor fora do passo aparecer (defensivo), helper puro `floorToMinuteStep` arredonda para baixo + unit test.
|
||||
|
||||
**D4 — Contrato de valor.**
|
||||
|
||||
- Opções: A) manter civil string `YYYY-MM-DDTHH:mm` no estado do overlay | B) migrar o estado para instantes ISO `Date`.
|
||||
- Recomendação: A — o estado atual (`start`/`end` em `ActivityInlineCreate`) já é civil string; `parseBahiaDateTimeInput`/`formatIsoAsBahiaDateTimeInput` continuam sendo a fronteira única. O campo novo devolve/consome a mesma string, e o diff fica contido no componente.
|
||||
- Rejeitada: B porque reescreve o caminho de submit (validação, `createActivityInline`, `parseInlineResponsibles`) sem ganho de produto — custo de reverter alto, benefício zero.
|
||||
|
||||
**D5 — Fuso no calendário.**
|
||||
|
||||
- Opções: A) prop `timeZone="America/Bahia"` do RDP v9 + `locale={ptBR}` | B) construir/ler datas em UTC local do cliente.
|
||||
- Recomendação: A — o RDP 9.7 suporta `timeZone` nativamente; `selected` é ancorado num instante do meio-dia baiano (`parseBahiaDateTimeInput('<dia>T12:00')`) e `onSelect` deriva o dia civil com o helper existente `formatBahiaCivilDate` — robusto independentemente do fuso do browser (CI incluso).
|
||||
- Rejeitada: B porque quebraria nos browsers fora de UTC-3 (testes locais/CI) e duplicaria lógica que `campaignTime.ts` já tem.
|
||||
|
||||
**D6 — 24h no popover (fatia mínima aprovada no gate).** O AM/PM que o usuário viu é o `datetime-local` nativo do popover: o browser renderiza o controle no idioma da UI (en-US → "2:30 PM") e não há como forçar 24h num controle nativo. O controle novo é 24h **por construção** — o trigger formata string civil pura (`dd/mm/aaaa às hh:mm`) e os selects são Hora 00–23 + Minuto em passos, sem meridiem — imune ao idioma do browser.
|
||||
|
||||
- **Cortado no gate (follow-up registrado):** eixo de horário e chips do mês do FullCalendar (v7 usa `meridiem: 'short'` → "2 PM" mesmo com locale pt-br) e `hourCycle` explícito no `bahiaDateTimeDisplayFormatter` — o humano escolheu fatia mínima; virar follow-up se o produto quiser 24h na agenda e nos rótulos.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`react-day-picker@^9.7.0`** (`package.json`): única dependência nova (date-fns transitivo).
|
||||
- **`src/components/ui/calendar.tsx`** (novo): componente shadcn radix-nova adaptado do fonte canônico (`apps/v4/registry/bases/radix/ui/calendar.tsx`); `IconPlaceholder` → `ChevronLeft`/`ChevronRight`/`ChevronDown` de `lucide-react`; imports `@/components/ui/button` + `@/lib/utils`.
|
||||
- **`src/app/(frontend)/styles.css`**: regras `.cn-calendar`, `.cn-calendar-dropdown-root`, `.cn-calendar-caption-label` (fonte `style-nova.css`) — definem `--cell-size:--spacing(7)` e `--cell-radius:var(--radius-md)` (token `--radius-md` já existe no arquivo, linha 83). O layout da campanha importa este CSS (layout de `(campaign)`).
|
||||
- **`src/lib/campaignTime.ts`** (owner de fuso/hora — editar, não twinar): helpers puros novos `formatBahiaCivilDateTimeLabel` (civil string → `dd/mm/aaaa às hh:mm`, 24h por construção), `timeStepMinutes = 15`, `hourOptions`/`minuteOptionsForStep`, `floorToMinuteStep`.
|
||||
- **`src/components/campaign/activity/ActivityDateTimeField.tsx`** (novo, convenção `Activity*` do domínio): `Field` + trigger `Button` (valor formatado + ícone de calendário, `id`/`aria-labelledby` para o `FieldLabel` existente) + `Popover` contendo `Calendar` e os dois `NativeSelect`; props `{ id, label, value: civil string, onValueChange, error, invalid }`; `onSelect` do calendário troca só a data mantendo hora/minuto atuais; selects trocam só hora/minuto mantendo a data.
|
||||
- **`src/components/campaign/activity/ActivityInlineCreate.tsx`**: substituir os dois `Input type="datetime-local"` por dois `ActivityDateTimeField` (Início \*, Término); estado `start`/`end` continua civil string; submit/validação/`parseBahiaDateTimeInput` **inalterados**. Como o novo controle não permite valor vazio, `end` deixa de poder ser limpo — o modelo continua aceitando `endAt` ausente, mas na prática o overlay sempre envia término (válido: o aceite exige Término > Início e nada de all-day). O AM/PM do `datetime-local` nativo some junto (24h por construção).
|
||||
- **Migration:** sem migration (sem schema).
|
||||
- **Access / Consent:** nenhum toque.
|
||||
- **UI:** Impeccable B — encaixe nos campos existentes: manter os dois campos na mesma grid `sm:grid-cols-2`, mesma altura visual dos inputs (`min-h-11`), erro no formato `FieldError` atual. Popover interno ancorado ao trigger (Radix vira quando falta espaço). Acessível: label visível + trigger `aria-labelledby`, calendário navegável por teclado (RDP), selects nativos.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A — controle de entrada, sem dados apresentados (intenção: "Vou apresentar dados? Não").
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — dependência + calendário** (quota pequena): `pnpm add react-day-picker@^9.7.0`; criar `ui/calendar.tsx`; regras CSS; smoke no dev (renderiza, `timeZone` ok, `ptBR` ok, células com tamanho certo). Verificar no browser com a agenda local.
|
||||
2. **Helpers puros**: `campaignTime.ts` + unit tests (`campaignTimeCivilDate.unit.spec.ts` ou spec irmã): `formatBahiaCivilDateTimeLabel`, `floorToMinuteStep`, opções de hora/minuto.
|
||||
3. **Campo novo + wiring**: `ActivityDateTimeField.tsx`; troca no `ActivityInlineCreate.tsx`; smoke do fluxo completo no dev (popover e drawer mobile): prefill do slot, trocar dia no calendário sem perder hora, trocar hora/minuto sem perder dia, salvar grava o escolhido, "Mais detalhes" intacto, trigger sempre 24h (sem AM/PM, qualquer idioma de browser).
|
||||
4. **Testes**: atualizar `tests/unit/activityInlineCreate.unit.spec.tsx` (trigger por label, valor via texto do botão; interação com calendário/selects — clicar dia, mudar hora, erro de Término ≤ Início); `activityInlineErrors.unit.spec.ts` se tocar os campos; novo spec unit do campo/pickers se precisar de interação isolada; e2e `tests/e2e/campaignActivity.e2e.spec.ts` — teste inline passa a ler o rótulo do trigger (sem `.inputValue()`), e ganha um passo que muda o horário pelo seletor e salva (aceite central: "Salvar grava o horário efetivamente escolhido").
|
||||
5. **Gates**: `pnpm gate:fast` → `pnpm test:int` → e2e afetado (`pnpm test:e2e` agenda) → `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles` → `pnpm build` (DB local) → scan Aikido dos arquivos tocados → registro no CHANGELOG-AGENTS → `pnpm push` → PR `--base main` `Closes #482` → auto-merge → `gh pr checks --watch --required`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Formulário completo e tarefas** (`ActivityForm.tsx`, `ActivityTaskFields.tsx`): continuam `datetime-local` — fora do escopo; o campo novo é independente o bastante para um swap futuro barato (registrar follow-up).
|
||||
- **Steppers segmentados / range no calendário / sincronizar Término com Início**: cortes de produto da intenção — não implementar.
|
||||
- **Persian/Hijri/RTL, `captionLayout="dropdown"`, presets, booked dates**: o componente canônico aceita via props, mas nada disso é usado.
|
||||
- **`generate:importmap`**: não se aplica (componentes de app, não de admin Payload).
|
||||
- **`datetime-local` em outros pontos do repo**: procurar e registrar follow-up único se houver mais ocorrências além do form/tarefas.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Comportamento da prop `timeZone` do RDP 9.7.0**: mitigar com `selected` ancorado no meio-dia baiano + derivação via `formatBahiaCivilDate`; verificar no dev browser **antes** do e2e; se a prop não se comportar, fallback é formatar a seleção em America/Bahia via helpers existentes (mudança contida no campo).
|
||||
- **Popover aninhado (overlay da agenda + picker)**: Radix porta o conteúdo para `body`; padrão documentado da shadcn (date-picker em dialog); validar abertura/fechamento/clique fora no dev e no e2e.
|
||||
- **e2e existente usa `getByLabel('Início *').inputValue()`**: o trigger novo é um `Button` — `.inputValue()` falha; atualizar a asserção para o texto do rótulo (e a unidade correspondente). Não há outro teste que dependa do `datetime-local` do overlay (o form completo continua com input — testes dele intactos).
|
||||
- **CSS do calendário**: se as regras `.cn-calendar` não forem adicionadas, células perdem o tamanho padrão (`--cell-size`) — coberto na Fase 1 com smoke visual.
|
||||
- **Prefill fora do passo de 15 min**: hoje impossível (slots de 30 min / janela de 1h), mas `floorToMinuteStep` garante invariante e tem unit test.
|
||||
|
||||
## Follow-ups registrados (fora da fatia mínima)
|
||||
|
||||
- **Eixo de horário e chips do mês do FullCalendar em AM/PM** (v7: `DEFAULT_SLAT_LABEL_FORMAT`/`DEFAULT_TABLE_EVENT_TIME_FORMAT` com `meridiem: 'short'`): fix de 2 linhas (`slotLabelFormat`/`eventTimeFormat` com `meridiem: false`) — registrado porque o humano cortou no gate; produto decide.
|
||||
- **`hourCycle: 'h23'` explícito no `bahiaDateTimeDisplayFormatter`** (`formatBahiaDateTimeLabel`): hardening engine-proof (V8 já é h23; Safari/WebKit tem histórico de divergência) — mesmo item de produto acima.
|
||||
- **Trocar `datetime-local` do formulário completo e das tarefas** pelo `ActivityDateTimeField` — barato agora que o componente existe.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (dia+horário selecionáveis, prefill mantido, validações, fuso, fluxo pós-salvar) + popover sem AM/PM (24h por construção no trigger e nos selects)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (sem schema/access/Consent tocados; identificadores em inglês; copy pt-BR)
|
||||
- [ ] Testes de domínio previstos (unit helpers + unit campo + e2e agenda com mudança de horário)
|
||||
- [ ] `model-local: deepseek-v4-flash-high` registrado na Issue #482 (mapping canônico de `model: composer-2.5`; ausente no frontmatter)
|
||||
|
|
@ -108,6 +108,7 @@
|
|||
"payload": "3.82.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ importers:
|
|||
version: 4.0.50(react@19.2.4)(zod@4.3.6)
|
||||
'@base-ui/react':
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0(@date-fns/tz@1.2.0)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@fullcalendar/react':
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(temporal-polyfill@1.0.3)
|
||||
|
|
@ -101,6 +101,9 @@ importers:
|
|||
react:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.4
|
||||
react-day-picker:
|
||||
specifier: ^9.14.0
|
||||
version: 9.14.0(react@19.2.4)
|
||||
react-dom:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.4(react@19.2.4)
|
||||
|
|
@ -573,6 +576,9 @@ packages:
|
|||
'@date-fns/tz@1.2.0':
|
||||
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
|
||||
|
||||
'@date-fns/tz@1.5.0':
|
||||
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
|
||||
|
||||
'@dependents/detective-less@5.0.3':
|
||||
resolution: {integrity: sha512-v6oD9Ukp+N7V4n6p5I/+mM5fIohSfkrDSGlFm5w/pYmchvbk+sMIHsLxrFJ5Lnujewj1BzWL0K84d88lwZAMQA==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -3021,6 +3027,10 @@ packages:
|
|||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@tabby_ai/hijri-converter@1.0.5':
|
||||
resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@tailwindcss/node@4.1.18':
|
||||
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
|
||||
|
||||
|
|
@ -4242,6 +4252,9 @@ packages:
|
|||
dataloader@2.2.3:
|
||||
resolution: {integrity: sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==}
|
||||
|
||||
date-fns-jalali@4.1.0-0:
|
||||
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
|
||||
|
||||
date-fns@3.6.0:
|
||||
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
|
||||
|
||||
|
|
@ -6555,6 +6568,12 @@ packages:
|
|||
react: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
react-day-picker@9.14.0:
|
||||
resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
react-dom@19.2.4:
|
||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -8068,7 +8087,7 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@base-ui/react@1.6.0(@date-fns/tz@1.2.0)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@base-ui/react@1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@base-ui/utils': 0.3.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -8078,7 +8097,7 @@ snapshots:
|
|||
react-dom: 19.2.4(react@19.2.4)
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@date-fns/tz': 1.2.0
|
||||
'@date-fns/tz': 1.5.0
|
||||
'@types/react': 19.2.14
|
||||
date-fns: 4.1.0
|
||||
|
||||
|
|
@ -8119,6 +8138,8 @@ snapshots:
|
|||
|
||||
'@date-fns/tz@1.2.0': {}
|
||||
|
||||
'@date-fns/tz@1.5.0': {}
|
||||
|
||||
'@dependents/detective-less@5.0.3':
|
||||
dependencies:
|
||||
gonzales-pe: 4.3.0
|
||||
|
|
@ -10552,6 +10573,8 @@ snapshots:
|
|||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tabby_ai/hijri-converter@1.0.5': {}
|
||||
|
||||
'@tailwindcss/node@4.1.18':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
|
|
@ -11725,6 +11748,8 @@ snapshots:
|
|||
|
||||
dataloader@2.2.3: {}
|
||||
|
||||
date-fns-jalali@4.1.0-0: {}
|
||||
|
||||
date-fns@3.6.0: {}
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
|
|
@ -14546,6 +14571,14 @@ snapshots:
|
|||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
react-day-picker@9.14.0(react@19.2.4):
|
||||
dependencies:
|
||||
'@date-fns/tz': 1.5.0
|
||||
'@tabby_ai/hijri-converter': 1.0.5
|
||||
date-fns: 4.1.0
|
||||
date-fns-jalali: 4.1.0-0
|
||||
react: 19.2.4
|
||||
|
||||
react-dom@19.2.4(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
|
|
|||
|
|
@ -413,6 +413,20 @@ body,
|
|||
}
|
||||
}
|
||||
|
||||
/* MARK: Calendar (shadcn radix-nova) — cell geometry tokens for the day grid.
|
||||
`--cell-size`/`--cell-radius` are consumed by ui/calendar.tsx classNames. */
|
||||
.cn-calendar {
|
||||
@apply p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)];
|
||||
}
|
||||
|
||||
.cn-calendar-dropdown-root {
|
||||
@apply border-input has-focus:ring-ring/50 border has-focus:border-ring has-focus:ring-3;
|
||||
}
|
||||
|
||||
.cn-calendar-caption-label {
|
||||
@apply h-6 pr-1 pl-1.5;
|
||||
}
|
||||
|
||||
/* E16 — municipality dossier print view: compact A4 typography (one step down
|
||||
the DESIGN.md ramp: body text-sm→text-xs, section titles text-base→text-sm),
|
||||
sections kept whole across page breaks. Chrome hiding lives in `print:`
|
||||
|
|
@ -423,7 +437,6 @@ body,
|
|||
size: A4;
|
||||
margin: 14mm;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
|
|
|
|||
131
src/components/campaign/activity/ActivityDateTimeField.tsx
Normal file
131
src/components/campaign/activity/ActivityDateTimeField.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
'use client'
|
||||
|
||||
import { CalendarIcon } from 'lucide-react'
|
||||
import { ptBR } from 'react-day-picker/locale'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/Calendar'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import {
|
||||
floorToMinuteStep,
|
||||
formatBahiaCivilDate,
|
||||
formatBahiaCivilDateTimeLabel,
|
||||
hourOptions,
|
||||
minuteOptionsForStep,
|
||||
parseBahiaDateTimeInput,
|
||||
} from '@/lib/campaignTime'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* C97 — shadcn day + time picker for the agenda quick create. The value
|
||||
* contract is the civil `YYYY-MM-DDTHH:mm` string the old `datetime-local`
|
||||
* used, so parse/validation on submit is untouched. 24h by construction:
|
||||
* the trigger formats the civil string directly and the selects list
|
||||
* 00–23 / 00–45, so no browser locale or Intl hour cycle can inject AM/PM.
|
||||
* The parent renders the surrounding Field/FieldError; this component is the
|
||||
* control itself (trigger + popover with calendar and step selects).
|
||||
*/
|
||||
export const ActivityDateTimeField = ({
|
||||
id,
|
||||
value,
|
||||
onValueChange,
|
||||
invalid = false,
|
||||
errorId,
|
||||
}: {
|
||||
id: string
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
invalid?: boolean
|
||||
/** FieldError id to announce on focus — parity with the input it replaces. */
|
||||
errorId?: string
|
||||
}) => {
|
||||
const civil = floorToMinuteStep(value)
|
||||
const datePart = civil.slice(0, 10)
|
||||
const timePart = civil.slice(11)
|
||||
const hour = timePart.slice(0, 2)
|
||||
const minute = timePart.slice(3, 5)
|
||||
|
||||
const setDate = (date: Date | undefined) => {
|
||||
if (!date) return
|
||||
onValueChange(`${formatBahiaCivilDate(date)}T${timePart}`)
|
||||
}
|
||||
|
||||
const setHour = (next: string) => onValueChange(`${datePart}T${next}:${minute}`)
|
||||
const setMinute = (next: string) => onValueChange(`${datePart}T${hour}:${next}`)
|
||||
|
||||
const selectedInstant = parseBahiaDateTimeInput(`${datePart}T12:00`)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={errorId}
|
||||
className={cn(
|
||||
'min-h-11 w-full justify-between rounded-full border-(--field-border) bg-(--field-background) px-4 text-base text-(--field-foreground) hover:bg-(--field-background) hover:text-(--field-foreground) data-[state=open]:border-primary focus-visible:border-primary focus-visible:ring-3 focus-visible:ring-primary/30 md:text-sm',
|
||||
invalid &&
|
||||
'border-destructive ring-3 ring-destructive/20 focus-visible:border-destructive focus-visible:ring-destructive/20',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{formatBahiaCivilDateTimeLabel(civil)}</span>
|
||||
<CalendarIcon
|
||||
data-icon="inline-end"
|
||||
aria-hidden="true"
|
||||
className="size-4 text-(--field-icon)"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="max-h-[22rem] w-80 overflow-y-auto p-0 overscroll-contain"
|
||||
>
|
||||
{selectedInstant ? (
|
||||
<Calendar
|
||||
mode="single"
|
||||
locale={ptBR}
|
||||
timeZone="America/Bahia"
|
||||
selected={new Date(selectedInstant)}
|
||||
onSelect={setDate}
|
||||
className="w-full p-2"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-3 border-t p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">Hora</span>
|
||||
<NativeSelect
|
||||
aria-label="Hora"
|
||||
value={hour}
|
||||
onChange={(event) => setHour(event.target.value)}
|
||||
>
|
||||
{hourOptions.map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}h
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">Minuto</span>
|
||||
<NativeSelect
|
||||
aria-label="Minuto"
|
||||
value={minute}
|
||||
onChange={(event) => setMinute(event.target.value)}
|
||||
>
|
||||
{minuteOptionsForStep().map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { toast } from 'sonner'
|
|||
|
||||
import { searchActivityResponsibleOptionsAction } from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
||||
import { createActivityInline } from '@/app/(campaign)/campanha/actions/activity'
|
||||
import { ActivityDateTimeField } from '@/components/campaign/activity/ActivityDateTimeField'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { ResponsibleMultiSelect } from '@/components/campaign/shared/ResponsibleMultiSelect'
|
||||
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
|
||||
|
|
@ -192,14 +193,12 @@ const ActivityInlineCreateForm = ({
|
|||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errorFor('startAt'))}>
|
||||
<FieldLabel htmlFor="inline-startAt">Início *</FieldLabel>
|
||||
<Input
|
||||
<ActivityDateTimeField
|
||||
id="inline-startAt"
|
||||
type="datetime-local"
|
||||
value={start}
|
||||
onChange={(event) => setStart(event.target.value)}
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('startAt'))}
|
||||
aria-describedby={errorFor('startAt') ? 'inline-startAt-error' : undefined}
|
||||
onValueChange={setStart}
|
||||
invalid={Boolean(errorFor('startAt'))}
|
||||
errorId={errorFor('startAt') ? 'inline-startAt-error' : undefined}
|
||||
/>
|
||||
{errorFor('startAt') ? (
|
||||
<FieldError id="inline-startAt-error">{errorFor('startAt')}</FieldError>
|
||||
|
|
@ -207,14 +206,12 @@ const ActivityInlineCreateForm = ({
|
|||
</Field>
|
||||
<Field data-invalid={Boolean(errorFor('endAt'))}>
|
||||
<FieldLabel htmlFor="inline-endAt">Término</FieldLabel>
|
||||
<Input
|
||||
<ActivityDateTimeField
|
||||
id="inline-endAt"
|
||||
type="datetime-local"
|
||||
value={end}
|
||||
onChange={(event) => setEnd(event.target.value)}
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('endAt'))}
|
||||
aria-describedby={errorFor('endAt') ? 'inline-endAt-error' : undefined}
|
||||
onValueChange={setEnd}
|
||||
invalid={Boolean(errorFor('endAt'))}
|
||||
errorId={errorFor('endAt') ? 'inline-endAt-error' : undefined}
|
||||
/>
|
||||
{errorFor('endAt') ? (
|
||||
<FieldError id="inline-endAt-error">{errorFor('endAt')}</FieldError>
|
||||
|
|
|
|||
185
src/components/ui/Calendar.tsx
Normal file
185
src/components/ui/Calendar.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { DayPicker, getDefaultClassNames, type DayButton, type Locale } from 'react-day-picker'
|
||||
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const Calendar = ({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
locale,
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant']
|
||||
}) => {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'cn-calendar group/calendar bg-background in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
locale={locale}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
|
||||
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||
nav: cn(
|
||||
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'cn-calendar-dropdown-root relative rounded-(--cell-radius)',
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
'font-medium select-none',
|
||||
captionLayout === 'label'
|
||||
? 'cn-calendar-caption text-sm'
|
||||
: 'cn-calendar-caption-label flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none',
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
|
||||
week_number: cn(
|
||||
'text-[0.8rem] text-muted-foreground select-none',
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
|
||||
props.showWeekNumber
|
||||
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
|
||||
: '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn(
|
||||
'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
|
||||
defaultClassNames.range_start,
|
||||
),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn(
|
||||
'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
|
||||
defaultClassNames.range_end,
|
||||
),
|
||||
today: cn(
|
||||
'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
'text-muted-foreground aria-selected:text-muted-foreground',
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return <ChevronLeftIcon className={cn('cn-rtl-flip size-4', className)} {...props} />
|
||||
}
|
||||
if (orientation === 'right') {
|
||||
return <ChevronRightIcon className={cn('cn-rtl-flip size-4', className)} {...props} />
|
||||
}
|
||||
return <ChevronDownIcon className={cn('size-4', className)} {...props} />
|
||||
},
|
||||
DayButton: ({ ...props }) => <CalendarDayButton locale={locale} {...props} />,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const CalendarDayButton = ({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
locale,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) => {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'cn-calendar-day-button relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -157,3 +157,37 @@ export const latestIsoTimestamp = (
|
|||
if (!right) return left
|
||||
return right > left ? right : left
|
||||
}
|
||||
|
||||
/**
|
||||
* C97 — the inline create picker offers time in fixed steps. 15 min covers
|
||||
* off-slot commitments (e.g. 09:45) without turning the select into a
|
||||
* spreadsheet; the agenda grid itself is 30 min.
|
||||
*/
|
||||
export const timeStepMinutes = 15
|
||||
|
||||
/** Civil hour labels 00–23 — the picker is 24h by construction (no meridiem). */
|
||||
export const hourOptions: string[] = Array.from({ length: 24 }, (_, hour) => pad(hour))
|
||||
|
||||
/** Civil minute labels on a step grid, e.g. 00/15/30/45 for the 15-min step. */
|
||||
export const minuteOptionsForStep = (step = timeStepMinutes): string[] =>
|
||||
Array.from({ length: 60 / step }, (_, index) => pad(index * step))
|
||||
|
||||
/** Rounds a civil datetime's minutes down to the step (C97 picker invariant). */
|
||||
export const floorToMinuteStep = (civil: string, step = timeStepMinutes): string => {
|
||||
const match = bahiaDateTimeInputPattern.exec(civil)
|
||||
if (!match) return civil
|
||||
const [, year, month, day, hour, minute] = match
|
||||
return `${year}-${month}-${day}T${hour}:${pad(Math.floor(Number(minute) / step) * step)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a civil datetime input value as a pt-BR label in Bahia civil time —
|
||||
* pure string arithmetic, so the 24h display never depends on the browser
|
||||
* locale or the Intl engine's hour cycle (C97).
|
||||
*/
|
||||
export const formatBahiaCivilDateTimeLabel = (civil: string): string => {
|
||||
const match = bahiaDateTimeInputPattern.exec(civil)
|
||||
if (!match) return civil
|
||||
const [, year, month, day, hour, minute] = match
|
||||
return `${day}/${month}/${year} às ${hour}:${minute}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,23 +202,51 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
|
||||
// The click no longer navigates: the inline overlay opens at the slot.
|
||||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||||
const startInput = page.getByLabel('Início *')
|
||||
await expect(startInput).toBeVisible()
|
||||
const [startValue, endValue] = await Promise.all([
|
||||
startInput.inputValue(),
|
||||
page.getByLabel('Término').inputValue(),
|
||||
// C97 — the native datetime-local is gone; the trigger buttons show the
|
||||
// civil 24h label (slot = 14:00–14:30 Bahia, whatever the browser locale).
|
||||
const startTrigger = page.getByLabel('Início *')
|
||||
await expect(startTrigger).toBeVisible()
|
||||
const [startLabel, endLabel] = await Promise.all([
|
||||
startTrigger.textContent(),
|
||||
page.getByLabel('Término').textContent(),
|
||||
])
|
||||
const [startAt, endAt] = [startValue, endValue].map(parseBahiaDateTimeInput)
|
||||
expect(startAt).not.toBeNull()
|
||||
expect(endAt).not.toBeNull()
|
||||
if (!startAt || !endAt) throw new Error('O overlay não recebeu o intervalo do slot.')
|
||||
expect(new Date(endAt).getTime() - new Date(startAt).getTime()).toBe(1_800_000)
|
||||
expect(startLabel).toMatch(/\d{2}\/\d{2}\/\d{4} às 14:00/)
|
||||
expect(endLabel).toMatch(/\d{2}\/\d{2}\/\d{4} às 14:30/)
|
||||
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
|
||||
|
||||
await page.getByLabel('Título *').fill(title)
|
||||
|
||||
// The acceptance center: pick a different time through the shadcn picker
|
||||
// (calendar keeps the day; the step selects change hour/minute) and the
|
||||
// saved event lands at the chosen hour. Both fields move together so the
|
||||
// "Término posterior ao Início" validation stays satisfied. After
|
||||
// selectOption the focus sits in the native select, where Escape never
|
||||
// reaches Radix — re-clicking the selected day moves focus into the
|
||||
// calendar, then Escape closes only the picker (never the overlay).
|
||||
await startTrigger.click()
|
||||
// Scope to the live picker: a just-closed popover lingers in the DOM for
|
||||
// its 100 ms close animation, so a bare combobox query can match twice.
|
||||
const openPicker = () => page.locator('[data-slot="popover-content"][data-state="open"]').last()
|
||||
const hourSelect = openPicker().getByRole('combobox', { name: 'Hora' })
|
||||
await expect(hourSelect).toBeVisible()
|
||||
await hourSelect.selectOption('15')
|
||||
await expect(startTrigger).toHaveText(/\d{2}\/\d{2}\/\d{4} às 15:00/)
|
||||
await openPicker().locator('[data-selected-single="true"]').click()
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByLabel('Término').click()
|
||||
const endHourSelect = openPicker().getByRole('combobox', { name: 'Hora' })
|
||||
await expect(endHourSelect).toBeVisible()
|
||||
await endHourSelect.selectOption('16')
|
||||
// The minute keeps its prefill (30) — only the hour changed.
|
||||
await expect(page.getByLabel('Término')).toHaveText(/\d{2}\/\d{2}\/\d{4} às 16:30/)
|
||||
await openPicker().locator('[data-selected-single="true"]').click()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await page.getByRole('button', { name: 'Salvar' }).click()
|
||||
|
||||
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
// The saved event landed at the chosen times (24h chip, no meridiem).
|
||||
await expect(page.getByText('15:00 – 16:30', { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -144,9 +144,10 @@ describe('ActivityAgenda schedule failures', () => {
|
|||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clicar slot' }))
|
||||
|
||||
const startInput = await screen.findByLabelText('Início *')
|
||||
expect((startInput as HTMLInputElement).value).toBe('2026-08-07T13:00')
|
||||
expect((screen.getByLabelText('Término') as HTMLInputElement).value).toBe('2026-08-07T13:30')
|
||||
const startTrigger = await screen.findByLabelText('Início *')
|
||||
// C97 — the overlay trigger is a button showing the civil label, 24h.
|
||||
expect(startTrigger.textContent).toBe('07/08/2026 às 13:00')
|
||||
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026 às 13:30')
|
||||
expect(mocks.routerPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createInline: vi.fn(),
|
||||
|
|
@ -62,20 +62,58 @@ const renderOverlay = ({
|
|||
/>,
|
||||
)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ActivityInlineCreate — overlay de criação inline', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('preenche horários do slot e usa o município do filtro ativo', () => {
|
||||
it('preenche horários do slot (24h) e usa o município do filtro ativo', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 } })
|
||||
|
||||
expect((screen.getByLabelText('Início *') as HTMLInputElement).value).toBe('2026-08-07T13:00')
|
||||
expect((screen.getByLabelText('Término') as HTMLInputElement).value).toBe('2026-08-07T13:30')
|
||||
// C97 — the native datetime-local is gone: the trigger button shows the
|
||||
// civil label, 24h by construction (no browser locale can inject AM/PM).
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026 às 13:00')
|
||||
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026 às 13:30')
|
||||
expect((screen.getByLabelText('Município *') as HTMLInputElement).value).toBe('Camaçari')
|
||||
})
|
||||
|
||||
it('troca dia no calendário e hora/minuto nos passos, sem perder o outro eixo', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 } })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Início *'))
|
||||
const dialogs = screen.getAllByRole('dialog')
|
||||
const picker = within(dialogs[dialogs.length - 1])
|
||||
|
||||
fireEvent.click(picker.getByRole('button', { name: /15 de agosto de 2026/ }))
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('15/08/2026 às 13:00')
|
||||
|
||||
fireEvent.change(picker.getByRole('combobox', { name: 'Hora' }), {
|
||||
target: { value: '09' },
|
||||
})
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('15/08/2026 às 09:00')
|
||||
|
||||
fireEvent.change(picker.getByRole('combobox', { name: 'Minuto' }), {
|
||||
target: { value: '45' },
|
||||
})
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('15/08/2026 às 09:45')
|
||||
})
|
||||
|
||||
it('cria sem navegar e avisa o pai para recarregar a janela', async () => {
|
||||
const onCreated = vi.fn()
|
||||
mocks.createInline.mockResolvedValue({ ok: true })
|
||||
|
|
@ -87,6 +125,12 @@ describe('ActivityInlineCreate — overlay de criação inline', () => {
|
|||
fireEvent.change(screen.getByLabelText('Local (opcional)'), {
|
||||
target: { value: 'Centro histórico' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('Término'))
|
||||
const dialogs = screen.getAllByRole('dialog')
|
||||
const endPicker = within(dialogs[dialogs.length - 1])
|
||||
fireEvent.change(endPicker.getByRole('combobox', { name: 'Minuto' }), {
|
||||
target: { value: '45' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
|
||||
|
|
@ -94,7 +138,7 @@ describe('ActivityInlineCreate — overlay de criação inline', () => {
|
|||
title: 'Café com apoiadores',
|
||||
municipality: 12,
|
||||
startAt: '2026-08-07T16:00:00.000Z',
|
||||
endAt: '2026-08-07T16:30:00.000Z',
|
||||
endAt: '2026-08-07T16:45:00.000Z',
|
||||
locality: 'Centro histórico',
|
||||
})
|
||||
expect(onCreated).toHaveBeenCalledTimes(1)
|
||||
|
|
@ -156,7 +200,7 @@ describe('ActivityInlineCreate — overlay de criação inline', () => {
|
|||
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
|
||||
|
||||
expect(screen.getByLabelText('Título *')).toBeTruthy()
|
||||
expect((screen.getByLabelText('Início *') as HTMLInputElement).value).toBe('2026-08-07T13:00')
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026 às 13:00')
|
||||
expect(screen.getByRole('button', { name: 'Salvar' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,8 +3,13 @@ import { describe, expect, it } from 'vitest'
|
|||
import {
|
||||
civilDateDaysBetween,
|
||||
civilDateToUtcMidnightIso,
|
||||
floorToMinuteStep,
|
||||
formatBahiaCivilDate,
|
||||
formatBahiaCivilDateTimeLabel,
|
||||
hourOptions,
|
||||
minuteOptionsForStep,
|
||||
subtractBahiaCivilDays,
|
||||
timeStepMinutes,
|
||||
} from '@/lib/campaignTime'
|
||||
|
||||
describe('Bahia civil date helpers (B57)', () => {
|
||||
|
|
@ -26,3 +31,46 @@ describe('Bahia civil date helpers (B57)', () => {
|
|||
expect(subtractBahiaCivilDays(today, 7)).toBe('2026-08-01')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bahia civil datetime picker helpers (C97)', () => {
|
||||
it('labels civil datetimes as dd/mm/aaaa às hh:mm — 24h, no meridiem', () => {
|
||||
expect(formatBahiaCivilDateTimeLabel('2026-08-07T14:30')).toBe('07/08/2026 às 14:30')
|
||||
expect(formatBahiaCivilDateTimeLabel('2026-08-07T09:45')).toBe('07/08/2026 às 09:45')
|
||||
expect(formatBahiaCivilDateTimeLabel('2026-08-07T23:05')).toBe('07/08/2026 às 23:05')
|
||||
expect(formatBahiaCivilDateTimeLabel('2026-08-07T00:00')).toBe('07/08/2026 às 00:00')
|
||||
})
|
||||
|
||||
it('exposes 24 hours and the 15-min step grid', () => {
|
||||
expect(hourOptions).toHaveLength(24)
|
||||
expect(hourOptions[0]).toBe('00')
|
||||
expect(hourOptions[23]).toBe('23')
|
||||
expect(timeStepMinutes).toBe(15)
|
||||
expect(minuteOptionsForStep()).toEqual(['00', '15', '30', '45'])
|
||||
expect(minuteOptionsForStep(5)).toEqual([
|
||||
'00',
|
||||
'05',
|
||||
'10',
|
||||
'15',
|
||||
'20',
|
||||
'25',
|
||||
'30',
|
||||
'35',
|
||||
'40',
|
||||
'45',
|
||||
'50',
|
||||
'55',
|
||||
])
|
||||
})
|
||||
|
||||
it('floors minutes to the step, leaving the date and hour untouched', () => {
|
||||
expect(floorToMinuteStep('2026-08-07T14:47')).toBe('2026-08-07T14:45')
|
||||
expect(floorToMinuteStep('2026-08-07T14:00')).toBe('2026-08-07T14:00')
|
||||
expect(floorToMinuteStep('2026-08-07T14:59')).toBe('2026-08-07T14:45')
|
||||
expect(floorToMinuteStep('2026-08-07T14:07', 5)).toBe('2026-08-07T14:05')
|
||||
})
|
||||
|
||||
it('passes malformed input through untouched', () => {
|
||||
expect(formatBahiaCivilDateTimeLabel('lixo')).toBe('lixo')
|
||||
expect(floorToMinuteStep('lixo')).toBe('lixo')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue