Merge pull request #687 from fsolla/C124-atividades-tarefa-so-com-texto-responsavel-e-concluida-

feat(C124): tarefa de atividade só com texto, responsável e concluída (sem prazo)
This commit is contained in:
Francisco Solla 2026-08-11 19:25:33 +00:00 committed by GitHub
commit e68e4f6eda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 13597 additions and 51 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-11):** **C124** (Issue #668) — tarefa de atividade fica só com **texto, responsável e concluída**: o campo `due` (Prazo, datetime) sai do modelo e da UI. Remoção em cadeia num único commit simétrico: campo do array `tasks` da `activity` (o guardrail de liderança perde a comparação de `due` no mesmo item — continua fail-closed sobre título/responsável/contagem), `taskSchema` zod, `parseTasksFormData`/`ParsedActivityTask` (formData), `ActivityFormTaskViewModel`/`ActivityTaskViewModel`, input `datetime-local` do `ActivityTaskFields` (grid `2fr_1fr`) e linha "Prazo: …" do `ActivityTaskChecklist` (subtítulo condicional + `break-words` no título — P2 dos reviewers). `doneAt` ("concluída em") permanece como derivado invisível; `taskTotal`/`taskDoneCount` intocados. **Migration** `20260811_183016_drop_activity_task_due` (gerada; diff do gerador tinha no-op de `google_calendar_sync` por snapshot stale — limpa à mão para só o `DROP COLUMN "due"` de `activity_tasks`; dados descartados conforme decidido no gate). Grep pós: zero `due` fora das migrations (histórico frozen); nenhum consumidor fora da vertical (notificações, Google sync, quick actions). Gates: unit+int 736/736, e2e atividade 9/9 (1 flake ECONNRESET no run paralelo — rerun limpo), build OK. Intenção [`atividades-tarefas-sem-prazo.md`](plans/atividades-tarefas-sem-prazo.md); impl [`atividades-tarefas-sem-prazo-impl.md`](plans/atividades-tarefas-sem-prazo-impl.md).
**Recently resolved (2026-08-11):** **B197+** (Issue #642) — o menu do picker de colunas de assessores nasce da **mesma fonte** que os `<th>` do `AdvisorsTable` (fim do espelho): o módulo client declara `advisorTableColumns` (id/label/mandatory + `headerClassName` + `visuallyHiddenLabel` para o `sr-only` de Ações) e exporta `advisorPickerColumns = toCampaignColumnPickerColumns(...)` (precedente `supporterPickerColumns`); o header renderiza por `resolveVisibleColumns(advisorTableColumns, hiddenColumnIds)` — mesmo helper B17 das outras listas — e `emailHidden` deriva do mesmo `visibleColumns`. **Descoberta do /simplify incorporada:** o corpo só condiciona a coluna `email` (células à mão + exceção do draft), então `phone`/`municipalities`/`actions` são `mandatory: true` — esconder um deles (cookie velho do toggle inerte do B197) derrubaria o `<th>` sobre um corpo de 5 células; os checkboxes viram "sempre visível" (honestos). `assessores/page.tsx` consome a fonte exportada e perdeu o array espelho + imports órfãos. Comportamento da tabela idêntico (ids/labels/ordem, exceção do draft intacta); e2e `campaignColumnPicker` ganhou pin dos mandatory. Sem migration/access/Consent/UI/cache. Observações de ambiente (não da mudança): o e2e do picker falha no harness dev sob carga desta máquina (painel do popover não monta; falha idêntica na base e em lideranças — intocado; conjunto falho deriva com a carga; verificado funcional em Chromium real); warning React de key pré-existente no assessores (`CampaignListOmnibox` — base também). Intenção [`escala-dry-pos-b197.md`](plans/escala-dry-pos-b197.md); impl [`escala-dry-pos-b197-impl.md`](plans/escala-dry-pos-b197-impl.md).
**Recently resolved (2026-08-11):** **C116** (Issue #655) — `/campanha/pessoas` desktop vira **edit-where-you-see**: toda célula (exceto Ações) é um input permanente sem destaque — salva no blur/Enter, Esc descarta, vazio mostra "—" apagado. **Nome** mantém o valor como link para a ficha (`/campanha/liderancas/[id]`) com o mecanismo travado no gate: input invisível (`text-transparent`, z-0) sobre o link (z-10, `pointer-events-auto`, `w-auto` — cobre só o texto; o resto da célula é a área clicável do input), draft renderizado **no texto do link** (o valor real do input nunca é o display), clique no link navega, teclado/área fora do texto edita. **Células de municípios** (Assessora, Lidera, Aliada em) e **Assessorado** usam chips internos com: grupo novo **"Salvador (19)"** (`buildMunicipalityPortfolioChips` ganha kind `city`, checado antes do território — senão as 19 ZEs colapsariam como "Metropolitano de Salvador"), **chip batch clicável expande in loco** (estado de apresentação do wrapper `PeopleMunicipalityCell` — clique expande, mouse-leave colapsa se todos os membros seguem atribuídos; X num membro remove e impede o recolapso), **"+X"** no toggle de overflow do B170, `quiet` (sem tint de hover/foco; indicador de foco discreto só no input), commit por **match exato** no Enter/espaço (digitar "salvador-ze-2"+Enter adiciona a ZE certa, não a pre-selecionada — regra da intenção). Escrita: `updatePersonContactRecord` (escopo "edita o que vê" — assessor só com entidade da pessoa na carteira; `contactFieldUpdateSchema` ganhou variante `city`, convivendo com `phones` do C112), `setPersonAssessoraMembershipRecord` (unrestricted-only, single-account — multi-conta nega com mensagem segura e célula read-only; toggle extraído para `toggleMunicipalityAdvisorMembership` em `municipality.ts`, reusado pelo record B27) e `setPersonAdvisorMembershipRecord` (union person-centric: delta de assessor nas DUAS entidades quando ambas existem). Matriz por papel na página: assessor edita Lidera/Aliada com `addableIds`=carteira e **read-only** em Assessora/Assessorado (regras das collections donas); tooltips de label (WhatsApp/Convidar/Apagar) via `CampaignHoverTooltip`; `CampaignListSheetProvider` na tabela (guard miss #52 — wrapper de composição registrado no `sharedSheetHostConventions`). Mobile intacto. Sem migration. Testes: unit (grupo Salvador + expand + match exato; guard do sheet host), int novo `campaignPersonCellEditing` (11 — escopo por papel, assessora single/multi/sem-conta, union nas duas entidades), e2e `campaignPeople` 4/4. Intenção [`pessoas-edicao-inplace-lista.md`](plans/pessoas-edicao-inplace-lista.md); impl [`pessoas-edicao-inplace-lista-impl.md`](plans/pessoas-edicao-inplace-lista-impl.md).

View file

@ -0,0 +1,73 @@
# Impl: C124 — Atividades: tarefa só com texto, responsável e concluída (sem data/hora)
Status: aprovado
Atualizado em: 2026-08-11
Issue: #668
Intenção: docs/plans/atividades-tarefas-sem-prazo.md
Appetite restante: herdado (~0,5–1 dia eng; mudança é remoção pura, sem migração de dados)
## Leitura da intenção
- **Outcome:** tarefa de atividade passa a ter exatamente três controles — título (texto), responsável (pessoa) e "concluída" (checkbox); o prazo (data/hora) sai do schema, da UI e do guardrail de liderança; `doneAt` permanece.
- **O que NÃO negociar:** guardrail de liderança fail-closed (só marca concluída, comparação perde `due`); dados existentes com prazo são **descartados** (drop da coluna, decidido no gate); anti-goal: prazo não migra para outro campo.
- **O que reavaliar:** a hipótese de direção da intenção estava completa — confirmei por grep que `due` de tarefa só existe em 6 arquivos (collections, zod, formData, viewModels, 2 componentes) + payload-types gerado + migration antiga (frozen). Nenhum consumidor fora da vertical de atividades (notificações, Google sync, activityUi, quick actions: nenhum lê `task.due`).
## Abordagem recomendada
```mermaid
flowchart LR
A[Activity.ts<br/>campo due sai do array tasks<br/>guardrail líder perde comparação due] --> M[migration:<br/>DROP COLUMN due<br/>em activity_tasks]
C[lib/schemas/activity.ts<br/>taskSchema: due sai] --> D[activityFormData.ts<br/>ParsedActivityTask + parsing due saem]
D --> E[activityViewModels.ts<br/>due sai dos dois view models]
E --> F[ActivityTaskFields.tsx<br/>input datetime sai<br/>grid 2fr_1fr]
E --> G[ActivityTaskChecklist.tsx<br/>linha Prazo sai]
M --> T[generate:types<br/>payload-types refresca]
T --> G2[Gates: tsc lint format knip cycles unit int e2e build]
```
**Opções consideradas:** A) remoção completa (campo + coluna + UI) · B) manter coluna morta (`due` nullable, nunca escrita) · C) manter campo no schema, só esconder da UI
**Recomendação:** A — a intenção e o gate decidiram "descartar e remover"; remover na origem elimina a coluna fantasma, o peso no guardrail e a tentação de re-exibir. Zero risco de perda: nenhum consumidor lê o valor (verificado por grep em `src/` e `tests/`), e dado de campanha interno sem valor operacional.
**Rejeitadas:** B (anti-goal "limpeza real, sem coluna fantasma" — matéria do gate) · C (mantém API e guardrail poluídos com campo morto; anti-goal: campo sai do modelo, não só da UI).
### Componentes / mudanças
- **`Activity`** (`src/collections/Activity.ts`): remove o field `due` do array `tasks` (linhas 575–583); remove `(taskRecord.due ?? null) !== (previousTask.due ?? null)` do guardrail de liderança em `deriveActivityFields` (linha 361) — mesmo item, senão a comparação lê propriedade inexistente e o fail-closed perde a cobertura. `doneAt`/`taskTotal`/`taskDoneCount` intocados.
- **`src/lib/schemas/activity.ts`**: `taskSchema` perde `due: z.string().datetime().optional().nullable()` (linha 74). Types `ActivityCreateInput`/`ActivityUpdateInput` derivam de zod — atualizam sozinhos.
- **`src/utilities/activityFormData.ts`**: `ParsedActivityTask` perde `due?: string`; `parseTasksFormData` perde o parsing/validação de prazo (linhas 81–85) e a propagação `...(due ? { due } : {})` (linha 91). O resto do parse fica intacto.
- **`src/utilities/activityViewModels.ts`**: `ActivityFormTaskViewModel` e `ActivityTaskViewModel` perdem `due` (linhas 244, 346) e os mapeamentos (linhas 294, 395). `doneAt` permanece no checklist VM.
- **`ActivityTaskFields.tsx`**: `ActivityTaskFieldValue` e `initialTasks` perdem `due`; `serializeTasks` deixa de emitir `due`; estado inicial e `addTask` perdem o default; o input `datetime-local` do prazo sai; grid `sm:grid-cols-[2fr_1fr_1fr]` → `sm:grid-cols-[2fr_1fr]`; import `formatIsoAsBahiaDateTimeInput` sai.
- **`ActivityTaskChecklist.tsx`**: a entrada `Prazo: …` do subtítulo sai (linha 63); import `formatBahiaDateTimeLabel` sai (o helper continua usado em outras superfícies — ActivityCard/agenda — knip valida).
- **Migration:** `pnpm migrate:create drop_activity_task_due` — gera `ALTER TABLE "activity_tasks" DROP COLUMN IF EXISTS "due";` (+ down). A coluna é nullable e sem índice próprio, então o drop é barato. Aplica em prod via build (Vercel) — revisar o SQL gerado antes.
- **Access / Consent:** nenhuma mudança — mesmo access, mesma coleção, sem Consent.
- **UI:** Impeccable B — encaixe nas superfícies existentes; remoção pura, sem shape novo.
### Dados → forma (se aplicável)
- Não se aplica — sem dado novo para apresentar; o contador `taskDoneCount` segue exatamente como está.
## Fases verificáveis
1. **Schema + server** — `Activity.ts` (campo + guardrail), `taskSchema` zod, `parseTasksFormData`. Verificar: `tsc --noEmit` + int spec `campaignActivity.int.spec.ts` (cobre o guardrail de liderança com `tasks` sem `due`).
2. **UI + view models** — `activityViewModels.ts`, `ActivityTaskFields.tsx`, `ActivityTaskChecklist.tsx`. Verificar: `tsc`, `lint`, unit specs de UI (activity\*) e `knip` (orfandade de helpers).
3. **Migration + tipos** — `pnpm migrate:create drop_activity_task_due`, revisar SQL, `pnpm migrate` local (teqo_wt<slot>), `pnpm generate:types`. Verificar: `migrate:status` nas duas DBs (dev + test).
4. **Gates** — `tsc --noEmit`, `lint` (0 warnings), `format:check`, `knip`, `check:cycles`, `pnpm test`, `pnpm test:e2e` (specs de atividade: `campaignActivity`, `campaignAgenda*`), `pnpm build` contra DB local.
## Rabbit holes / Não escopo (engenharia)
- **Migração de conteúdo (preservar `due` em outra coluna):** corte — nada consome; drop limpo.
- **Prazo em demandas/atividades em geral:** fora de escopo (não criar equivalente).
- **Tocar `doneAt`/`taskDoneCount`:** fora de escopo.
- **C123 (overlay da agenda):** roda depois, nasce já com o modelo simplificado — nada a preparar aqui além do modelo limpo.
## Riscos e mitigação
- **Guardrail de liderança quebrado ao mudar o shape:** mitigado — a comparação de `due` é removida no mesmo commit do field; o int spec `denies leaders from updating activities` e o de toggle de tarefa da liderança continuam exercitando o caminho fail-closed.
- **Migração destrutiva em prod (via build):** coluna nullable, dados já sem consumidor (grep `src/`+`tests/`); SQL revisado no passo 3 antes de qualquer deploy.
- **`payload-types.ts` desatualizado no meio do caminho:** `due` some do tipo gerado no passo 3 (`generate:types`) — até lá `tsc` pega qualquer uso remanescente.
- **Serialização client × parse server fora de sincronia:** `serializeTasks` para de emitir `due` e o parse para de ler — mudança simétrica num único commit; nenhum cliente antigo persiste (formas stateless).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: 3 controles por tarefa; checklist sem "Prazo: …"; schema sem prazo; dados descartados; guardrail de liderança intacto; `doneAt` permanece
- [ ] Invariantes AGENTS/engineering-standards: sem toque em access/Consent; transações intactas; copy pt-BR; identificadores em inglês
- [ ] Testes de domínio previstos: int spec existente do guardrail continua verde (sem novo teste — remoção pura, superfície coberta)

