From fdedfa9ad423ce1e7ea549d1f1de7884e9b316f2 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 14:47:28 -0300 Subject: [PATCH] =?UTF-8?q?feat(C97):=20seletor=20shadcn=20de=20dia+hor?= =?UTF-8?q?=C3=A1rio=2024h=20no=20popover=20de=20cria=C3=A7=C3=A3o=20r?= =?UTF-8?q?=C3=A1pida=20da=20agenda?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Troca o datetime-local nativo (AM/PM no idioma do browser, horário pouco confiável) por 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 Hora 00-23 / Minuto em passos de 15. Contrato de valor e submit inalterados; prefill do slot mantido. Novo ui/Calendar.tsx (variante radix-nova), helpers puros em campaignTime.ts. Sem migration. Closes #482 (PR). --- docs/CHANGELOG-AGENTS.md | 2 + ...7-agenda-popover-seletor-data-hora-impl.md | 117 +++++++++++ package.json | 1 + pnpm-lock.yaml | 39 +++- src/app/(frontend)/styles.css | 15 +- .../activity/ActivityDateTimeField.tsx | 131 +++++++++++++ .../activity/ActivityInlineCreate.tsx | 21 +- src/components/ui/Calendar.tsx | 185 ++++++++++++++++++ src/lib/campaignTime.ts | 34 ++++ tests/e2e/campaignActivity.e2e.spec.ts | 48 ++++- .../activityAgendaInteractions.unit.spec.tsx | 7 +- tests/unit/activityInlineCreate.unit.spec.tsx | 58 +++++- tests/unit/campaignTimeCivilDate.unit.spec.ts | 48 +++++ 13 files changed, 670 insertions(+), 36 deletions(-) create mode 100644 docs/plans/c97-agenda-popover-seletor-data-hora-impl.md create mode 100644 src/components/campaign/activity/ActivityDateTimeField.tsx create mode 100644 src/components/ui/Calendar.tsx diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index d1c4f106..fe861b7a 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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. diff --git a/docs/plans/c97-agenda-popover-seletor-data-hora-impl.md b/docs/plans/c97-agenda-popover-seletor-data-hora-impl.md new file mode 100644 index 00000000..28b60aa4 --- /dev/null +++ b/docs/plans/c97-agenda-popover-seletor-data-hora-impl.md @@ -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
trigger + Popover] + B --> C[ui/calendar.tsx
react-day-picker 9
timeZone America/Bahia] + B --> D[NativeSelect hora + minuto
passo 15 min] + B --> E[civil string YYYY-MM-DDTHH:mm
= contrato atual] + E --> F[submit/validação atuais
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('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) diff --git a/package.json b/package.json index 86e060ad..162f2b12 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88b22131..74e4078a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 88cb0df5..16ea69f5 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -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 { diff --git a/src/components/campaign/activity/ActivityDateTimeField.tsx b/src/components/campaign/activity/ActivityDateTimeField.tsx new file mode 100644 index 00000000..cec95963 --- /dev/null +++ b/src/components/campaign/activity/ActivityDateTimeField.tsx @@ -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 ( + + + + + + {selectedInstant ? ( + + ) : null} +
+
+
+ Hora + setHour(event.target.value)} + > + {hourOptions.map((option) => ( + + {option}h + + ))} + +
+
+ Minuto + setMinute(event.target.value)} + > + {minuteOptionsForStep().map((option) => ( + + {option} + + ))} + +
+
+
+
+
+ ) +} diff --git a/src/components/campaign/activity/ActivityInlineCreate.tsx b/src/components/campaign/activity/ActivityInlineCreate.tsx index 83c8e015..48e701a9 100644 --- a/src/components/campaign/activity/ActivityInlineCreate.tsx +++ b/src/components/campaign/activity/ActivityInlineCreate.tsx @@ -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 = ({
Início * - 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') ? ( {errorFor('startAt')} @@ -207,14 +206,12 @@ const ActivityInlineCreateForm = ({ Término - 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') ? ( {errorFor('endAt')} diff --git a/src/components/ui/Calendar.tsx b/src/components/ui/Calendar.tsx new file mode 100644 index 00000000..404e307e --- /dev/null +++ b/src/components/ui/Calendar.tsx @@ -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 & { + buttonVariant?: React.ComponentProps['variant'] +}) => { + const defaultClassNames = getDefaultClassNames() + + return ( + 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
+ }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === 'left') { + return + } + if (orientation === 'right') { + return + } + return + }, + DayButton: ({ ...props }) => , + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +const CalendarDayButton = ({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) => { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( +