feat(C90): remodela responsáveis da Atividade em campo único polimórfico
Um campo responsible (staff campaignUser · leadership · stateDeputy) multi-valor substitui responsible/Contact + advisors + leadership; assessor mantém acesso via notação-objeto equals no access; nova migração esquema+dados (advisors/leadership → responsible); busca q da lista título-only; notificação notifica staff do campo. Closes #426
This commit is contained in:
parent
8b2bc51aa5
commit
63642483a4
27 changed files with 14244 additions and 305 deletions
|
|
@ -8,6 +8,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-08):** **C90** (Issue #426) — os três campos de responsável da Atividade (`responsible` Contact, `advisors` staff, `leadership`) viraram **um campo `responsible` polimórfico multi-valor** (`campaignUser` staff · `leadership` · `stateDeputy`), removendo os dois antigos do modelo/UI; assessor continua vendo/curando o compromisso onde está listado como responsável via notação-objeto `equals {relationTo,value}` no access (provado por int test com `payload.find` real); seletor `ResponsibleMultiSelect` (chips tipados) + busca única `searchActivityResponsibleOptions` (escopo de leitura do ator, liderança engajada) — reusado pelo C91 na agenda; migração esquema+dados (advisors/leadership → `responsible`, Contact antigo descartado, contagem via NOTICE; SQL de reconciliação ensaiado contra cenário misto); busca `q` da lista virou título-only (regressão assumida no gate); notificação de atenção notifica só staff em `responsible`. Intenção [`remodelar-responsaveis-atividade.md`](docs/plans/remodelar-responsaveis-atividade.md); impl [`remodelar-responsaveis-atividade-impl.md`](docs/plans/remodelar-responsaveis-atividade-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-08):** **worktree next provisiona ambiente isolado por worktree** — agentes em paralelo não disputam mais porta 3000, o `teqo_test` compartilhado nem um segundo Postgres no 5432: `pnpm worktree next [--go] [--no-migrate]` deriva deterministicamente slot do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos) → dev server `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test` criados no container compartilhado com migrations aplicadas; escreve `.env.local` + `.env.test.local` (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD*SECRET/Blob/VAPID do main. `kill` dropa os bancos gerados (best-effort, WITH FORCE). Suites leem `.env.test.local` depois de `.env.test` (vitest.setup/playwright/db-doctor/gate-ci espelham o layering); `assertTestDatabase` aceita `teqo_test` ou `teqo*<worktree>\_test`em localhost só (fail-closed preservado).`db:start`/`db:stop`fixam`-p teqo`(um container, qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação pura`scripts/lib/worktree-env.mjs` (unit-tested).
|
||||
|
||||
**Recently resolved (2026-08-08):** **B164** (Issue #400) — barra de navegação inferior fixa no mobile para staff, com 5 itens (Início, Municípios, Atualizações, Agenda, Mais) e drawer overflow "Mais" para destinos secundários (Quadro, Territórios, Organizações, Líderanças, etc.) + rodapé Perfil/Sair. Leader e desktop mantêm sidebar-only (lockdown intacto). `CampaignContentScroll` e `CampaignQuickActionsHost` ajustados para safe-area e z-index do FAB sobre a barra. `/campanha/atualizacoes` é placeholder honesto (âncora C89). Impl [`barra-navegacao-inferior-mobile-impl.md`](docs/plans/barra-navegacao-inferior-mobile-impl.md).
|
||||
|
|
|
|||
135
docs/plans/remodelar-responsaveis-atividade-impl.md
Normal file
135
docs/plans/remodelar-responsaveis-atividade-impl.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Impl: C90 — Remodelar responsáveis da Atividade — campo único polimórfico multi-valor
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-08-08
|
||||
Issue: #426
|
||||
Intenção: docs/plans/remodelar-responsaveis-atividade.md
|
||||
Appetite restante: herdado (~1–1,5 dia eng). Sem cortes no appetite — a mudança é serializada (schema→server→UI→gates) e não cria entidade nova.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** uma Atividade tem UM campo `responsible` polimórfico multi-valor (staff `campaignUser` nas funções assessor/candidato/coordenador, `leadership`, `stateDeputy`); `advisors` e `leadership` saem do modelo e da UI; a informação migra para `responsible`; o assessor mantém ver/editar quando está listado como responsável (mesmo direito que `advisors` dava).
|
||||
- **O que NÃO negociar:** campo único (não 3 campos); sem segundo cadastro de pessoa (seletor só escolhe existentes); sem papel novo; assessor continua vendo/curando o compromisso em que está listado; `responsible` opcional (obrigatório só título/município); `tasks[].responsible` (Contact) NÃO muda.
|
||||
- **O que reavaliar (engenharia):**
|
||||
1. A leg do assessor em `canReadActivity` hoje é `advisors: { contains: user.id }`. Campo polimórfico hasMany **não aceita** `contains` escalar (o query-builder do adapter não suporta where por id escalar em relação polimórfica hasMany — só a notação-objeto `{ relationTo, value }` com `equals`). → decisão de acesso abaixo.
|
||||
2. A busca `q` da lista usa `'responsible.name'`, impossível de manter em polimórfico → vira título-only (perda pequena, sinalizada no gate).
|
||||
3. `notifyActivityNeedsAttention` usava `doc.advisors` → passa a filtrar entradas `campaignUser` de `responsible`.
|
||||
4. O `responsible` antigo é um **Contact** — Contact não é tipo válido no novo union. O valor antigo não tem destino; decisão de dados no gate.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Activity.ts: responsible polimórfico hasMany<br/>relationTo campaignUser|leadership|stateDeputy<br/>remove advisors + leadership] --> B[validateActivityResponsibles hook<br/>+ auto-include assessor no create]
|
||||
B --> C[access/activities.ts: leg assessor =<br/>responsible equals {relationTo campaignUser, value me}]
|
||||
C --> D[lib/schemas + activityFormData: responsiblesJson<br/>bounded, shape {relationTo, value}]
|
||||
D --> E[action searchActivityResponsibleOptions 3 catálogos<br/>+ ResponsibleMultiSelect novo componente]
|
||||
E --> F[ActivityForm/OverviewTab/ActivityCard/viewModels<br/>chips tipados]
|
||||
A --> G[migration: activity_rels +leadership_id+statedeputy_id<br/>drop activity.responsible_id/leadership_id<br/>migra advisors→responsible, leadership→responsible]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) campo polimórfico puro (payload `relationTo: [...]`, `hasMany`) como a intenção manda | B) campo polimórfico + coluna derivada escondida `assignedStaffIds` para o acesso do assessor | C) manter sessões separadas e só juntar na UI.
|
||||
**Recomendação: A** — é o modelo de domínio da intenção, sem twin de dados; o acesso por notação-objeto `equals {relationTo,value}` resolve o escopo do assessor dentro do próprio campo (provado por teste int que roda a query real).
|
||||
**Rejeitadas:** B porque duplica o dado do `responsible` num campo derivado (viola "edit the owner, don't twin" e drift não é barato); C porque não atende o aceite de produto (campo único).
|
||||
|
||||
### Decisões de engenharia (forma obrigatória)
|
||||
|
||||
**D1 — Leg do assessor no read/update:**
|
||||
|
||||
- Opções: A) `{ responsible: { equals: { relationTo: 'campaignUser', value: id } } }` (notação-objeto; o único operador aceito pelo adapter em polimórfico é `equals`) | B) `{ responsible: { contains: id } }` escalar | C) consulta dupla (município scope + lookup pós-fetch).
|
||||
- Recomendação: A — expressivo, suportado (verificado no fonte do `@payloadcms/drizzle`, `sanitizeQueryValue`/`getTableColumnFromPath`), PROVADO por teste int que roda `payload.find` como o assessor.
|
||||
- Rejeitadas: B — o query-builder não tem caminho para id escalar em relação polimórfica hasMany (cairia em recursão sem tabela resolvida); C — fere a semântica de `Access` que precisa devolver `Where`.
|
||||
|
||||
**D2 — Edição do `responsible` (field access):**
|
||||
|
||||
- Opções: A) staff pode gravar o campo (mesmo esboço do `advisors` que era coordinator-only some) | B) manter gate coordinator-only | C) só hook de validate.
|
||||
- Recomendação: A — o produto diz "staff cria; assessor edita o que administra + o que lhe é imputado" sem gate de coordenador; o row access (`canUpdateActivity`) já restringe o assessor ao escopo. `canCreateActivityAdvisors`/`canManageActivityAdvisors` saem; entra `canSetActivityResponsible` = staff.
|
||||
- Rejeitadas: B pois `advisors` coordinator-only era uma UI conveniente do Admin, não um requisito; o assessor precisa conseguir atribuir/editar responsáveis no que administra. C sozinho não protege o Admin.
|
||||
|
||||
**D3 — Busca `q` da lista de Atividades:**
|
||||
|
||||
- Opções: A) título-only | B) manter `responsible.name` (quebra em polimórfico) | C) resolver nomes pós-fetch.
|
||||
- Recomendação: A (título-only) — a query aninhada `responsible.name` em polimórfico não é suportada; pós-fetch é caro e proporcional a nada. **Sinalizado no gate** (mudança visível: buscar por nome de responsável na lista deixa de casar).
|
||||
- Rejeitadas: B (runtime error), C (overhead injustificável).
|
||||
|
||||
**D4 — Dados antigos na migração:**
|
||||
|
||||
- Opções: A) `advisors`→`responsible` (campaignUser), `leadership`→`responsible` (leadership), Contact antigo em `responsible_id` **descartado** (sem tipo válido no union, sem destino confiável) | B) tentar mapear Contact antigo para campaignUser/leadership por heurística | C) manter Contact como 4º tipo.
|
||||
- Recomendação: A — fiel à intenção (o union é staff/leadership/dobradinha); Contact é exatamente o campo que o produto quer eliminar (o "Contact avulso"). **Decisão de dados confirmada no gate** antes de executar.
|
||||
- Rejeitadas: B (heurística especulativa, risco de atribuir responsável errado); C (viola o aceite — Contact não é papel da campanha).
|
||||
|
||||
**D5 — Componente do seletor:**
|
||||
|
||||
- Opções: A) novo `ResponsibleMultiSelect` (chips + Command dialog + busca polimórfica agrupada por tipo) | B) estender `AsyncSearchCombobox` para multi+polimórfico | C) três seletores.
|
||||
- Recomendação: A — o picker polimórfico multi-tipo (resultado tipado, chip tipado) é uma forma nova que nenhum primitivo expressa; `AsyncSearchCombobox` é single-value e usado em vários lugares (município/liderança); mudá-lo arrisca os outros call sites. Colocado em `campaign/shared/` para o C91 o reusar no overlay da agenda.
|
||||
- Rejeitadas: B (twin-arriscado num primitivo compartilhado), C (anti-aceite).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`Activity.ts`** (`src/collections/Activity.ts`): `responsible` → relationship polimórfico hasMany `relationTo: ['campaignUser', 'leadership', 'stateDeputy']`, `hasMany: true`, `index: true`, `filterOptions` por collection (`campaignUser`: `eligibleCampaignStaffWhere`), `admin.label` "Responsáveis". Remove `advisors` e `leadership`. Hook `validateActivityAdvisors` → `validateActivityResponsibles` (valida entradas: staff elegível p/ `campaignUser`, existência p/ `leadership`/`stateDeputy`, cap). `deriveActivityFields`: auto-include `[{relationTo:'campaignUser', value: user.id}]` no create do assessor; `activityStaffFieldSnapshot` re-expresso sobre `responsible`.
|
||||
- **`src/utilities/access/activities.ts`**: `canReadActivity`/`canUpdateActivity` leg assessor = `{ or: [{ responsible: { equals: { relationTo: 'campaignUser', value: id } } }, advisorMunicipalityScopeWhere('municipality', ids)] }`. Remove `canCreateActivityAdvisors`/`canManageActivityAdvisors`; adiciona `canSetActivityResponsible` (staff). Re-export em `campaignAccess.ts`.
|
||||
- **`src/lib/schemas/activity.ts`**: `responsible: z.array(activityResponsibleSchema).max(20).optional()` (shape `{ relationTo: enum, value: positiveRelationshipId }`); remove `advisors`/`leadership` (create/update). `tasks[].responsible` intacto.
|
||||
- **`src/utilities/activityFormData.ts`**: parseia `responsiblesJson` (bounded JSON, precedente `tagsJson`), replace de `advisors`/`leadership`/`responsible`.
|
||||
- **`src/app/(campaign)/campanha/actions/activity.ts`**: create/update recebem `responsible` polimórfico; update sempre envia o array (limpar = `[]`); ajusta comentário de stripping de null (endAt/responsible mantêm null-anulável? → `responsible` agora é array, muda o strip).
|
||||
- **`src/utilities/activityViewModels.ts`**: `ActivityFormViewModel` → `responsibles: Array<{ relationTo, id, name, typeLabel }>`; `ActivityDetailViewModel` → `responsibles` (chips tipados) no lugar de `advisors`+`responsibleName`; `ActivityListViewModel` → `responsibles` + resumo p/ card. Selects ajustados (depth 1 para popular).
|
||||
- **Search actions** (`src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts` + `src/utilities/activityLeadershipOptions.ts`): novo `searchActivityResponsibleOptions(query)` (server action) que busca os 3 catálogos em paralelo com `overrideAccess: false` (escopo de leitura do ator), agrupando por tipo com rótulo; mantém filtro `supportStatus: 'engajado'` p/ leadership (comportamento atual — sinalizado). `searchActivityContactOptions` permanece (tarefas). Remove `searchActivityLeadershipOptionsAction` se ficar órfão (knip).
|
||||
- **`src/components/campaign/shared/ResponsibleMultiSelect.tsx`** (novo, client): chips removíveis (padrão `RelationMultiSelect`) + trigger + `Command` dialog com grupos por tipo; submete `responsiblesJson`.
|
||||
- **`src/components/campaign/activity/ActivityForm.tsx`**: card "Pessoas e organizações" → campos "Responsáveis" (novo seletor) + "Organizações apoiadoras"; some grupo de assessores e combobox de liderança; `searchContacts` permanece p/ tarefas; props `advisorOptions`/`canManageAdvisors`/`searchLeaderships` saem; entra `searchResponsibles`.
|
||||
- **`nova`/`editar` pages**: drop `advisorOptions`/`canManageAdvisors`; passam `searchResponsibles`.
|
||||
- **`ActivityOverviewTab.tsx`**: "Equipe e organizações" → "Responsáveis" (lista de chips tipados); remove linha "Responsável" do card Detalhes.
|
||||
- **`ActivityCard.tsx`**: "Resp: X" → chips de responsáveis (até 2 + "+N") com tipo.
|
||||
- **`src/utilities/notification/notificationEvents.ts`**: `notifyActivityNeedsAttention` recipients = entradas `campaignUser` de `responsible` (filtra `relationTo`).
|
||||
- **`src/utilities/activityUi.ts`**: `buildActivityListWhere` — `q` vira título-only (remove `'responsible.name'`).
|
||||
- **Migration:** `pnpm migrate:create remodel_activity_responsible` → schema diff (activity_rels +leadership_id/state_deputy_id; activity −responsible_id/−leadership_id com índices/FKs) + data step: `UPDATE activity_rels SET path='responsible' WHERE path='advisors'`; `INSERT … leadership_id` dos antigos `activity.leadership_id`; reorder por parent; dropar `responsible_id` antiga. Decisão D4 no gate.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
Não aplica (intenção `Dados: N/A`). O "chips tipados por responsável" é a resposta à questão em aberto da intenção (recomendação B) — sem métrica/série/mapa.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer/schema+server:** Migration + `Activity.ts` + access + schemas/parse + actions + notificação. Rodar `pnpm migrate:create`, `pnpm migrate`, `generate:types`. Atualizar int spec (inclui teste real do Where do assessor via `payload.find` e teste de auto-include + teste de responsable protegendo contra staff não-elegível).
|
||||
2. **UI:** `ResponsibleMultiSelect` + form + overview + card + list search. Impeccable B (shape→craft→critique); shells/Command existentes; tokens `data-theme='campaign'`.
|
||||
3. **Gates:** `pnpm gate:fast` na iteração; entrega com `pnpm push`. `knip` (remover órfãos de access/leader options); `formatação`; `check:cycles`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **"Twin" de campo derivado para acesso** — rejeitado (D1 usa notação-objeto, sem coluna extra).
|
||||
- Consulta de `responsible` por nome em listas/filtros — título-only; não construir busca nome polimórfica (defer com gatilho: "voltar busca por responsável quando o produto pedir").
|
||||
- Estender `AsyncSearchCombobox`/`RelationMultiSelect` para multi-polimórfico — novo componente, não mutação de primitivo compartilhado.
|
||||
- Admin UI do `responsible`: `filterOptions` por collection só para conveniência do `/admin`; sem UI custom.
|
||||
- Sem mudança em `tasks[].responsible`, `resultRecordedBy`, `createdBy`, `updates[].author`, do giro, e2e existente de agenda (não toca responsáveis).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Notação-objeto `equals` em hasMany polimórfico não casar como esperado** → mitigação: teste int cria atividade com assessor em `responsible` e roda `payload.find`/`canReadActivity` como esse assessor; se falhar, implantar D1-B/CD como fallback no mesmo esforço.
|
||||
- **Esquema de dados polimórfico em produção** (joins `_rels`, ordens) → migração idempotente por `path`/`parent`, NOTICE de rows afetadas, revisão do SQL antes do deploy (padrão remodel já aplicado em prod).
|
||||
- **Perda do `responsible` (Contact) antigo** → decisão explícita no gate (D4) antes de executar.
|
||||
- **Campo `responsible` com `same name` após mudança de tipo** no diff (era column, vira rels) → conferir o SQL gerado e ajustar manualmente se o diff dropar/criar errado.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto (campo único polimórfico multi-valor; `advisors`/`leadership` fora; assessor mantém direito via `responsible`)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (transações, access `overrideAccess:false` onde actor, sem novo cadastro, sem Consent novo, identificadores inglês/copy pt-BR)
|
||||
- [ ] Testes de domínio: int spec (acesso assessor por `responsible` provado via query real; auto-include; validação de staff; data migration verificada em `teqo_test`)
|
||||
|
||||
## Já resolvido no simplify (não reabrir)
|
||||
|
||||
- Parser polimórfico `{relationTo, value}` centralizado em `parseActivityResponsibleEntries` (`lib/schemas/activity.ts`) — Activity.ts, notificationEvents e viewModels consomem o mesmo.
|
||||
- Seleção do `ResponsibleMultiSelect` inicial-only (bug de wipe no create após erro de submit).
|
||||
- Existência de `leadership`/`stateDeputy` validada como verdade admin (com `// bypass:` justificado) — assessor responsável re-salva lista intacta mesmo com liderança fora do portfólio (int test cobre).
|
||||
- a11y do seletor (`aria-invalid`/`aria-describedby` + `FieldError` com id); gate morto `atCapacity && selected.length > 0` colapsado.
|
||||
|
||||
## Adiado com gatilho (não registrar epic agora)
|
||||
|
||||
- **S1 — extrair `useDebouncedAsyncSearch(query, isReady, search)`** do efeito clonado entre `AsyncSearchCombobox` e `ResponsibleMultiSelect` (debounce 250ms + requestId + loading/failed). **Gatilho:** terceiro consumidor de busca assíncrona client, ou refactor do próprio `AsyncSearchCombobox`.
|
||||
- **S2 — unificar nome-fallback de responsável não-populado** (`Responsável #id` / `Equipe #N` / `Liderança #N`). **Gatilho:** se algum fluxo depender do rótulo exato.
|
||||
- **S3 — UX em capacidade máxima** (trigger disabled; remoção só via chips). **Gatilho:** feedback da mesa.
|
||||
|
||||
## Explicitamente fora (skips dos revisores + descartes deste triage)
|
||||
|
||||
- Estender `AsyncSearchCombobox`/`RelationMultiSelect` para multi-polimórfico (novo componente é a forma certa).
|
||||
- Busca por nome de responsável na lista (`q`) — título-only aceito no gate (consulta aninhada em relação polimórfica não é suportada).
|
||||
- `tasks[].responsible` (Contact), resultado, giro, agenda — intactos.
|
||||
|
||||
Self-score decision-quality: 5/5 (decisões caras com rejeitadas; cabe no appetite; rabbit holes nomeados; reusa loaders/Command/shells; intenção preservada).
|
||||
|
|
@ -71,10 +71,6 @@ export const ActivityOverviewTab = ({
|
|||
<dt className="text-muted-foreground">Término</dt>
|
||||
<dd>{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Responsável</dt>
|
||||
<dd>{view.responsibleName ?? 'Não definido'}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-muted-foreground">Tags</dt>
|
||||
<dd className="flex flex-wrap gap-1 pt-1">
|
||||
|
|
@ -97,15 +93,20 @@ export const ActivityOverviewTab = ({
|
|||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Assessores responsáveis</p>
|
||||
{view.advisors.length ? (
|
||||
<ul role="list" className="flex flex-col gap-1">
|
||||
{view.advisors.map((advisor) => (
|
||||
<li key={advisor.id}>{advisor.name}</li>
|
||||
<p className="text-muted-foreground">Responsáveis</p>
|
||||
{view.responsibles.length ? (
|
||||
<ul role="list" className="flex flex-wrap gap-1.5 pt-1">
|
||||
{view.responsibles.map((responsible) => (
|
||||
<li key={`${responsible.relationTo}:${responsible.id}`}>
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<span>{responsible.name}</span>
|
||||
<span className="text-muted-foreground">{responsible.typeLabel}</span>
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground">Nenhum assessor vinculado.</p>
|
||||
<p className="text-muted-foreground">Nenhum responsável definido.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { getPayload } from 'payload'
|
|||
|
||||
import {
|
||||
searchActivityContactOptions,
|
||||
searchActivityLeadershipOptionsAction,
|
||||
searchActivityResponsibleOptionsAction,
|
||||
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
||||
import { updateActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
|
||||
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
|
||||
|
|
@ -16,7 +16,6 @@ import {
|
|||
loadMunicipalityOptions,
|
||||
loadOrganizationOptions,
|
||||
} from '@/utilities/campaignRelationOptions'
|
||||
import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipalityViewModels'
|
||||
|
||||
type EditActivityPageProps = {
|
||||
params: Promise<{ slug: string }>
|
||||
|
|
@ -47,15 +46,13 @@ export default async function EditActivityPage({ params }: EditActivityPageProps
|
|||
])
|
||||
if (!slug) notFound()
|
||||
|
||||
const canManageAdvisors = user.role === 'coordinator'
|
||||
const [view, municipalityOptions, organizationOptions, advisorOptions] = await Promise.all([
|
||||
const [view, municipalityOptions, organizationOptions] = await Promise.all([
|
||||
getActivityEditPageData(payload, user, slug).catch((error) => {
|
||||
if (error instanceof ActivityNotFoundError) notFound()
|
||||
throw error
|
||||
}),
|
||||
loadMunicipalityOptions(payload, user),
|
||||
loadOrganizationOptions(payload, user),
|
||||
canManageAdvisors ? getEligibleAdvisorOptions(payload, user) : Promise.resolve([]),
|
||||
])
|
||||
|
||||
return (
|
||||
|
|
@ -66,11 +63,9 @@ export default async function EditActivityPage({ params }: EditActivityPageProps
|
|||
activity={view}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
advisorOptions={advisorOptions}
|
||||
canManageAdvisors={canManageAdvisors}
|
||||
submitLabel="Salvar alterações"
|
||||
searchContacts={searchActivityContactOptions}
|
||||
searchLeaderships={searchActivityLeadershipOptionsAction}
|
||||
searchResponsibles={searchActivityResponsibleOptionsAction}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { getPayload } from 'payload'
|
|||
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
|
||||
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
|
||||
import {
|
||||
searchActivityLeadershipOptions,
|
||||
type ActivityLeadershipOption,
|
||||
} from '@/utilities/activityLeadershipOptions'
|
||||
searchActivityResponsibleOptions,
|
||||
type ActivityResponsibleSearchOption,
|
||||
} from '@/utilities/activityResponsibleSearch'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { CAMPAIGN_AUTH_REQUIRED_MESSAGE } from '@/utilities/campaignFormActionError'
|
||||
|
||||
|
|
@ -42,11 +42,11 @@ export const searchActivityContactOptions = async (
|
|||
return result.docs.map(({ id, name, phone }) => ({ id, name, phone: phone ?? null }))
|
||||
}
|
||||
|
||||
export const searchActivityLeadershipOptionsAction = async (
|
||||
export const searchActivityResponsibleOptionsAction = async (
|
||||
query: string,
|
||||
): Promise<ActivityLeadershipOption[]> => {
|
||||
): Promise<ActivityResponsibleSearchOption[]> => {
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
|
||||
if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
|
||||
|
||||
return searchActivityLeadershipOptions(payload, user, query)
|
||||
return searchActivityResponsibleOptions(payload, user, query)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getPayload } from 'payload'
|
|||
|
||||
import {
|
||||
searchActivityContactOptions,
|
||||
searchActivityLeadershipOptionsAction,
|
||||
searchActivityResponsibleOptionsAction,
|
||||
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
||||
import { createActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
|
||||
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
|
||||
|
|
@ -15,7 +15,6 @@ import {
|
|||
loadMunicipalityOptions,
|
||||
loadOrganizationOptions,
|
||||
} from '@/utilities/campaignRelationOptions'
|
||||
import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipalityViewModels'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('atividadesNova')
|
||||
|
||||
|
|
@ -30,11 +29,9 @@ export default async function NewActivityPage({ searchParams }: NewActivityPageP
|
|||
getPayload({ config }),
|
||||
])
|
||||
|
||||
const canManageAdvisors = user.role === 'coordinator'
|
||||
const [municipalityOptions, organizationOptions, advisorOptions, knownTags] = await Promise.all([
|
||||
const [municipalityOptions, organizationOptions, knownTags] = await Promise.all([
|
||||
loadMunicipalityOptions(payload, user),
|
||||
loadOrganizationOptions(payload, user),
|
||||
canManageAdvisors ? getEligibleAdvisorOptions(payload, user) : Promise.resolve([]),
|
||||
loadAccessibleActivityTags(payload, user),
|
||||
])
|
||||
const accessibleMunicipalityIDs = new Set(municipalityOptions.map((option) => option.id))
|
||||
|
|
@ -52,14 +49,12 @@ export default async function NewActivityPage({ searchParams }: NewActivityPageP
|
|||
action={createActivityFormAction}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
advisorOptions={advisorOptions}
|
||||
canManageAdvisors={canManageAdvisors}
|
||||
initialValues={initialValues}
|
||||
cancelHref={cancelHref}
|
||||
knownTags={knownTags}
|
||||
submitLabel="Criar atividade"
|
||||
searchContacts={searchActivityContactOptions}
|
||||
searchLeaderships={searchActivityLeadershipOptionsAction}
|
||||
searchResponsibles={searchActivityResponsibleOptionsAction}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ const updateActivityRecord = async (
|
|||
const { id, ...rawData } = activityUpdateSchema.parse(input)
|
||||
// Payload date fields accept string | undefined, not null.
|
||||
// Strip nulls only for required-ish fields; keep null for optional
|
||||
// fields (endAt, responsible, leadership) so clearing them works.
|
||||
// fields (endAt) so clearing them works.
|
||||
const data = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(rawData).filter(([key, value]) => {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import { APIError } from 'payload'
|
|||
import { relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
|
||||
activityStatusLabels,
|
||||
activityStatuses,
|
||||
activityStatusLabels,
|
||||
MAX_ACTIVITY_RESPONSIBLES,
|
||||
parseActivityResponsibleEntries,
|
||||
} from '@/lib/schemas/activity'
|
||||
import { slugify } from '@/lib/slug'
|
||||
import { trimmedText } from '@/lib/text'
|
||||
|
|
@ -17,10 +19,9 @@ import { getFreshCampaignUser } from '@/utilities/access/shared'
|
|||
import {
|
||||
canCampaignUserRescheduleActivity,
|
||||
canCreateActivity,
|
||||
canCreateActivityAdvisors,
|
||||
canDeleteActivity,
|
||||
canManageActivityAdvisors,
|
||||
canReadActivity,
|
||||
canSetActivityResponsible,
|
||||
canSetActivityStatus,
|
||||
canSetActivitySystemField,
|
||||
canUpdateActivity,
|
||||
|
|
@ -51,9 +52,11 @@ const activityStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
|
|||
locality: trimmedText(doc.locality),
|
||||
deputyPresent: Boolean(doc.deputyPresent),
|
||||
organizations: relationshipIds(doc.organizations),
|
||||
advisors: relationshipIds(doc.advisors),
|
||||
responsible: relationshipId(doc.responsible),
|
||||
leadership: relationshipId(doc.leadership),
|
||||
responsible: JSON.stringify(
|
||||
parseActivityResponsibleEntries(doc.responsible)
|
||||
.map(({ relationTo, value }) => `${relationTo}:${value}`)
|
||||
.sort(),
|
||||
),
|
||||
resultSummary: trimmedText(doc.resultSummary),
|
||||
resultMedia: relationshipIds(doc.resultMedia),
|
||||
})
|
||||
|
|
@ -110,33 +113,101 @@ const validateActivitySchedule: CollectionBeforeValidateHook = ({
|
|||
return data
|
||||
}
|
||||
|
||||
const validateActivityAdvisors: CollectionBeforeValidateHook = async ({ data, req, context }) => {
|
||||
/**
|
||||
* C90 — every `responsible` entry must point at a real, allowed entity:
|
||||
* staff `campaignUser` (assessor/candidato/coordenador) must stay eligible,
|
||||
* and `leadership`/`stateDeputy` ids must exist. Existence is verified as
|
||||
* admin truth so a staff member responsible for a commitment can always
|
||||
* re-save it as-is — the form's selector scopes the choice to the actor's
|
||||
* portfolio, not the save validation.
|
||||
*/
|
||||
const validateActivityResponsibles: CollectionBeforeValidateHook = async ({
|
||||
data,
|
||||
req,
|
||||
context,
|
||||
}) => {
|
||||
if (isActivityMutationShortcut(context)) return data
|
||||
if (!data) return data
|
||||
if (data.advisors === undefined) return data
|
||||
if (data.responsible === undefined) return data
|
||||
|
||||
const advisorIDs = [...new Set(relationshipIds(data.advisors))]
|
||||
if (advisorIDs.length === 0) return data
|
||||
|
||||
const eligibleAdvisors = await req.payload.find({
|
||||
collection: 'campaignUser',
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
where: {
|
||||
and: [{ id: { in: advisorIDs } }, eligibleCampaignStaffWhere],
|
||||
},
|
||||
select: { name: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
if (eligibleAdvisors.docs.length !== advisorIDs.length) {
|
||||
const entries = parseActivityResponsibleEntries(data.responsible)
|
||||
if (entries.length === 0) return data
|
||||
if (entries.length > MAX_ACTIVITY_RESPONSIBLES) {
|
||||
throw new APIError(
|
||||
'Cada responsável deve ter papel de Coordenador Geral, Assessor ou Candidato.',
|
||||
`Informe no máximo ${MAX_ACTIVITY_RESPONSIBLES} responsáveis pelo compromisso.`,
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const staffIDs = [
|
||||
...new Set(
|
||||
entries.filter((entry) => entry.relationTo === 'campaignUser').map((entry) => entry.value),
|
||||
),
|
||||
]
|
||||
const leadershipIDs = [
|
||||
...new Set(
|
||||
entries.filter((entry) => entry.relationTo === 'leadership').map((entry) => entry.value),
|
||||
),
|
||||
]
|
||||
const deputyIDs = [
|
||||
...new Set(
|
||||
entries.filter((entry) => entry.relationTo === 'stateDeputy').map((entry) => entry.value),
|
||||
),
|
||||
]
|
||||
|
||||
if (staffIDs.length > 0) {
|
||||
// bypass: eligibility is an admin truth — row access would reject
|
||||
// no-user/admin creates and empty requests; the where filter is the gate.
|
||||
const eligibleStaff = await req.payload.find({
|
||||
collection: 'campaignUser',
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
where: {
|
||||
and: [{ id: { in: staffIDs } }, eligibleCampaignStaffWhere],
|
||||
},
|
||||
select: { name: true },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (eligibleStaff.docs.length !== staffIDs.length) {
|
||||
throw new APIError(
|
||||
'Cada responsável deve ter papel de Coordenador Geral, Assessor ou Candidato.',
|
||||
400,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (leadershipIDs.length > 0) {
|
||||
// bypass: existence check only — the actor must be able to save an intact
|
||||
// responsible list even for leaderships outside their read scope.
|
||||
const leaderships = await req.payload.find({
|
||||
collection: 'leadership',
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
where: { id: { in: leadershipIDs } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (leaderships.docs.length !== leadershipIDs.length) {
|
||||
throw new APIError('Responsável de liderança inválido.', 400)
|
||||
}
|
||||
}
|
||||
|
||||
if (deputyIDs.length > 0) {
|
||||
// bypass: existence check only — see the leadership leg above.
|
||||
const deputies = await req.payload.find({
|
||||
collection: 'stateDeputy',
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
where: { id: { in: deputyIDs } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (deputies.docs.length !== deputyIDs.length) {
|
||||
throw new APIError('Responsável de dobradinha inválido.', 400)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
|
|
@ -188,9 +259,10 @@ const deriveActivityFields: CollectionBeforeChangeHook = ({
|
|||
data.createdBy = req.user.id
|
||||
if (
|
||||
req.user.role === 'advisor' &&
|
||||
(data.advisors === undefined || (Array.isArray(data.advisors) && data.advisors.length === 0))
|
||||
(data.responsible === undefined ||
|
||||
(Array.isArray(data.responsible) && data.responsible.length === 0))
|
||||
) {
|
||||
data.advisors = [req.user.id]
|
||||
data.responsible = [{ relationTo: 'campaignUser', value: req.user.id }]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,7 +377,7 @@ export const Activity: CollectionConfig = {
|
|||
beforeValidate: [
|
||||
setCanonicalActivitySlug,
|
||||
validateActivitySchedule,
|
||||
validateActivityAdvisors,
|
||||
validateActivityResponsibles,
|
||||
validateDeputyPresentTimeGate,
|
||||
],
|
||||
beforeChange: [deriveActivityFields],
|
||||
|
|
@ -427,31 +499,20 @@ export const Activity: CollectionConfig = {
|
|||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'advisors',
|
||||
// C90 — one polymorphic multi-value field replaces the old `advisors`
|
||||
// (staff hasMany), `leadership` (hasOne) and `responsible` (Contact)
|
||||
// fields. Anyone conducting the commitment — staff, a leadership or a
|
||||
// dobradinha — lives here.
|
||||
name: 'responsible',
|
||||
type: 'relationship',
|
||||
relationTo: 'campaignUser',
|
||||
label: 'Assessores responsáveis',
|
||||
relationTo: ['campaignUser', 'leadership', 'stateDeputy'],
|
||||
label: 'Responsáveis',
|
||||
hasMany: true,
|
||||
index: true,
|
||||
access: {
|
||||
create: canCreateActivityAdvisors,
|
||||
update: canManageActivityAdvisors,
|
||||
create: canSetActivityResponsible,
|
||||
update: canSetActivityResponsible,
|
||||
},
|
||||
filterOptions: eligibleCampaignStaffWhere,
|
||||
},
|
||||
{
|
||||
name: 'responsible',
|
||||
type: 'relationship',
|
||||
relationTo: 'contact',
|
||||
label: 'Responsável',
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'leadership',
|
||||
type: 'relationship',
|
||||
relationTo: 'leadership',
|
||||
label: 'Liderança',
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'tasks',
|
||||
|
|
|
|||
|
|
@ -39,9 +39,27 @@ export const ActivityCard = ({ activity }: { activity: ActivityListViewModel })
|
|||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-1 text-sm">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
||||
<UserIcon aria-hidden="true" className="size-4 shrink-0" />
|
||||
Resp: {activity.responsibleName ?? 'Não definido'}
|
||||
{activity.responsibles.length ? (
|
||||
<span className="flex min-w-0 flex-wrap gap-1">
|
||||
{activity.responsibles.slice(0, 2).map((responsible) => (
|
||||
<Badge
|
||||
key={`${responsible.relationTo}:${responsible.id}`}
|
||||
variant="outline"
|
||||
className="min-w-0 gap-1"
|
||||
>
|
||||
<span className="truncate">{responsible.name}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{responsible.typeLabel}</span>
|
||||
</Badge>
|
||||
))}
|
||||
{activity.responsibles.length > 2 ? (
|
||||
<Badge variant="outline">+{activity.responsibles.length - 2}</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
'Resp: Não definido'
|
||||
)}
|
||||
</span>
|
||||
{activity.taskProgress.total > 0 ? (
|
||||
<span className="font-medium tabular-nums text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -5,40 +5,29 @@ import { useActionState, useState } from 'react'
|
|||
|
||||
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
|
||||
import { ActivityTaskFields } from '@/components/campaign/activity/ActivityTaskFields'
|
||||
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
|
||||
import {
|
||||
ContactCombobox,
|
||||
type ContactComboboxOption,
|
||||
} from '@/components/campaign/shared/ContactCombobox'
|
||||
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
|
||||
import {
|
||||
RelationMultiSelect,
|
||||
type RelationOption,
|
||||
} from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import {
|
||||
ResponsibleMultiSelect,
|
||||
type ResponsibleOption,
|
||||
} from '@/components/campaign/shared/ResponsibleMultiSelect'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from '@/components/ui/field'
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime'
|
||||
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
||||
import { MAX_ACTIVITY_TAG_LENGTH, MAX_ACTIVITY_TAGS } from '@/lib/schemas/activity'
|
||||
import type { ActivityLeadershipOption } from '@/utilities/activityLeadershipOptions'
|
||||
import type { ActivityCreatePrefill } from '@/utilities/activityUi'
|
||||
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
import type { EligibleAdvisorOption } from '@/utilities/municipality/municipalityViewModels'
|
||||
|
||||
export type ActivityFormState = {
|
||||
message?: string
|
||||
|
|
@ -55,14 +44,12 @@ type ActivityFormAction = (
|
|||
export type ActivityFormFieldsProps = {
|
||||
municipalityOptions: RelationOption[]
|
||||
organizationOptions: RelationOption[]
|
||||
advisorOptions?: EligibleAdvisorOption[]
|
||||
canManageAdvisors?: boolean
|
||||
activity?: ActivityFormViewModel
|
||||
initialValues?: ActivityCreatePrefill
|
||||
fieldErrors?: Record<string, string[]>
|
||||
submittedTitle?: string
|
||||
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
|
||||
searchLeaderships: (query: string) => Promise<ActivityLeadershipOption[]>
|
||||
searchResponsibles: (query: string) => Promise<ResponsibleOption[]>
|
||||
/** Tags already used across activities, for autocomplete. */
|
||||
knownTags?: string[]
|
||||
}
|
||||
|
|
@ -152,29 +139,16 @@ const TagInput = ({
|
|||
const ActivityFormFields = ({
|
||||
municipalityOptions,
|
||||
organizationOptions,
|
||||
advisorOptions = [],
|
||||
canManageAdvisors = false,
|
||||
activity,
|
||||
initialValues,
|
||||
fieldErrors = {},
|
||||
submittedTitle,
|
||||
searchContacts,
|
||||
searchLeaderships,
|
||||
searchResponsibles,
|
||||
knownTags = [],
|
||||
}: ActivityFormFieldsProps) => {
|
||||
const errorFor = (name: string) => fieldError(fieldErrors, name)
|
||||
const [responsible, setResponsible] = useState<ContactComboboxOption | null>(
|
||||
activity?.responsible
|
||||
? {
|
||||
id: activity.responsible.id,
|
||||
name: activity.responsible.name,
|
||||
phone: activity.responsible.phone,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
const [leadership, setLeadership] = useState<ActivityLeadershipOption | null>(
|
||||
activity?.leadership ? { id: activity.leadership.id, label: activity.leadership.label } : null,
|
||||
)
|
||||
const initialResponsibles = activity?.responsibles ?? []
|
||||
const initialStartAt = activity?.startAt ?? initialValues?.startAt
|
||||
const initialEndAt = activity?.endAt ?? initialValues?.endAt
|
||||
const initialMunicipalityId = activity?.municipalityId ?? initialValues?.municipalityId
|
||||
|
|
@ -336,41 +310,14 @@ const ActivityFormFields = ({
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errorFor('responsible'))}>
|
||||
<FieldLabel>Responsável</FieldLabel>
|
||||
<ContactCombobox
|
||||
name="responsible"
|
||||
label="Responsável pela atividade"
|
||||
current={responsible}
|
||||
search={searchContacts}
|
||||
onChange={setResponsible}
|
||||
/>
|
||||
{errorFor('responsible') ? (
|
||||
<FieldError id="responsible-error">{errorFor('responsible')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('leadership'))}>
|
||||
<FieldLabel>Liderança vinculada</FieldLabel>
|
||||
<AsyncSearchCombobox
|
||||
name="leadership"
|
||||
label="Liderança vinculada"
|
||||
value={leadership}
|
||||
emptyOptionLabel="Nenhuma"
|
||||
dialogDescription="Busque lideranças engajadas por nome ou celular."
|
||||
isQueryReady={isContactSearchQueryReady}
|
||||
queryTooShortMessage="Digite ao menos dois caracteres para buscar."
|
||||
search={searchLeaderships}
|
||||
onChange={setLeadership}
|
||||
/>
|
||||
<FieldDescription>
|
||||
Quando vinculada, a liderança engajada vê a atividade e pode marcar tarefas e
|
||||
registrar atualizações.
|
||||
</FieldDescription>
|
||||
{errorFor('leadership') ? (
|
||||
<FieldError id="leadership-error">{errorFor('leadership')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<ResponsibleMultiSelect
|
||||
name="responsiblesJson"
|
||||
label="Responsáveis"
|
||||
value={initialResponsibles}
|
||||
error={errorFor('responsiblesJson')}
|
||||
description="Quem conduz este compromisso: equipe da campanha, liderança ou dobradinha — um ou vários."
|
||||
search={searchResponsibles}
|
||||
/>
|
||||
|
||||
<RelationMultiSelect
|
||||
name="organizations"
|
||||
|
|
@ -380,47 +327,6 @@ const ActivityFormFields = ({
|
|||
error={errorFor('organizations')}
|
||||
placeholder="Adicionar organização…"
|
||||
/>
|
||||
|
||||
{canManageAdvisors ? (
|
||||
<FieldSet
|
||||
aria-invalid={Boolean(errorFor('advisors'))}
|
||||
aria-describedby={errorFor('advisors') ? 'advisors-error' : undefined}
|
||||
>
|
||||
<FieldLegend>Assessores responsáveis</FieldLegend>
|
||||
<FieldDescription>
|
||||
Assessores selecionados podem editar esta atividade e gerenciar suas tarefas.
|
||||
</FieldDescription>
|
||||
<input type="hidden" name="advisorsSubmitted" value="1" />
|
||||
{advisorOptions.length ? (
|
||||
<div data-slot="checkbox-group" className="grid gap-2 sm:grid-cols-2">
|
||||
{advisorOptions.map((advisor) => (
|
||||
<FieldLabel key={advisor.id}>
|
||||
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
|
||||
<Checkbox
|
||||
name="advisors"
|
||||
value={String(advisor.id)}
|
||||
defaultChecked={
|
||||
activity
|
||||
? activity.advisorIDs.includes(advisor.id)
|
||||
: advisor.isCurrent
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{advisor.name}
|
||||
{advisor.isCurrent ? ' (você)' : ''}
|
||||
</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nenhum assessor disponível.</p>
|
||||
)}
|
||||
{errorFor('advisors') ? (
|
||||
<FieldError id="advisors-error">{errorFor('advisors')}</FieldError>
|
||||
) : null}
|
||||
</FieldSet>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -454,14 +360,12 @@ export const ActivityForm = ({
|
|||
action,
|
||||
municipalityOptions,
|
||||
organizationOptions,
|
||||
advisorOptions,
|
||||
canManageAdvisors,
|
||||
activity,
|
||||
initialValues,
|
||||
fieldErrors: externalFieldErrors,
|
||||
submittedTitle,
|
||||
searchContacts,
|
||||
searchLeaderships,
|
||||
searchResponsibles,
|
||||
knownTags,
|
||||
submitLabel,
|
||||
cancelHref,
|
||||
|
|
@ -477,14 +381,12 @@ export const ActivityForm = ({
|
|||
<ActivityFormFields
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
advisorOptions={advisorOptions}
|
||||
canManageAdvisors={canManageAdvisors}
|
||||
activity={activity}
|
||||
initialValues={initialValues}
|
||||
fieldErrors={state.fieldErrors ?? externalFieldErrors}
|
||||
submittedTitle={state.submittedTitle ?? submittedTitle}
|
||||
searchContacts={searchContacts}
|
||||
searchLeaderships={searchLeaderships}
|
||||
searchResponsibles={searchResponsibles}
|
||||
knownTags={knownTags}
|
||||
/>
|
||||
|
||||
|
|
|
|||
226
src/components/campaign/shared/ResponsibleMultiSelect.tsx
Normal file
226
src/components/campaign/shared/ResponsibleMultiSelect.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronsUpDownIcon, XIcon } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/Command'
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
||||
import {
|
||||
MAX_ACTIVITY_RESPONSIBLES,
|
||||
type ActivityResponsibleCollection,
|
||||
} from '@/lib/schemas/activity'
|
||||
|
||||
export type ResponsibleOption = {
|
||||
relationTo: ActivityResponsibleCollection
|
||||
id: number
|
||||
name: string
|
||||
typeLabel: string
|
||||
}
|
||||
|
||||
type ResponsibleMultiSelectProps = {
|
||||
/** FormData field name — a hidden JSON field with the typed entries. */
|
||||
name: string
|
||||
label: string
|
||||
value?: ResponsibleOption[]
|
||||
error?: string
|
||||
description?: string
|
||||
search: (query: string) => Promise<ResponsibleOption[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* C90 — polymorphic multi-value responsible picker for the activity form
|
||||
* (later reused by the agenda inline creation, C91). One search catalog across
|
||||
* staff/leadership/dobradinha, results grouped by type, removable typed chips.
|
||||
*/
|
||||
export const ResponsibleMultiSelect = ({
|
||||
name,
|
||||
label,
|
||||
value = [],
|
||||
error,
|
||||
description,
|
||||
search,
|
||||
}: ResponsibleMultiSelectProps) => {
|
||||
// Initial-only (like RelationMultiSelect's initialSelectedIDs): on the create
|
||||
// page `value` is a fresh `[]` every render and a sync effect would wipe the
|
||||
// user's picks on any failed submit.
|
||||
const [selected, setSelected] = useState<ResponsibleOption[]>(value)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [options, setOptions] = useState<ResponsibleOption[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const requestId = useRef(0)
|
||||
const searchRef = useRef(search)
|
||||
|
||||
searchRef.current = search
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!isContactSearchQueryReady(query)) {
|
||||
setOptions([])
|
||||
setLoading(false)
|
||||
setFailed(false)
|
||||
return
|
||||
}
|
||||
|
||||
const currentRequestId = ++requestId.current
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLoading(true)
|
||||
setFailed(false)
|
||||
void searchRef
|
||||
.current(query.trim())
|
||||
.then((nextOptions) => {
|
||||
if (requestId.current !== currentRequestId) return
|
||||
setOptions(nextOptions)
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId.current !== currentRequestId) return
|
||||
setFailed(true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId.current === currentRequestId) setLoading(false)
|
||||
})
|
||||
}, 250)
|
||||
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [open, query])
|
||||
|
||||
const selectedKey = (option: ResponsibleOption) => `${option.relationTo}:${option.id}`
|
||||
const selectedKeys = new Set(selected.map(selectedKey))
|
||||
const atCapacity = selected.length >= MAX_ACTIVITY_RESPONSIBLES
|
||||
|
||||
const add = (option: ResponsibleOption) => {
|
||||
if (atCapacity) return
|
||||
setSelected((current) =>
|
||||
current.some((entry) => selectedKey(entry) === selectedKey(option))
|
||||
? current
|
||||
: [...current, option],
|
||||
)
|
||||
}
|
||||
|
||||
const remove = (option: ResponsibleOption) => {
|
||||
const key = selectedKey(option)
|
||||
setSelected((current) => current.filter((entry) => selectedKey(entry) !== key))
|
||||
}
|
||||
|
||||
const visibleOptions = options.filter((option) => !selectedKeys.has(selectedKey(option)))
|
||||
const groups = visibleOptions.reduce<Record<string, ResponsibleOption[]>>((acc, option) => {
|
||||
const group = acc[option.typeLabel] ?? []
|
||||
group.push(option)
|
||||
acc[option.typeLabel] = group
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const selectedLabel =
|
||||
selected.length === 0
|
||||
? 'Nenhum'
|
||||
: `${selected.length} ${selected.length === 1 ? 'responsável' : 'responsáveis'}`
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
value={JSON.stringify(selected.map(({ relationTo, id }) => ({ relationTo, value: id })))}
|
||||
/>
|
||||
{selected.length ? (
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{selected.map((option) => (
|
||||
<li key={selectedKey(option)}>
|
||||
<Badge variant="secondary" className="gap-1 pr-1">
|
||||
<span className="font-normal">{option.name}</span>
|
||||
<span className="text-muted-foreground">{option.typeLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remover ${option.name}`}
|
||||
className="inline-flex size-6 items-center justify-center rounded-full hover:bg-foreground/10"
|
||||
onClick={() => remove(option)}
|
||||
>
|
||||
<XIcon className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full justify-between font-normal"
|
||||
aria-label={`${label}: ${selectedLabel}`}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${name}-error` : undefined}
|
||||
disabled={atCapacity}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{atCapacity ? 'Limite de responsáveis alcançado' : selectedLabel}
|
||||
</span>
|
||||
<ChevronsUpDownIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={label}
|
||||
description="Busque por nome e escolha um ou mais responsáveis."
|
||||
showCloseButton
|
||||
>
|
||||
<Command shouldFilter={false} label={label}>
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Buscar responsável"
|
||||
aria-label={label}
|
||||
/>
|
||||
<CommandList aria-busy={loading}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Spinner aria-hidden="true" />
|
||||
Buscando…
|
||||
</div>
|
||||
) : failed ? (
|
||||
<p className="py-6 text-center text-sm text-destructive">
|
||||
Não foi possível concluir a busca.
|
||||
</p>
|
||||
) : Object.keys(groups).length > 0 ? (
|
||||
Object.entries(groups).map(([typeLabel, groupOptions]) => (
|
||||
<CommandGroup key={typeLabel} heading={typeLabel}>
|
||||
{groupOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={selectedKey(option)}
|
||||
value={`${option.typeLabel}-${option.id}`}
|
||||
onSelect={() => add(option)}
|
||||
disabled={atCapacity}
|
||||
>
|
||||
<span className="truncate">{option.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{!isContactSearchQueryReady(query)
|
||||
? 'Digite ao menos dois caracteres para buscar.'
|
||||
: 'Nenhum resultado encontrado.'}
|
||||
</p>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
{description ? <FieldDescription>{description}</FieldDescription> : null}
|
||||
{error ? <FieldError id={`${name}-error`}>{error}</FieldError> : null}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import { campaignDemandCreateSchema } from '@/lib/schemas/campaignDemandInput'
|
||||
import {
|
||||
positiveRelationshipId,
|
||||
|
|
@ -46,6 +47,9 @@ export const activityStatusLabels: Record<ActivityStatus, string> = {
|
|||
const MAX_ACTIVITY_ORGANIZATIONS = 20
|
||||
export const MAX_ACTIVITY_DEMAND_DRAFTS = 20
|
||||
|
||||
/** C90 — cap for the polymorphic multi-value `responsible` field. */
|
||||
export const MAX_ACTIVITY_RESPONSIBLES = 20
|
||||
|
||||
const activityDemandDraftSchema = campaignDemandCreateSchema.pick({
|
||||
title: true,
|
||||
kind: true,
|
||||
|
|
@ -69,6 +73,52 @@ const activityTagsSchema = z
|
|||
.array(z.string().trim().min(1).max(MAX_ACTIVITY_TAG_LENGTH))
|
||||
.max(MAX_ACTIVITY_TAGS)
|
||||
|
||||
const activityResponsibleCollections = ['campaignUser', 'leadership', 'stateDeputy'] as const
|
||||
export type ActivityResponsibleCollection = (typeof activityResponsibleCollections)[number]
|
||||
|
||||
export const activityResponsibleTypeLabels: Record<ActivityResponsibleCollection, string> = {
|
||||
campaignUser: 'Equipe',
|
||||
leadership: 'Liderança',
|
||||
stateDeputy: 'Dobradinha',
|
||||
}
|
||||
|
||||
export type ActivityResponsibleEntry = {
|
||||
relationTo: ActivityResponsibleCollection
|
||||
value: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a polymorphic `responsible` value (ids or `{relationTo, value}`
|
||||
* objects at any depth) into typed entries, dropping malformed rows. Shared by
|
||||
* the collection validation, the notification recipients and the view models —
|
||||
* one place to add a future responsible kind.
|
||||
*/
|
||||
export const parseActivityResponsibleEntries = (value: unknown): ActivityResponsibleEntry[] =>
|
||||
(Array.isArray(value) ? value : [])
|
||||
.map((entry): ActivityResponsibleEntry | null => {
|
||||
if (typeof entry !== 'object' || entry === null) return null
|
||||
const record = entry as { relationTo?: unknown; value?: unknown }
|
||||
const relationTo = activityResponsibleCollections.find(
|
||||
(candidate) => candidate === record.relationTo,
|
||||
)
|
||||
if (!relationTo) return null
|
||||
const id = relationshipId(record.value)
|
||||
return id === null ? null : { relationTo, value: id }
|
||||
})
|
||||
.filter((entry): entry is ActivityResponsibleEntry => entry !== null)
|
||||
|
||||
/**
|
||||
* C90 — one typed responsible entry. `value` must be a positive id and the
|
||||
* `relationTo` one of the allowed collections; the collection hook re-verifies
|
||||
* the entity exists / stays eligible.
|
||||
*/
|
||||
export const activityResponsibleSchema = z.object({
|
||||
relationTo: z.enum(activityResponsibleCollections),
|
||||
value: positiveRelationshipId,
|
||||
})
|
||||
|
||||
const activityResponsiblesSchema = z.array(activityResponsibleSchema).max(MAX_ACTIVITY_RESPONSIBLES)
|
||||
|
||||
const activityFieldsSchema = z.object({
|
||||
title: z.string().trim().min(2).max(160),
|
||||
tags: activityTagsSchema.optional(),
|
||||
|
|
@ -80,9 +130,7 @@ const activityFieldsSchema = z.object({
|
|||
municipality: positiveRelationshipId,
|
||||
locality: trimmedOptionalText(160),
|
||||
organizations: z.array(positiveRelationshipId).max(MAX_ACTIVITY_ORGANIZATIONS).optional(),
|
||||
advisors: z.array(positiveRelationshipId).optional(),
|
||||
responsible: positiveRelationshipId.optional(),
|
||||
leadership: positiveRelationshipId.optional(),
|
||||
responsible: activityResponsiblesSchema.optional(),
|
||||
tasks: z.array(taskSchema).optional(),
|
||||
})
|
||||
|
||||
|
|
@ -182,8 +230,6 @@ export const activityUpdateSchema = activityFieldsSchema
|
|||
description: trimmedNullableText(4000),
|
||||
startAt: z.string().datetime().nullable().optional(),
|
||||
endAt: z.string().datetime().nullable().optional(),
|
||||
responsible: positiveRelationshipId.nullable().optional(),
|
||||
leadership: positiveRelationshipId.nullable().optional(),
|
||||
status: z.enum(activityStatuses).optional(),
|
||||
})
|
||||
.superRefine((data, context) => {
|
||||
|
|
|
|||
13171
src/migrations/20260808_184113_remodel_activity_responsible.json
Normal file
13171
src/migrations/20260808_184113_remodel_activity_responsible.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,78 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "activity" DROP CONSTRAINT "activity_responsible_id_contact_id_fk";
|
||||
|
||||
ALTER TABLE "activity" DROP CONSTRAINT "activity_leadership_id_leadership_id_fk";
|
||||
|
||||
DROP INDEX "activity_responsible_idx";
|
||||
DROP INDEX "activity_leadership_idx";
|
||||
ALTER TABLE "activity_rels" ADD COLUMN "leadership_id" integer;
|
||||
ALTER TABLE "activity_rels" ADD COLUMN "state_deputy_id" integer;
|
||||
ALTER TABLE "activity_rels" ADD CONSTRAINT "activity_rels_leadership_fk" FOREIGN KEY ("leadership_id") REFERENCES "public"."leadership"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "activity_rels" ADD CONSTRAINT "activity_rels_state_deputy_fk" FOREIGN KEY ("state_deputy_id") REFERENCES "public"."state_deputy"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "activity_rels_leadership_id_idx" ON "activity_rels" USING btree ("leadership_id");
|
||||
CREATE INDEX "activity_rels_state_deputy_id_idx" ON "activity_rels" USING btree ("state_deputy_id");
|
||||
|
||||
-- C90 data reconciliation (must run before the columns are dropped).
|
||||
-- 1) The old advisors (staff hasMany) rows move to the single responsible path.
|
||||
UPDATE "activity_rels"
|
||||
SET "path" = 'responsible'
|
||||
WHERE "path" = 'advisors' AND "campaign_user_id" IS NOT NULL;
|
||||
|
||||
-- 2) Each old activity.leadership_id becomes a responsible row.
|
||||
INSERT INTO "activity_rels" ("parent_id", "path", "order", "leadership_id")
|
||||
SELECT a."id", 'responsible', COALESCE(m.max_order + 1, 1), a."leadership_id"
|
||||
FROM "activity" a
|
||||
LEFT JOIN (
|
||||
SELECT r."parent_id", max(r."order") AS max_order
|
||||
FROM "activity_rels" r
|
||||
WHERE r."path" = 'responsible'
|
||||
GROUP BY r."parent_id"
|
||||
) m ON m."parent_id" = a."id"
|
||||
WHERE a."leadership_id" IS NOT NULL;
|
||||
|
||||
-- 3) Renumber responsible rows sequentially per parent so sparse orders
|
||||
-- left by the migration never confuse reading.
|
||||
UPDATE "activity_rels" r
|
||||
SET "order" = t.new_order
|
||||
FROM (
|
||||
SELECT r2."id", r2."parent_id", row_number() OVER (
|
||||
PARTITION BY r2."parent_id" ORDER BY r2."order", r2."id"
|
||||
) AS new_order
|
||||
FROM "activity_rels" r2
|
||||
WHERE r2."path" = 'responsible'
|
||||
) t
|
||||
WHERE r."id" = t."id";
|
||||
|
||||
-- 4) The old responsible (Contact) has no valid type in the new union;
|
||||
-- C90 gate decided to discard it. Surface the count at deploy time.
|
||||
DO $$
|
||||
DECLARE contacts_dropped integer;
|
||||
BEGIN
|
||||
SELECT count(*) INTO contacts_dropped FROM "activity" WHERE "responsible_id" IS NOT NULL;
|
||||
RAISE NOTICE 'remodel_activity_responsible: descartando % responsável(is) Contact antigo(s)', contacts_dropped;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "activity" DROP COLUMN "responsible_id";
|
||||
ALTER TABLE "activity" DROP COLUMN "leadership_id";`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "activity_rels" DROP CONSTRAINT "activity_rels_leadership_fk";
|
||||
|
||||
ALTER TABLE "activity_rels" DROP CONSTRAINT "activity_rels_state_deputy_fk";
|
||||
|
||||
DROP INDEX "activity_rels_leadership_id_idx";
|
||||
DROP INDEX "activity_rels_state_deputy_id_idx";
|
||||
ALTER TABLE "activity" ADD COLUMN "responsible_id" integer;
|
||||
ALTER TABLE "activity" ADD COLUMN "leadership_id" integer;
|
||||
ALTER TABLE "activity" ADD CONSTRAINT "activity_responsible_id_contact_id_fk" FOREIGN KEY ("responsible_id") REFERENCES "public"."contact"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "activity" ADD CONSTRAINT "activity_leadership_id_leadership_id_fk" FOREIGN KEY ("leadership_id") REFERENCES "public"."leadership"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE INDEX "activity_responsible_idx" ON "activity" USING btree ("responsible_id");
|
||||
CREATE INDEX "activity_leadership_idx" ON "activity" USING btree ("leadership_id");
|
||||
ALTER TABLE "activity_rels" DROP COLUMN "leadership_id";
|
||||
ALTER TABLE "activity_rels" DROP COLUMN "state_deputy_id";`)
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ import * as migration_20260806_082110_add_state_deputy_contact from './20260806_
|
|||
import * as migration_20260806_120000_c14_remodel_activity_agenda from './20260806_120000_c14_remodel_activity_agenda';
|
||||
import * as migration_20260807_030000_unify_municipality_update from './20260807_030000_unify_municipality_update';
|
||||
import * as migration_20260808_134911_add_calendar_feed from './20260808_134911_add_calendar_feed';
|
||||
import * as migration_20260808_184113_remodel_activity_responsible from './20260808_184113_remodel_activity_responsible';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -274,6 +275,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260808_134911_add_calendar_feed.up,
|
||||
down: migration_20260808_134911_add_calendar_feed.down,
|
||||
name: '20260808_134911_add_calendar_feed'
|
||||
name: '20260808_134911_add_calendar_feed',
|
||||
},
|
||||
{
|
||||
up: migration_20260808_184113_remodel_activity_responsible.up,
|
||||
down: migration_20260808_184113_remodel_activity_responsible.down,
|
||||
name: '20260808_184113_remodel_activity_responsible'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -747,9 +747,22 @@ export interface Activity {
|
|||
municipality: number | Municipality;
|
||||
locality?: string | null;
|
||||
organizations?: (number | Organization)[] | null;
|
||||
advisors?: (number | CampaignUser)[] | null;
|
||||
responsible?: (number | null) | Contact;
|
||||
leadership?: (number | null) | Leadership;
|
||||
responsible?:
|
||||
| (
|
||||
| {
|
||||
relationTo: 'campaignUser';
|
||||
value: number | CampaignUser;
|
||||
}
|
||||
| {
|
||||
relationTo: 'leadership';
|
||||
value: number | Leadership;
|
||||
}
|
||||
| {
|
||||
relationTo: 'stateDeputy';
|
||||
value: number | StateDeputy;
|
||||
}
|
||||
)[]
|
||||
| null;
|
||||
tasks?:
|
||||
| {
|
||||
title: string;
|
||||
|
|
@ -1787,9 +1800,7 @@ export interface ActivitySelect<T extends boolean = true> {
|
|||
municipality?: T;
|
||||
locality?: T;
|
||||
organizations?: T;
|
||||
advisors?: T;
|
||||
responsible?: T;
|
||||
leadership?: T;
|
||||
tasks?:
|
||||
| T
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
|
|||
import {
|
||||
advisorMunicipalityScopeWhere,
|
||||
getFreshCampaignUser,
|
||||
isCampaignCoordinator,
|
||||
isCampaignLeader,
|
||||
isCampaignStaff,
|
||||
isCampaignUnrestricted,
|
||||
|
|
@ -41,9 +40,12 @@ export const canReadActivity: Access = async ({ req }): Promise<boolean | Where>
|
|||
const municipalityIDs = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
return {
|
||||
or: [
|
||||
// C90 — polymorphic `responsible`: the only scalar query the adapter
|
||||
// supports on a multi-relation relationship is the object notation
|
||||
// with `equals` (see @payloadcms/drizzle sanitizeQueryValue).
|
||||
{
|
||||
advisors: {
|
||||
contains: currentUser.id,
|
||||
responsible: {
|
||||
equals: { relationTo: 'campaignUser', value: currentUser.id },
|
||||
},
|
||||
},
|
||||
advisorMunicipalityScopeWhere('municipality', municipalityIDs),
|
||||
|
|
@ -63,10 +65,5 @@ export const canSetActivitySystemField: FieldAccess = ({ req }) => isPayloadAdmi
|
|||
|
||||
export const canSetActivityStatus: FieldAccess = canStaffCreateActivity
|
||||
|
||||
export const canCreateActivityAdvisors: FieldAccess = canStaffCreateActivity
|
||||
|
||||
export const canManageActivityAdvisors: FieldAccess = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
return isCampaignCoordinator(await getFreshCampaignUser(req))
|
||||
}
|
||||
/** C90 — the unified `responsible` field is editable by any staff with row access. */
|
||||
export const canSetActivityResponsible: FieldAccess = canStaffCreateActivity
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
boundedJsonFormValue,
|
||||
checkboxFormValue,
|
||||
FormDataBoundaryError,
|
||||
nullableRelationshipFormValue,
|
||||
optionalFormText,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredFormText,
|
||||
|
|
@ -13,13 +12,16 @@ import {
|
|||
} from '@/lib/formData'
|
||||
import {
|
||||
activityDemandDraftsSchema,
|
||||
activityResponsibleSchema,
|
||||
activityStatuses,
|
||||
MAX_ACTIVITY_DEMAND_DRAFTS,
|
||||
MAX_ACTIVITY_RESPONSIBLES,
|
||||
MAX_ACTIVITY_TAG_LENGTH,
|
||||
MAX_ACTIVITY_TAGS,
|
||||
tourStopDraftsSchema,
|
||||
type ActivityCreateInput,
|
||||
type ActivityDemandDraft,
|
||||
type ActivityResponsibleCollection,
|
||||
type ActivityUpdateInput,
|
||||
type TourStopDraft,
|
||||
} from '@/lib/schemas/activity'
|
||||
|
|
@ -114,10 +116,41 @@ const parseTagsFormData = (formData: FormData): string[] => {
|
|||
return tags
|
||||
}
|
||||
|
||||
const parseSharedActivityFormData = (formData: FormData) => {
|
||||
const responsible = nullableRelationshipFormValue(formData, 'responsible')
|
||||
const leadership = nullableRelationshipFormValue(formData, 'leadership')
|
||||
type ParsedResponsible = {
|
||||
relationTo: ActivityResponsibleCollection
|
||||
value: number
|
||||
}
|
||||
|
||||
/**
|
||||
* C90 — the polymorphic multi-value `responsible` submits as a bounded JSON
|
||||
* field (same precedent as `tasksJson`/`tagsJson`): repeated hidden inputs
|
||||
* would lose the `relationTo` correlation.
|
||||
*/
|
||||
const parseResponsiblesFormData = (formData: FormData): ParsedResponsible[] => {
|
||||
const raw = boundedJsonFormValue(formData, 'responsiblesJson', 8_000)
|
||||
if (raw === undefined) return []
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new FormDataBoundaryError('responsiblesJson', 'Lista de responsáveis inválida.')
|
||||
}
|
||||
if (raw.length > MAX_ACTIVITY_RESPONSIBLES) {
|
||||
throw new FormDataBoundaryError(
|
||||
'responsiblesJson',
|
||||
`Informe no máximo ${MAX_ACTIVITY_RESPONSIBLES} responsáveis.`,
|
||||
)
|
||||
}
|
||||
return raw.map((item, index) => {
|
||||
const parsed = activityResponsibleSchema.safeParse(item)
|
||||
if (!parsed.success) {
|
||||
throw new FormDataBoundaryError(
|
||||
'responsiblesJson',
|
||||
`Responsável ${index + 1} inválido. Atualize a página e refaça a escolha.`,
|
||||
)
|
||||
}
|
||||
return parsed.data
|
||||
})
|
||||
}
|
||||
|
||||
const parseSharedActivityFormData = (formData: FormData) => {
|
||||
return {
|
||||
title: optionalFormText(formData, 'title') ?? '',
|
||||
tags: parseTagsFormData(formData),
|
||||
|
|
@ -129,8 +162,7 @@ const parseSharedActivityFormData = (formData: FormData) => {
|
|||
municipality: requiredRelationshipFormValue(formData, 'municipality'),
|
||||
locality: optionalFormText(formData, 'locality'),
|
||||
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
|
||||
responsible,
|
||||
leadership,
|
||||
responsible: parseResponsiblesFormData(formData),
|
||||
tasks: parseTasksFormData(formData),
|
||||
demands: parseDemandDraftsFormData(formData),
|
||||
}
|
||||
|
|
@ -142,15 +174,12 @@ export type ParsedActivityCreateFormData = ActivityCreateInput & {
|
|||
|
||||
export const parseActivityCreateFormData = (formData: FormData): ParsedActivityCreateFormData => {
|
||||
const shared = parseSharedActivityFormData(formData)
|
||||
const advisorIds = repeatedRelationshipFormValues(formData, 'advisors')
|
||||
|
||||
return {
|
||||
...shared,
|
||||
status: (shared.status ?? 'confirmado') as ActivityCreateInput['status'],
|
||||
responsible: shared.responsible ?? undefined,
|
||||
leadership: shared.leadership ?? undefined,
|
||||
responsible: shared.responsible.length ? shared.responsible : undefined,
|
||||
organizations: shared.organizations.length ? shared.organizations : undefined,
|
||||
advisors: advisorIds.length ? advisorIds : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,10 +223,6 @@ export type ParsedActivityUpdateFormData = ActivityUpdateInput & {
|
|||
|
||||
export const parseActivityUpdateFormData = (formData: FormData): ParsedActivityUpdateFormData => {
|
||||
const shared = parseSharedActivityFormData(formData)
|
||||
const advisorIds = repeatedRelationshipFormValues(formData, 'advisors')
|
||||
// Checkboxes only submit when checked, so a hidden marker distinguishes
|
||||
// "advisors section rendered but all unchecked" from "not editable" (advisor view).
|
||||
const hasAdvisors = formData.has('advisorsSubmitted')
|
||||
|
||||
return {
|
||||
...shared,
|
||||
|
|
@ -207,9 +232,10 @@ export const parseActivityUpdateFormData = (formData: FormData): ParsedActivityU
|
|||
description: shared.description ?? null,
|
||||
startAt: shared.startAt ?? null,
|
||||
endAt: shared.endAt ?? null,
|
||||
responsible: shared.responsible,
|
||||
leadership: shared.leadership,
|
||||
organizations: shared.organizations,
|
||||
advisors: hasAdvisors ? advisorIds : undefined,
|
||||
// Update always submits the full list (possibly empty) so clearing works.
|
||||
// Invariant: the form always renders `responsiblesJson` (even empty) — a
|
||||
// partial form that omits it would silently clear `responsible`.
|
||||
responsible: shared.responsible,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
src/utilities/activityResponsibleSearch.ts
Normal file
117
src/utilities/activityResponsibleSearch.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
|
||||
import { populatedContactName } from '@/lib/relationship'
|
||||
import {
|
||||
activityResponsibleTypeLabels,
|
||||
type ActivityResponsibleCollection,
|
||||
} from '@/lib/schemas/activity'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { searchActivityLeadershipOptions } from '@/utilities/activityLeadershipOptions'
|
||||
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
|
||||
|
||||
export type ActivityResponsibleSearchOption = {
|
||||
relationTo: ActivityResponsibleCollection
|
||||
id: number
|
||||
name: string
|
||||
typeLabel: string
|
||||
}
|
||||
|
||||
const RESPONSIBLE_SEARCH_LIMIT = 20
|
||||
|
||||
const toResponsibleOption = (
|
||||
relationTo: ActivityResponsibleCollection,
|
||||
id: number,
|
||||
name: string,
|
||||
): ActivityResponsibleSearchOption => ({
|
||||
relationTo,
|
||||
id,
|
||||
name,
|
||||
typeLabel: activityResponsibleTypeLabels[relationTo],
|
||||
})
|
||||
|
||||
/**
|
||||
* C90 — one async catalog across the three responsible types: eligible staff,
|
||||
* engaged leaderships and dobradinhas, each read through the actor's own
|
||||
* access (`overrideAccess: false`), so a leadership outside an advisor's
|
||||
* portfolio never shows up as a responsible option.
|
||||
*/
|
||||
export const searchActivityResponsibleOptions = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
query: string,
|
||||
): Promise<ActivityResponsibleSearchOption[]> => {
|
||||
const normalizedQuery = typeof query === 'string' ? query : ''
|
||||
if (!isContactSearchQueryReady(normalizedQuery)) return []
|
||||
|
||||
const { trimmed, digits } = normalizeContactSearchQuery(normalizedQuery)
|
||||
|
||||
const [staff, leaderships, deputies] = await Promise.all([
|
||||
searchStaffResponsibles(payload, user, trimmed),
|
||||
searchActivityLeadershipOptions(payload, user, trimmed),
|
||||
searchStateDeputyResponsibles(payload, user, trimmed, digits),
|
||||
])
|
||||
|
||||
const staffOptions = staff.map(({ id, name }) => toResponsibleOption('campaignUser', id, name))
|
||||
const leadershipOptions = leaderships.map(({ id, label }) =>
|
||||
toResponsibleOption('leadership', id, label),
|
||||
)
|
||||
const deputyOptions = deputies.map(({ id, name }) => toResponsibleOption('stateDeputy', id, name))
|
||||
|
||||
return [...staffOptions, ...leadershipOptions, ...deputyOptions].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, 'pt-BR'),
|
||||
)
|
||||
}
|
||||
|
||||
const searchStaffResponsibles = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
trimmed: string,
|
||||
) => {
|
||||
const result = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: {
|
||||
and: [eligibleCampaignStaffWhere, ...(trimmed ? [{ name: { contains: trimmed } }] : [])],
|
||||
},
|
||||
depth: 0,
|
||||
limit: RESPONSIBLE_SEARCH_LIMIT,
|
||||
page: 1,
|
||||
select: { name: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return result.docs.map(({ id, name }) => ({ id, name: name ?? `Equipe #${id}` }))
|
||||
}
|
||||
|
||||
const searchStateDeputyResponsibles = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
trimmed: string,
|
||||
digits: string,
|
||||
) => {
|
||||
const result = await payload.find({
|
||||
collection: 'stateDeputy',
|
||||
where: {
|
||||
or: [
|
||||
{ 'contact.name': { contains: trimmed } },
|
||||
{ 'contact.phone': { contains: digits || trimmed } },
|
||||
],
|
||||
},
|
||||
depth: 1,
|
||||
limit: RESPONSIBLE_SEARCH_LIMIT,
|
||||
page: 1,
|
||||
sort: 'contact.name',
|
||||
select: { contact: true, party: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return result.docs.map((stateDeputy) => {
|
||||
const plainName = populatedContactName(stateDeputy.contact)
|
||||
return {
|
||||
id: stateDeputy.id,
|
||||
name: stateDeputy.party ? `${plainName} (${stateDeputy.party})` : plainName,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -262,9 +262,10 @@ export const buildActivityListWhere = (state: ActivityListState, now: Date): Whe
|
|||
if (state.q) {
|
||||
const q = isActivityListSearchReady(state.q)
|
||||
if (q) {
|
||||
filters.push({
|
||||
or: [{ title: { contains: q } }, { 'responsible.name': { contains: q } }],
|
||||
})
|
||||
// C90 — `responsible` is a polymorphic multi-relation; a nested
|
||||
// `responsible.name` query is not supported on it, so the list search is
|
||||
// title-only (see impl plan).
|
||||
filters.push({ title: { contains: q } })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { isPopulatedRelationship, relationshipId } from '@/lib/relationship'
|
||||
import type { Activity, CampaignUser, Contact, Municipality, Organization } from '@/payload-types'
|
||||
import { isPopulatedRelationship, populatedContactName, relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
activityResponsibleTypeLabels,
|
||||
parseActivityResponsibleEntries,
|
||||
type ActivityResponsibleCollection,
|
||||
type ActivityResponsibleEntry,
|
||||
} from '@/lib/schemas/activity'
|
||||
import type {
|
||||
Activity,
|
||||
CampaignUser,
|
||||
Contact,
|
||||
Leadership,
|
||||
Municipality,
|
||||
Organization,
|
||||
StateDeputy,
|
||||
} from '@/payload-types'
|
||||
import { canCampaignUserRescheduleActivity } from '@/utilities/access/activities'
|
||||
import type { ActivityDetailTab } from '@/utilities/activityDetailTabUi'
|
||||
|
||||
|
|
@ -17,6 +31,60 @@ export const activityMunicipalitySummary = (
|
|||
? { id: municipality.id, name: municipality.name, slug: municipality.slug }
|
||||
: null
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C90 — polymorphic `responsible` display (typed chips)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ActivityResponsibleDisplay = {
|
||||
relationTo: ActivityResponsibleCollection
|
||||
id: number
|
||||
name: string
|
||||
typeLabel: string
|
||||
}
|
||||
|
||||
const responsibleEntryName = (
|
||||
relationTo: ActivityResponsibleEntry['relationTo'],
|
||||
value: unknown,
|
||||
): string | null => {
|
||||
switch (relationTo) {
|
||||
case 'campaignUser':
|
||||
return isPopulatedRelationship<CampaignUser>(value) ? value.name : null
|
||||
case 'leadership':
|
||||
return isPopulatedRelationship<Leadership>(value)
|
||||
? populatedContactName((value as Leadership).contact)
|
||||
: null
|
||||
case 'stateDeputy':
|
||||
return isPopulatedRelationship<StateDeputy>(value)
|
||||
? populatedContactName((value as StateDeputy).contact)
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a polymorphic `responsible` value (ids or populated docs) to display chips. */
|
||||
const mapActivityResponsibles = (
|
||||
value: readonly unknown[] | null | undefined,
|
||||
): ActivityResponsibleDisplay[] => {
|
||||
const populatedByKey = new Map<string, unknown>()
|
||||
for (const raw of Array.isArray(value) ? value : []) {
|
||||
const record = raw as { relationTo?: unknown; value?: unknown }
|
||||
const id = relationshipId(record.value)
|
||||
if (typeof record.relationTo === 'string' && id !== null) {
|
||||
populatedByKey.set(`${record.relationTo}:${id}`, record.value)
|
||||
}
|
||||
}
|
||||
|
||||
return parseActivityResponsibleEntries(value).map((entry) => ({
|
||||
relationTo: entry.relationTo,
|
||||
id: entry.value,
|
||||
name:
|
||||
responsibleEntryName(
|
||||
entry.relationTo,
|
||||
populatedByKey.get(`${entry.relationTo}:${entry.value}`),
|
||||
) ?? `Responsável #${entry.value}`,
|
||||
typeLabel: activityResponsibleTypeLabels[entry.relationTo],
|
||||
}))
|
||||
}
|
||||
|
||||
export const formatActivityWhenLabel = (startAt: string | null | undefined): string =>
|
||||
startAt ? formatBahiaDateTimeLabel(startAt) : 'Data a definir'
|
||||
|
||||
|
|
@ -107,7 +175,7 @@ export type ActivityListViewModel = {
|
|||
municipalityName: string | null
|
||||
locality: string | null
|
||||
locationLabel: string
|
||||
responsibleName: string | null
|
||||
responsibles: ActivityResponsibleDisplay[]
|
||||
taskProgress: { done: number; total: number }
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +198,7 @@ export const toActivityListViewModel = (activity: Activity): ActivityListViewMod
|
|||
municipalityName,
|
||||
locality: activity.locality ?? null,
|
||||
locationLabel: formatActivityLocationLabel({ municipalityName, locality: activity.locality }),
|
||||
responsibleName: relationshipName(activity.responsible),
|
||||
responsibles: mapActivityResponsibles(activity.responsible),
|
||||
taskProgress: {
|
||||
done: activity.taskDoneCount ?? 0,
|
||||
total: activity.taskTotal ?? 0,
|
||||
|
|
@ -150,9 +218,7 @@ export const activityFormSelect = {
|
|||
municipality: true,
|
||||
locality: true,
|
||||
organizations: true,
|
||||
advisors: true,
|
||||
responsible: true,
|
||||
leadership: true,
|
||||
tasks: true,
|
||||
} as const
|
||||
|
||||
|
|
@ -177,9 +243,7 @@ export type ActivityFormViewModel = {
|
|||
municipalityId: number | null
|
||||
locality: string | null
|
||||
organizationIDs: number[]
|
||||
advisorIDs: number[]
|
||||
responsible: { id: number; name: string; phone: string | null } | null
|
||||
leadership: { id: number; label: string } | null
|
||||
responsibles: ActivityResponsibleDisplay[]
|
||||
tasks: ActivityFormTaskViewModel[]
|
||||
}
|
||||
|
||||
|
|
@ -199,26 +263,7 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
|
|||
municipalityId: relationshipId(activity.municipality),
|
||||
locality: activity.locality ?? null,
|
||||
organizationIDs: relationshipIds(activity.organizations),
|
||||
advisorIDs: relationshipIds(activity.advisors),
|
||||
responsible: isPopulatedRelationship<Contact>(activity.responsible)
|
||||
? {
|
||||
id: activity.responsible.id,
|
||||
name: activity.responsible.name,
|
||||
phone: activity.responsible.phone ?? null,
|
||||
}
|
||||
: null,
|
||||
leadership: (() => {
|
||||
const leadershipId = relationshipId(activity.leadership)
|
||||
if (!leadershipId) return null
|
||||
if (isPopulatedRelationship(activity.leadership)) {
|
||||
const contact = activity.leadership.contact
|
||||
const contactName = isPopulatedRelationship<Contact>(contact)
|
||||
? contact.name
|
||||
: `Liderança #${leadershipId}`
|
||||
return { id: leadershipId, label: contactName }
|
||||
}
|
||||
return { id: leadershipId, label: `Liderança #${leadershipId}` }
|
||||
})(),
|
||||
responsibles: mapActivityResponsibles(activity.responsible),
|
||||
tasks: (activity.tasks ?? []).map((task) => ({
|
||||
id: task.id ?? null,
|
||||
title: task.title,
|
||||
|
|
@ -246,7 +291,6 @@ const activityDetailContextSelect = {
|
|||
municipality: true,
|
||||
locality: true,
|
||||
organizations: true,
|
||||
advisors: true,
|
||||
responsible: true,
|
||||
createdBy: true,
|
||||
updatedAt: true,
|
||||
|
|
@ -313,9 +357,7 @@ export type ActivityDetailViewModel = {
|
|||
locality: string | null
|
||||
locationLabel: string
|
||||
organizations: Array<{ id: number; name: string }>
|
||||
advisors: Array<{ id: number; name: string }>
|
||||
responsibleId: number | null
|
||||
responsibleName: string | null
|
||||
responsibles: ActivityResponsibleDisplay[]
|
||||
taskProgress: { done: number; total: number }
|
||||
tasks: ActivityTaskViewModel[]
|
||||
updates: ActivityUpdateViewModel[]
|
||||
|
|
@ -390,14 +432,7 @@ export const toActivityDetailViewModel = (
|
|||
isPopulatedRelationship<Organization>(organization),
|
||||
)
|
||||
.map(({ id, name }) => ({ id, name })) ?? [],
|
||||
advisors:
|
||||
activity.advisors
|
||||
?.filter((advisor): advisor is CampaignUser =>
|
||||
isPopulatedRelationship<CampaignUser>(advisor),
|
||||
)
|
||||
.map(({ id, name }) => ({ id, name })) ?? [],
|
||||
responsibleId: relationshipId(activity.responsible),
|
||||
responsibleName: relationshipName(activity.responsible),
|
||||
responsibles: mapActivityResponsibles(activity.responsible),
|
||||
taskProgress: {
|
||||
done: activity.taskDoneCount ?? 0,
|
||||
total: activity.taskTotal ?? 0,
|
||||
|
|
|
|||
|
|
@ -107,10 +107,9 @@ export {
|
|||
export {
|
||||
canCampaignUserRescheduleActivity,
|
||||
canCreateActivity,
|
||||
canCreateActivityAdvisors,
|
||||
canDeleteActivity,
|
||||
canManageActivityAdvisors,
|
||||
canReadActivity,
|
||||
canSetActivityResponsible,
|
||||
canSetActivityStatus,
|
||||
canSetActivitySystemField,
|
||||
canUpdateActivity,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
SKIP_CAMPAIGN_NOTIFICATION_CONTEXT_KEY,
|
||||
} from '@/lib/notificationContract'
|
||||
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { parseActivityResponsibleEntries } from '@/lib/schemas/activity'
|
||||
import type { MunicipalityUpdatePolarity } from '@/lib/schemas/municipalityUpdate'
|
||||
import { municipalityUpdatePolarityLabels } from '@/lib/schemas/municipalityUpdate'
|
||||
import { createCampaignNotifications } from '@/utilities/notification/createCampaignNotification'
|
||||
|
|
@ -161,7 +162,7 @@ export const notifyActivityNeedsAttention = async (
|
|||
title: string
|
||||
slug: string
|
||||
municipality: unknown
|
||||
advisors?: unknown
|
||||
responsible?: unknown
|
||||
},
|
||||
): Promise<void> => {
|
||||
if (shouldSkipCampaignNotification(req.context)) return
|
||||
|
|
@ -178,9 +179,15 @@ export const notifyActivityNeedsAttention = async (
|
|||
req,
|
||||
})
|
||||
|
||||
const advisorIDs = uniqueRelationshipIds(doc.advisors as readonly unknown[] | null | undefined)
|
||||
|
||||
const recipientIDs = [...new Set(advisorIDs)]
|
||||
// C90 — recipients are the staff members listed as responsible for the
|
||||
// commitment (leadership/dobradinha entries have no notification account).
|
||||
const recipientIDs = [
|
||||
...new Set(
|
||||
parseActivityResponsibleEntries(doc.responsible)
|
||||
.filter((entry) => entry.relationTo === 'campaignUser')
|
||||
.map((entry) => entry.value),
|
||||
),
|
||||
]
|
||||
|
||||
await createCampaignNotifications(
|
||||
req.payload,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ test.describe('Atividades — registro-fundação', () => {
|
|||
await page.getByLabel('Início *').fill('2026-08-15T10:00')
|
||||
await page.getByLabel('Município *').selectOption(String(municipality.id))
|
||||
|
||||
// C90 — the unified polymorphic selector replaces the old Contact
|
||||
// responsável + assessores/liderança fields.
|
||||
await expect(page.getByRole('button', { name: /Responsáveis: Nenhum/ })).toBeVisible()
|
||||
await expect(page.getByText('Assessores responsáveis')).toHaveCount(0)
|
||||
|
||||
await page.getByRole('button', { name: 'Adicionar demanda' }).click()
|
||||
await page.getByLabel('Título da demanda 1').fill(transportDemand)
|
||||
await page.getByLabel('Tipo da demanda 1').selectOption('transporte')
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
loadActivityAgendaEventsRecord,
|
||||
rescheduleActivityRecord,
|
||||
} from '@/app/(campaign)/campanha/actions/activity'
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
|
||||
activityCreateSchema,
|
||||
|
|
@ -28,7 +29,7 @@ import {
|
|||
getAccessibleLeadershipIds,
|
||||
} from '@/utilities/campaignAccess'
|
||||
|
||||
import { installCampaignFixtures, relationId, relationIds } from '../helpers/campaignFixtures'
|
||||
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
/** Create data with server-derived fields (e.g. `slug`) intentionally omitted. */
|
||||
|
|
@ -246,9 +247,10 @@ describe('activity domain', () => {
|
|||
collection: 'activity',
|
||||
data: stub<ActivityCreateData>({
|
||||
...validActivityInput(municipality.id),
|
||||
advisors: [advisor.id],
|
||||
leadership: leadership.id,
|
||||
responsible: contact.id,
|
||||
responsible: [
|
||||
{ relationTo: 'campaignUser', value: advisor.id },
|
||||
{ relationTo: 'leadership', value: leadership.id },
|
||||
],
|
||||
createdBy: coordinator.id,
|
||||
}),
|
||||
overrideAccess: true,
|
||||
|
|
@ -264,14 +266,20 @@ describe('activity domain', () => {
|
|||
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
|
||||
})
|
||||
expect(advisorRead).toEqual({
|
||||
or: [{ advisors: { contains: advisor.id } }, { municipality: { in: [municipality.id] } }],
|
||||
or: [
|
||||
{ responsible: { equals: { relationTo: 'campaignUser', value: advisor.id } } },
|
||||
{ municipality: { in: [municipality.id] } },
|
||||
],
|
||||
})
|
||||
|
||||
const otherAdvisorRead = await canReadActivity({
|
||||
req: stub<PayloadRequest>({ user: otherAdvisor, payload, context: {} }),
|
||||
})
|
||||
expect(otherAdvisorRead).toEqual({
|
||||
or: [{ advisors: { contains: otherAdvisor.id } }, { municipality: { in: [] } }],
|
||||
or: [
|
||||
{ responsible: { equals: { relationTo: 'campaignUser', value: otherAdvisor.id } } },
|
||||
{ municipality: { in: [] } },
|
||||
],
|
||||
})
|
||||
|
||||
const leaderRead = await canReadActivity({
|
||||
|
|
@ -289,7 +297,10 @@ describe('activity domain', () => {
|
|||
req: stub<PayloadRequest>({ user: advisor, payload, context: {} }),
|
||||
})
|
||||
expect(advisorUpdate).toEqual({
|
||||
or: [{ advisors: { contains: advisor.id } }, { municipality: { in: [municipality.id] } }],
|
||||
or: [
|
||||
{ responsible: { equals: { relationTo: 'campaignUser', value: advisor.id } } },
|
||||
{ municipality: { in: [municipality.id] } },
|
||||
],
|
||||
})
|
||||
|
||||
const visibleToAdvisor = await payload.find({
|
||||
|
|
@ -321,7 +332,102 @@ describe('activity domain', () => {
|
|||
).rejects.toThrow(/permissão/i)
|
||||
})
|
||||
|
||||
it('auto-includes the creating advisor in advisors', async () => {
|
||||
it('grants an advisor access to an activity they are responsible for outside their portfolio (C90 polymorphic leg)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const [ownMunicipality, otherMunicipality] = await Promise.all([
|
||||
fixtures.getMunicipality(),
|
||||
fixtures.getMunicipality(),
|
||||
])
|
||||
await fixtures.assignMunicipalityAdvisors(ownMunicipality, [advisor])
|
||||
|
||||
const visibleByResponsibility = await payload.create({
|
||||
collection: 'activity',
|
||||
data: stub<ActivityCreateData>({
|
||||
...validActivityInput(otherMunicipality.id),
|
||||
title: fixtures.value('Fora do portfólio, mas responsável'),
|
||||
responsible: [{ relationTo: 'campaignUser', value: advisor.id }],
|
||||
}),
|
||||
overrideAccess: true,
|
||||
})
|
||||
fixtures.own('activity', visibleByResponsibility.id)
|
||||
|
||||
const invisible = await payload.create({
|
||||
collection: 'activity',
|
||||
data: stub<ActivityCreateData>({
|
||||
...validActivityInput(otherMunicipality.id),
|
||||
title: fixtures.value('Fora do portfólio e sem o assessor'),
|
||||
}),
|
||||
overrideAccess: true,
|
||||
})
|
||||
fixtures.own('activity', invisible.id)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'activity',
|
||||
where: { id: { in: [visibleByResponsibility.id, invisible.id] } },
|
||||
user: advisor,
|
||||
overrideAccess: false,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
// The polymorphic `responsible` object-notation equals is the exact leg
|
||||
// that keeps the advisor's right when the municipality scope does not.
|
||||
expect(result.docs.map((doc) => doc.id)).toEqual([visibleByResponsibility.id])
|
||||
})
|
||||
|
||||
it('lets a responsible advisor re-save an activity whose leadership they cannot read (C90 existence validation)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const foreignMunicipality = await fixtures.getMunicipality()
|
||||
const foreignLeadership = await fixtures.createLeadership({
|
||||
contact: (await fixtures.createContact()).id,
|
||||
municipalities: [foreignMunicipality.id],
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
|
||||
const activity = await payload.create({
|
||||
collection: 'activity',
|
||||
data: stub<ActivityCreateData>({
|
||||
...validActivityInput(foreignMunicipality.id),
|
||||
title: fixtures.value('Atividade que o assessor precisa editar'),
|
||||
responsible: [
|
||||
{ relationTo: 'campaignUser', value: advisor.id },
|
||||
{ relationTo: 'leadership', value: foreignLeadership.id },
|
||||
],
|
||||
}),
|
||||
overrideAccess: true,
|
||||
})
|
||||
fixtures.own('activity', activity.id)
|
||||
|
||||
// The advisor has no portfolio in the foreign municipality, so the
|
||||
// leadership is outside their read scope — but they are responsible and
|
||||
// re-submitting the intact list must not dead-end the save.
|
||||
const updated = await payload.update({
|
||||
collection: 'activity',
|
||||
id: activity.id,
|
||||
data: {
|
||||
description: 'Ajuste do assessor responsável.',
|
||||
responsible: [
|
||||
{ relationTo: 'campaignUser', value: advisor.id },
|
||||
{ relationTo: 'leadership', value: foreignLeadership.id },
|
||||
],
|
||||
},
|
||||
user: advisor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
expect(updated.description).toBe('Ajuste do assessor responsável.')
|
||||
const persisted = await payload.findByID({
|
||||
collection: 'activity',
|
||||
id: activity.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const responsibleIDs = (persisted.responsible ?? []).map((entry) => relationshipId(entry.value))
|
||||
expect(responsibleIDs).toEqual(expect.arrayContaining([advisor.id, foreignLeadership.id]))
|
||||
})
|
||||
|
||||
it('auto-includes the creating advisor as responsible', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
|
@ -338,7 +444,8 @@ describe('activity domain', () => {
|
|||
})
|
||||
fixtures.own('activity', activity.id)
|
||||
|
||||
expect(relationIds(activity.advisors)).toEqual([advisor.id])
|
||||
const responsibleIDs = (activity.responsible ?? []).map((entry) => relationId(entry.value))
|
||||
expect(responsibleIDs).toEqual([advisor.id])
|
||||
expect(relationId(activity.createdBy)).toBe(advisor.id)
|
||||
})
|
||||
|
||||
|
|
@ -537,7 +644,7 @@ describe('activity domain', () => {
|
|||
const contact = await fixtures.createContact()
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
|
||||
const leadership = await fixtures.createLeadership({
|
||||
await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
municipalities: [municipality.id],
|
||||
user: leaderAccount.id,
|
||||
|
|
@ -549,8 +656,7 @@ describe('activity domain', () => {
|
|||
data: stub<ActivityCreateData>({
|
||||
...validActivityInput(municipality.id),
|
||||
title: fixtures.value('Atividade Tarefas'),
|
||||
advisors: [advisor.id],
|
||||
leadership: leadership.id,
|
||||
responsible: [{ relationTo: 'campaignUser', value: advisor.id }],
|
||||
tasks: [{ title: 'Levar faixas', done: false }],
|
||||
}),
|
||||
overrideAccess: true,
|
||||
|
|
|
|||
|
|
@ -93,13 +93,11 @@ describe('buildActivityListWhere (tab → where matrix)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('filters by title or responsible name when q is set', () => {
|
||||
it('filters by title when q is set (C90 — responsible is polymorphic, title-only)', () => {
|
||||
const where = buildActivityListWhere({ page: 1, tab: 'proximos', q: 'Maria' }, NOW)
|
||||
expect(where).toEqual({
|
||||
and: [
|
||||
{
|
||||
or: [{ title: { contains: 'Maria' } }, { 'responsible.name': { contains: 'Maria' } }],
|
||||
},
|
||||
{ title: { contains: 'Maria' } },
|
||||
{ status: { equals: 'confirmado' } },
|
||||
{ startAt: { greater_than_equal: NOW.toISOString() } },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -383,6 +383,7 @@ describe('src/utilities top-level is pinned', () => {
|
|||
'activityLeadershipOptions.ts',
|
||||
'activityPageData.ts',
|
||||
'activityRelationOptions.ts',
|
||||
'activityResponsibleSearch.ts',
|
||||
'activityUi.ts',
|
||||
'activityViewModels.ts',
|
||||
'advisorData.ts',
|
||||
|
|
@ -598,7 +599,7 @@ describe('admin bypasses carry a justification comment', () => {
|
|||
['src/utilities/access/municipalities.ts', 3],
|
||||
['src/utilities/access/shared.ts', 2],
|
||||
['src/utilities/access/webauthnCredentials.ts', 1],
|
||||
['src/collections/Activity.ts', 1],
|
||||
['src/collections/Activity.ts', 0],
|
||||
['src/collections/CampaignDemand.ts', 1],
|
||||
['src/collections/CampaignUser.ts', 4],
|
||||
['src/collections/CampaignWebAuthnCredential.ts', 1],
|
||||
|
|
|
|||
Loading…
Reference in a new issue