View file

@ -357,8 +357,7 @@ const deriveActivityFields: CollectionBeforeChangeHook = ({
const taskRecord = task as Record<string, unknown>
if (
trimmedText(taskRecord.title) !== trimmedText(previousTask.title) ||
relationshipId(taskRecord.responsible) !== relationshipId(previousTask.responsible) ||
(taskRecord.due ?? null) !== (previousTask.due ?? null)
relationshipId(taskRecord.responsible) !== relationshipId(previousTask.responsible)
) {
throw new APIError('Lideranças só podem marcar tarefas como concluídas.', 403)
}
@ -571,16 +570,6 @@ export const Activity: CollectionConfig = {
relationTo: 'contact',
label: 'Responsável',
},
{
name: 'due',
type: 'date',
label: 'Prazo',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'done',
type: 'checkbox',

View file

@ -5,7 +5,6 @@ import { toast } from 'sonner'
import { toggleActivityTaskAction } from '@/app/(campaign)/campanha/(app)/atividades/[slug]/taskActions'
import { Checkbox } from '@/components/ui/Checkbox'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import type { ActivityTaskViewModel } from '@/utilities/activityViewModels'
type ActivityTaskChecklistProps = {
@ -53,18 +52,15 @@ export const ActivityTaskChecklist = ({ activityId, tasks }: ActivityTaskCheckli
aria-label={`Marcar tarefa "${task.title}" como ${task.done ? 'pendente' : 'concluída'}`}
className="mt-0.5"
/>
<div className="flex flex-1 flex-col gap-0.5">
<span className={task.done ? 'line-through text-muted-foreground' : undefined}>
<div className="min-w-0 flex-1 flex-col gap-0.5">
<span
className={`break-words ${task.done ? 'line-through text-muted-foreground' : ''}`}
>
{task.title}
</span>
<span className="text-xs text-muted-foreground">
{[
task.responsibleName ? `Resp: ${task.responsibleName}` : null,
task.due ? `Prazo: ${formatBahiaDateTimeLabel(task.due)}` : null,
]
.filter(Boolean)
.join(' · ')}
</span>
{task.responsibleName ? (
<span className="text-xs text-muted-foreground">Resp: {task.responsibleName}</span>
) : null}
</div>
</li>
))}

View file

@ -10,13 +10,11 @@ import {
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime'
export type ActivityTaskFieldValue = {
key: string
title: string
responsible: ContactComboboxOption | null
due: string
done: boolean
}
@ -25,7 +23,6 @@ type ActivityTaskFieldsProps = {
id: string | null
title: string
responsible: ContactComboboxOption | null
due: string | null
done: boolean
}>
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
@ -45,7 +42,6 @@ const serializeTasks = (tasks: ActivityTaskFieldValue[]) =>
.map((task) => ({
title: task.title.trim(),
...(task.responsible ? { responsible: task.responsible.id } : {}),
...(task.due ? { due: task.due } : {}),
done: task.done,
})),
)
@ -61,7 +57,6 @@ export const ActivityTaskFields = ({
key: task.id ?? nextTaskKey(),
title: task.title,
responsible: task.responsible,
due: task.due ? formatIsoAsBahiaDateTimeInput(task.due) : '',
done: task.done,
})),
)
@ -73,7 +68,7 @@ export const ActivityTaskFields = ({
const addTask = () => {
setTasks((current) => [
...current,
{ key: nextTaskKey(), title: '', responsible: null, due: '', done: false },
{ key: nextTaskKey(), title: '', responsible: null, done: false },
])
}
@ -91,7 +86,7 @@ export const ActivityTaskFields = ({
key={task.key}
className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-start"
>
<div className="grid flex-1 gap-3 sm:grid-cols-[2fr_1fr_1fr]">
<div className="grid flex-1 gap-3 sm:grid-cols-[2fr_1fr]">
<Input
aria-label={`Título da tarefa ${index + 1}`}
placeholder="Título da tarefa"
@ -106,13 +101,6 @@ export const ActivityTaskFields = ({
search={searchContacts}
onChange={(contact) => updateTask(task.key, { responsible: contact })}
/>
<Input
aria-label={`Prazo da tarefa ${index + 1}`}
type="datetime-local"
value={task.due}
className="min-h-11"
onChange={(event) => updateTask(task.key, { due: event.target.value })}
/>
</div>
<Button
type="button"

View file

@ -71,7 +71,6 @@ export type ActivityDemandDraft = z.input<typeof activityDemandDraftSchema>
const taskSchema = z.object({
title: z.string().trim().min(1).max(200),
responsible: positiveRelationshipId.optional(),
due: z.string().datetime().optional().nullable(),
done: z.boolean().optional(),
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
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_tasks" DROP COLUMN "due";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "activity_tasks" ADD COLUMN "due" timestamp(3) with time zone;`)
}

View file

@ -50,6 +50,7 @@ import * as migration_20260810_010844_add_activity_all_day from './20260810_0108
import * as migration_20260811_111828_add_contact_phones_array from './20260811_111828_add_contact_phones_array';
import * as migration_20260811_133332_add_google_calendar_sync from './20260811_133332_add_google_calendar_sync';
import * as migration_20260811_155116_add_lembranca_support_status from './20260811_155116_add_lembranca_support_status';
import * as migration_20260811_183016_drop_activity_task_due from './20260811_183016_drop_activity_task_due';
export const migrations = [
{
@ -312,4 +313,9 @@ export const migrations = [
down: migration_20260811_155116_add_lembranca_support_status.down,
name: '20260811_155116_add_lembranca_support_status',
},
{
up: migration_20260811_183016_drop_activity_task_due.up,
down: migration_20260811_183016_drop_activity_task_due.down,
name: '20260811_183016_drop_activity_task_due'
},
];

View file

@ -780,7 +780,6 @@ export interface Activity {
| {
title: string;
responsible?: (number | null) | Contact;
due?: string | null;
done?: boolean | null;
doneAt?: string | null;
id?: string | null;
@ -1847,7 +1846,6 @@ export interface ActivitySelect<T extends boolean = true> {
| {
title?: T;
responsible?: T;
due?: T;
done?: T;
doneAt?: T;
id?: T;

View file

@ -31,7 +31,6 @@ import { MAX_TOUR_NAME_LENGTH, TOUR_EMPTY_MESSAGE } from '@/utilities/visit/visi
type ParsedActivityTask = {
title: string
responsible?: number
due?: string
done?: boolean
}
@ -78,17 +77,11 @@ const parseTasksFormData = (formData: FormData): ParsedActivityTask[] => {
record.responsible > 0
? record.responsible
: undefined
const dueRaw = typeof record.due === 'string' ? record.due : undefined
const due = dueRaw ? (parseBahiaDateTimeInput(dueRaw) ?? undefined) : undefined
if (dueRaw && !due) {
throw new FormDataBoundaryError('tasksJson', `Prazo inválido na tarefa ${index + 1}.`)
}
const done = typeof record.done === 'boolean' ? record.done : undefined
return {
title,
...(responsible ? { responsible } : {}),
...(due ? { due } : {}),
...(done !== undefined ? { done } : {}),
}
})

View file

@ -241,7 +241,6 @@ type ActivityFormTaskViewModel = {
id: string | null
title: string
responsible: { id: number; name: string; phone: string | null } | null
due: string | null
done: boolean
}
@ -291,7 +290,6 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
phone: primaryPhoneOf(task.responsible.phones),
}
: null,
due: task.due ?? null,
done: Boolean(task.done),
})),
})
@ -343,7 +341,6 @@ export type ActivityTaskViewModel = {
title: string
responsibleId: number | null
responsibleName: string | null
due: string | null
done: boolean
doneAt: string | null
}
@ -392,7 +389,6 @@ const mapActivityTasks = (activity: Activity): ActivityTaskViewModel[] =>
title: task.title,
responsibleId: relationshipId(task.responsible),
responsibleName: relationshipName(task.responsible),
due: task.due ?? null,
done: Boolean(task.done),
doneAt: task.doneAt ?? null,
}))