diff --git a/docs/changelog/2026-09-29-c236.md b/docs/changelog/2026-09-29-c236.md new file mode 100644 index 00000000..7fea3305 --- /dev/null +++ b/docs/changelog/2026-09-29-c236.md @@ -0,0 +1 @@ +**Central de Conteúdos — publicar, despublicar e apagar em lote (2026-09-29):** o kill switch morava ficha a ficha e apagar era uma confirmação por linha; o uso real chega em blocos. A lista `/campanha/comunicacao/conteudos` ganhou um modo de seleção opt-in (checkbox no topo da lista, coluna `mandatory` fora do seletor de colunas, tint `primary/5` na linha) que envolve os dois corpos da lista — tabela desktop e card list mobile — com **um** estado de seleção client; a seleção é só da página visível, morre ao trocar de página/filtro (o provider é chaveado pela URL canônica) e ids que saem da página deixam de contar. Com seleção ativa a barra flutua no rodapé (`sticky`, 16px acima do FAB no mobile, recuo à direita no desktop) com `N selecionada(s)` e `Publicar`/`Despublicar`/`Apagar`: os reversíveis agem direto, o irreversível sempre confirma no `CampaignDeleteDialog` reusado via um caminho `onConfirm` aditivo — o aviso de links públicos aparece só quando a seleção carrega publicadas (singular de uma peça reusa a copy do C222). O lote é o gesto unitário repetido: `POST .../conteudos/lote` (`campaignJsonMutationRoute`, teto de 50 ids) itera server-side as ações donas, cada item na própria transação e no próprio gate, e devolve `{ affected, failures[] }` — a falha de uma peça nunca reverte as demais e o recibo inline (cena 5 do design) nunca diz "3 apagadas" com falha: `2 de 3 peças apagadas. 1 falhou.` (singular/plural no builder puro, `aria-live` no sucesso e `role="alert"` na parcial). Invariantes intocados: access fail-closed da vertical, sem routine por linha nova, sem `Consent`, sem migration, sem rota pública (S27), `ContentEvent` preservado e a semântica do C222 (slug/`publishedAt`/arquivo preservados) reusada literalmente. Cobertura: int do lote (papéis, dedupe, mídia, evento, tag, falha parcial, schema), unit do builder/barra/seleção (incl. card frame) e e2e HTTP do lote. **Design tier: DEGRADED** (artefato de origem sem certificação frontier) — a crítica do designer contra o app renderizado aplicou a adaptação do FAB/reserva de rolagem/clamp/a11y e a Issue foi flipada para `blocked` aguardando sign-off humano antes do PR (screenshots em `docs/plans/central-conteudos-acoes-em-lote-ui-critique/`). diff --git a/docs/plans/central-conteudos-acoes-em-lote-impl.md b/docs/plans/central-conteudos-acoes-em-lote-impl.md new file mode 100644 index 00000000..cc5caec2 --- /dev/null +++ b/docs/plans/central-conteudos-acoes-em-lote-impl.md @@ -0,0 +1,159 @@ +# Impl: C236 — Central de Conteúdos — publicar, despublicar e apagar em lote + +Status: aprovado (executado; entrega bloqueada aguardando sign-off do design DEGRADED) +Atualizado em: 2026-09-29 +Issue: #1388 +Intenção: docs/plans/central-conteudos-acoes-em-lote.md +Appetite restante: herdado (~1–1,5 dia eng) — o corte de produto (seleção só da página visível, sem job, sem agendamento, sem edição de campos) mantém o escopo dentro do appetite. + +## Leitura da intenção + +- **Outcome:** a assessoria seleciona várias peças **da página visível** na lista da Central (`/campanha/comunicacao/conteudos`) e aplica o **mesmo veredito unitário** — `Publicar`, `Despublicar` ou `Apagar` — num gesto, com recibo honesto (afetadas × falhas) e a lista refletindo o desfecho; a seleção morre ao trocar de página/filtro e pós-ação. +- **O que NÃO negociar:** o lote é o gesto unitário repetido, sem regra nova — elegibilidade `processando`/`falhou` incluída; `Publicar`/`Despublicar` agem sem confirmação (reversíveis) e `Apagar` sempre confirma avisando links públicos quando há publicadas; falha parcial é honesta e não reverte as demais; audiência `communicator|coordinator|candidate` (`advisor`/`leader` negados, fail-closed), sem papel novo; sem rota pública nova (S27), sem mudar a semântica do C222 (slug canônico/`publishedAt`/arquivo preservados no despublicar, mídia privada best-effort, `ContentEvent` nunca limpo); nenhum `Consent` novo. +- **O que reavaliar (hipóteses da intenção confirmadas no código):** o `CampaignTable` é server e não tem API de seleção (`CampaignTable.tsx:27-63,155-224`) — a seleção entra por ilha client, não por mudança de shell; a lista é renderizada 2× por breakpoint (card/tabela) — um único estado compartilhado via provider; a ação unitária já concentra semântica e transação (reuso literal, não reinterpretação). + +## Abordagem recomendada + +```mermaid +flowchart LR + Page["page.tsx (RSC)
conteudos"] --> Provider["ContentPieceSelectionProvider
(client: modo, ids, pending, receipt)"] + Provider --> Mode["ContentPieceSelectionModeControl"] + Provider --> Table["ContentPieceTable (RSC)
coluna seleção oculta fora do modo"] + Provider --> Cards["ContentPieceCardList (RSC)"] + Table --> Boxes["ContentPieceRowCheckbox /
ContentPieceSelectAllCheckbox"] + Cards --> Frame["ContentPieceCardSelectionFrame"] + Provider --> Bar["ContentPieceSelectionBar"] + Bar -->|Publicar / Despublicar| BatchRoute["POST .../conteudos/lote
campaignJsonMutationRoute"] + Bar --> DeleteDialog["CampaignDeleteDialog
(confirm = batch)"] + DeleteDialog -->|Apagar| BatchRoute + BatchRoute --> BatchAction["applyContentPieceBatchActionForActor
loop por item, transação por item"] + BatchAction --> PublishAction["setContentPiecePublishedForActor"] + BatchAction --> DeleteAction["deleteContentPieceForActor"] + BatchAction --> Outcome["outcome { action, affected, failures }"] + Outcome --> Receipt["ContentPieceBatchReceipt
(inline, status/alert)"] + Outcome --> Refresh["clear() + router.refresh()"] +``` + +**Opções consideradas:** A | B | C +**Recomendação:** **B** — uma rota única `POST .../conteudos/lote` que itera server-side reusando as ações unitárias, cada item na própria transação, e devolve um recibo estruturado. Reuso literal da semântica (slug/`publishedAt`/arquivo/mídia/tag), teto de lote fixado em schema (max 50, precedente `contentPieceStatusRequestSchema`), recibo e falha parcial testáveis no int, transporte único pelo wrapper `campaignJsonMutationRoute`. +**Rejeitadas:** **A** (cliente dispara N fetches unitários) porque espalha no cliente o laço, o recibo e a agregação de falhas, faz N round-trips e não fixa o teto do conjunto em nenhum contrato; **C** (transação única do lote) porque a intenção fixa falha parcial honesta — uma falha não pode reverter as demais —, prolongaria a transação e mudaria a semântica pós-commit da mídia privada. + +### Decisões de engenharia (caras de reverter) + +#### 1. Transporte do lote + +**Opções:** A) o cliente dispara N fetches às rotas unitárias existentes | B) rota nova única `POST .../conteudos/lote` com `{ action, contentPieceIds }` iterando server-side o reuso das ações unitárias | C) transação única para o lote. +**Recomendação:** **B** — a rota entra por `campaignJsonMutationRoute` (guard de convenção em `tests/unit/codebaseConventions.unit.spec.ts:251`), o schema fixa o teto (`.min(1).max(50)`), e a action `applyContentPieceBatchActionForActor` chama `setContentPiecePublishedForActor` / `deleteContentPieceForActor` **uma vez por id** — cada chamada mantém sua própria transação (`withPayloadTransaction`), o gate fresco e a limpeza de mídia pós-commit (`contentPieces.ts:163-189,239-278`). O recibo (`affected` + `failures[]`) nasce no domínio e é testável no int; o cliente só traduz para os literais do design. +**Alternativas rejeitadas:** A porque duplica no cliente o laço/agregação, multiplica round-trips (até 25) e não tem contrato de teto nem recibo de domínio; C porque contraria a decisão de produto de falha parcial honesta, cria transação longa e conflita com o `onPayloadTransactionCommit` da mídia por item. +**Detalhe de execução:** o loop é sequencial (determinístico, gentil com o banco; 25 itens máx. da página). Se a latência do lote doer (medida no e2e), o gatilho de revisitação é paralelizar com `Promise.allSettled` mantendo o mesmo outcome — barato e local. + +#### 2. Estado de seleção compartilhado (tabela + card list) + +**Opções:** A) um client provider via `children` (RSC preservado), com estado de ids, modo, pending e recibo, resetado por `key` da URL canônica da lista | B) Context API com row registry (cada linha registra/desregistra no provider) | C) estado no servidor via URL (`?sel=1,2,3`). +**Recomendação:** **A** — `ContentPieceSelectionProvider` (client) recebe uma projeção serializável das rows (`{ id, status, publicPath }`) e envolve os dois corpos renderizados pela RSC (`ContentPieceCardList` e `ContentPieceTable`), que continuam server; as ilhas de checkbox consomem o contexto. O provider é chaveado pela URL canônica da lista (`buildContentPieceListHref(data.state, data.state.page)`), então trocar página/filtro **remonta** e zera seleção, modo e recibo. Pós-ação, o provider chama `clear()` e `router.refresh()`. O poll de status (`ContentPieceStatusRefresher.tsx:48`) refresca sem mudar a key — a seleção sobrevive a uma renovação de status, que é o comportamento correto (os ids seguem válidos). +**Alternativas rejeitadas:** B porque é maquinaria de sincronização sem necessidade (as rows já chegam como prop) e dificulta "selecionar todas" e o aviso de publicadas; C porque contraria "a seleção morre ao navegar", polui o contrato da URL canônica com ordenação/limites e transforma cada checkbox numa navegação com re-render do servidor. + +#### 3. Coluna de seleção no `CampaignTable` server + +**Opções:** A) coluna definida sempre em `columns` com `mandatory: true`, head/cell como ilhas client que leem o contexto, e a coluna nascendo oculta por CSS (`data-selection-mode` no root do provider + variantes `group-data-[selection-mode=true]:table-cell`); a lista do column picker **não** ganha a coluna | B) checkbox permanente, sem modo | C) dar API de seleção ao `CampaignTable` (ou torná-lo client). +**Recomendação:** **A** — o shell da tabela não muda (`CampaignTable.tsx` segue server e data-driven); `mandatory: true` protege contra cookie antigo (`campaignColumnVisibility.ts:184-192`) e a coluna não entra no picker em `page.tsx:60-66` porque é chrome do modo de seleção, não dado. O tint da linha selecionada usa `rowClassName: 'has-[[data-selection-state=selected]]:bg-primary/5'` (o `has-[...]` já é usado em `ui/Table.tsx:57,67` e `ui/field.tsx`), e a ilha marca `data-selection-state` no seu root. +**Alternativas rejeitadas:** B porque contraria o design aprovado (repouso sem ruído) e a questão de produto A da intenção; C porque acopla um concern de uma superfície ao sistema de tabela compartilhado — a seleção é local da Central, não do shell (depth errado). + +#### 4. Barra de ações e confirmação de apagar + +**Opções:** A) barra client dentro do provider (`sticky bottom-4 z-20`, classes do artefato) e confirmação reusando `CampaignDeleteDialog` estendido de forma **aditiva** com um caminho `onConfirm`/`onSuccess` (union: `endpoint` XOR `onConfirm`) | B) criar um `ContentPieceBatchDeleteDialog` irmão, duplicando o chrome | C) montar o `AlertDialog` inline na barra. +**Recomendação:** **A** — a barra é a única dona do gesto (contagem, botões, pending, recibo) e o diálogo continua com **um só dono de chrome/estado** (`CampaignDeleteDialog.tsx:54-127`: ícone circular destrutivo, rodapé `muted/50`, `Cancelar` outline, `Apagar` vermelho cheio, erro dentro do diálogo, spinner/disabled). A extensão não toca os 3 call sites atuais (cut, recording, peça): `onConfirm: () => Promise<{ ok: true } | { ok: false; message: string }>` e `onSuccess?: () => void`; no caminho novo, o machine chama `onConfirm()` em vez do DELETE por endpoint e, no sucesso, mantém o `router.refresh()` e chama `onSuccess`. A barra computa título/descrição: `1` selecionada reusa o C222 (`Apagar esta peça?` + link nomeado quando publicado); `N > 1` usa `Apagar N peças selecionadas?` com o aviso de publicadas × só "não pode ser desfeita". +**Alternativas rejeitadas:** B porque duplica um machine extraído justamente para não ter a 4ª cópia (C183→C199→C222); C porque recopia chrome, submitting/erro/disabled que o machine já owns. + +#### 5. Recibo e estado de erro + +**Opções:** A) banner inline no topo da lista (ilha client no provider; `role="status"`/`aria-live` no sucesso, `role="alert"` na falha parcial) | B) toast sonner (`useCampaignFormSuccessToast`, layout `(campaign)/layout.tsx:52`) | C) mensagem dentro da barra. +**Recomendação:** **A** — o design do gate (cena 5) fixa o recibo inline no topo da lista, nunca sobre ela; o precedente sonner é de form action, não de lista, e um `role="alert"` de falha parcial precisa ficar no contexto da lista, não num canto transitório. Sucesso some sozinho (~6 s) ou no X; falha parcial/erro fica até fechar ou até a próxima ação. Os literais saem de um builder puro (singular/plural seguindo `TourComposerForm.tsx:180` e o literal do plano): `3 peças publicadas`, `1 peça despublicada`, `2 de 3 peças apagadas. 1 falhou.`; a UI não renderiza mensagem por peça (fora do design), mas o outcome carrega `failures[]` para teste e diagnóstico. Erro de rota no `Publicar`/`Despublicar` vira banner de erro e **mantém** a seleção (nada mudou); no `Apagar` o erro fica dentro do diálogo (como no C222). +**Alternativas rejeitadas:** B porque o design fixa o recibo inline e o toast some sozinho — a falha parcial não pode ser silenciosa; C porque a barra sai de cena quando a seleção zera e o recibo precisa sobreviver à limpeza da seleção. + +#### 6. Migração, access e Consent + +**Opções:** A) sem migration, reusando access existente e o gate fresco por ação | B) migration nova | C) mecanismo de consentimento/auditoria novo. +**Recomendação:** **A** — nenhuma coleção/campo muda (o lote só chama ações existentes), nenhuma `Consent` nova, nenhuma role nova. `applyContentPieceBatchActionForActor` repete o gate fresco (`canReadCommunicationCatalog`) **antes** do loop — role/auth negados falham a requisição inteira (400/401), não viram falha parcial — e cada item reusa a ação que re-checa o gate e termina na collection access (`canUpdateContentPiece`/`canDeleteContentPiece`, `utilities/access/contentPieces.ts:40-58`) como barreira final. +**Alternativas rejeitadas:** B por desnecessária (nenhum schema); C por sair do escopo e do invariante "nada de Consent novo". + +### Componentes / mudanças + +- **`ContentPieceSelectionProvider`** (`src/components/campaign/content/ContentPieceSelectionProvider.tsx`, novo): contexto client com `selectionMode`, `selectedIds` (`number[]` + `Set` memoizado, precedente `TourComposerForm.tsx:137-141`), `pendingAction`, `receipt`; ações `enter/exit/toggle/toggleAll/clear/applyBatch`; `resetKey` por `key`; transporta o lote com `postCampaignJson` (`lib/campaignJsonRequest.ts:12-26`) e orquestra `clear()` + `router.refresh()`. +- **`ContentPieceSelectionControls`** (`.../ContentPieceSelectionControls.tsx`, novo): ilhas `ContentPieceSelectionModeControl` (botão `Selecionar` × badge `Modo de seleção` + `Cancelar seleção`), `ContentPieceRowCheckbox` (tabela, com `data-selection-state`), `ContentPieceSelectAllCheckbox` (`indeterminate`), `ContentPieceCardSelectionFrame` (envelope client do card mobile com checkbox 44 px + `ring-1 ring-primary/25`; corpo do card continua RSC via `children`). +- **`ContentPieceSelectionBar`** (`.../ContentPieceSelectionBar.tsx`, novo): barra com `N selecionada(s)`, `Publicar`/`Despublicar`/`Apagar`, pending (`aria-busy`), consumindo o provider; monta o `CampaignDeleteDialog` do batch. +- **`ContentPieceBatchReceipt`** (`.../ContentPieceBatchReceipt.tsx`, novo): banner inline do recibo (cena 5 do artefato). +- **`CampaignDeleteDialog`** (`src/components/campaign/shared/CampaignDeleteDialog.tsx`): extensão aditiva do union `endpoint` XOR `onConfirm` + `onSuccess` (decisão 4); call sites atuais intocados. +- **`ContentPieceTable`** (`src/components/campaign/content/ContentPieceTable.tsx`): coluna `selection` `mandatory` com head/cell ilhas, CSS de modo, `rowClassName` de tint; nenhuma mudança no `CampaignTable`. +- **`ContentPieceCardList`** (`src/components/campaign/content/ContentPieceCardList.tsx`): card envolvido pelo frame client de seleção (sem mudar o conteúdo RSC). +- **`page.tsx`** (`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx`): provider em volta dos dois corpos + mode control + recibo + barra; projeção `selectionRows` e `resetKey` calculados da `data.state`; coluna `selection` fora do `toCampaignColumnPickerColumns`. +- **`contentPieceBatch`** (`src/lib/contentPieceBatch.ts`, novo): vocabulário `publicar|despublicar|apagar`, labels e builder puro do recibo (singular/plural/parcial). +- **Schema** (`src/lib/schemas/contentPiece.ts`): `contentPieceBatchRequestSchema` (`action` enum + `contentPieceIds` `.min(1).max(50)`, precedente `:133-135`). +- **Action** (`src/app/(campaign)/campanha/actions/contentPieces.ts`): `applyContentPieceBatchActionForActor` com gate fresco, loop sequencial, `mapCampaignFormActionError` + `CONTENT_PIECE_SAFE_MESSAGES` para mascarar falha por item. +- **Rota** (`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/lote/route.ts`, novo): `POST = campaignJsonMutationRoute(...)` devolvendo `{ status: 'success', outcome }`; convive com `[id]` como `enviar`/`status`/`link` já convivem. Sem rota DELETE nova. +- **Contratos/wire** (`.../conteudos/types.ts`): `ContentPieceBatchResponse`/`ContentPieceBatchOutcome`/`ContentPieceBatchFailure`; **paths** (`src/lib/campaignPaths.ts`): `CAMPAIGN_CONTENT_PIECE_BATCH_HREF`. +- **Migration:** N/A — nenhuma coleção/campo/índice muda; nenhuma migration criada (skill `payload-migrations` não é acionada). +- **Access / Consent:** sem mudança de access; sem `Consent` novo; fail-closed preservado. +- **UI:** Impeccable **B** — encaixe na lista existente. Port **classe-a-classe** do artefato DEGRADED `docs/plans/central-conteudos-acoes-em-lote-ui-design.html`; shells reusados: `CampaignPageShell`, `CampaignListPendingBoundary/Results`, `CampaignListFooter`, `CampaignTable`/`CampaignTableHead`, `AlertDialog`, `Button`, `Checkbox`, `Spinner`, badges. Classes-chave a preservar: barra `bar-shadow flex items-center justify-between gap-4 rounded-xl border border-border bg-card px-3 py-2.5` (mobile `p-3` com `grid grid-cols-3 gap-2`); contador `text-sm font-medium tabular-nums`; `Publicar` `bg-primary text-primary-foreground`, `Despublicar` outline `hover:bg-muted`, `Apagar` `bg-destructive/10 text-destructive hover:bg-destructive/20`; checkbox `size-4 rounded-[4px]` com hit-area e alvo `size-9` (tabela)/`size-11` (card); linha `bg-primary/5`; card `ring-1 ring-primary/25`; badge do modo `bg-primary/10 text-primary`; recibos `rounded-lg border` com check verde só no ícone e `border-destructive/40` na falha; `min-h-11` em todo alvo; `motion-reduce:animate-none` no spinner. **Sign-off humano obrigatório no PR** (design DEGRADED). +- **Testes:** int em `tests/int/contentPiece.int.spec.ts` (novo describe de lote), unit novos `tests/unit/contentPieceBatchReceipt.unit.spec.ts` e `tests/unit/contentPieceSelection.unit.spec.tsx` (template `contentPieceDeleteDialog.unit.spec.tsx`: mock de `next/navigation` + fetch), e2e no describe de content pieces de `tests/e2e/campaignSpeechAcervo.e2e.spec.ts`. + +### Dados → forma (se aplicável) + +- **Não se aplica** — a intenção fixa "não apresento dados": nenhum painel, métrica, dashboard ou telemetria. A forma do recibo (banner inline com contagens do outcome) é a da cena 5 do design; o número é o resultado da própria ação, não leitura agregada nova. + +## Fases verificáveis + +1. **Tracer / server (schema → action → rota)** — quota ~0,5 dia. `contentPieceBatchRequestSchema` + `contentPieceBatch` (builder do recibo) + `applyContentPieceBatchActionForActor` + `lote/route.ts` + `CAMPAIGN_CONTENT_PIECE_BATCH_HREF` + tipos do wire; int cobrindo matriz de papéis (communicator/coordinator/candidate/admin × advisor/leader), publicar/despublicar em lote preservando slug/`publishedAt`/arquivo, apagar em lote derrubando mídia (best-effort) e mantendo `ContentEvent`, falha parcial honesta (`affected` + `failures` com `NOT_FOUND`), teto/limite do schema e bust da tag `contentPieces`; e2e de rota (HTTP) com sucesso, falha parcial e negado. +2. **UI** — quota ~0,5–0,75 dia. Provider + mode control + ilhas de checkbox (tabela e card) + coluna `mandatory` com CSS de modo + tint + barra + extensão aditiva do `CampaignDeleteDialog` + recibo; unit de seleção (modo, contagem `1 selecionada`/`N selecionadas`, POST com `{ action, contentPieceIds }`, limpeza pós-ação, recibo de sucesso e parcial, diálogo plural/singular com aviso de publicadas, erro de rota mantendo seleção) e unit do builder do recibo; port classe-a-classe revisado contra o artefato. +3. **Gates** — `pnpm gate:fast`; push via `pnpm push` (o CI PR roda o cascade curado; o deploy roda a suíte full). Sign-off humano do design DEGRADED no PR. + +## Rabbit holes / Não escopo (engenharia) + +- Seleção cross-page/"todos os filtrados", dedupe, contagem global — corte explícito da intenção (só a página visível; 25). +- Seleção persistente em `localStorage`/cookie ou modo de seleção sobrevivendo à navegação — a key da URL zera tudo de propósito. +- Marcar as peças que falharam como "retry da seleção", progresso por item, fila/retomada/cancelamento — o recibo é uma frase, não um painel. +- Toast e banner ao mesmo tempo; auditoria/telemetria de quem agiu; undo/lixeira. +- Otimismo nas rows (atualizar a linha antes do refresh) — o refresh do RSC é a verdade; o estado de envio cobre o feedback. +- "Selecionar todas" também no card list mobile (o design não mostra; a barra mobile não ganha header de seleção). +- Adicionar API de seleção ao `CampaignTable` ou converter a tabela em client — o shell fica intocado. +- Migrations ("já que mexe na lista, ajusta X"), novos campos em `contentPiece`, `Consent` novo, mudar o gesto unitário (slug/`publishedAt`/arquivo/mídia/`ContentEvent`) ou as rotas públicas S27. +- Editar `docs/plans/central-conteudos-acoes-em-lote.md` (intenção) ou o design HTML. +- **Adiado com gatilho (triage /simplify):** a frase singular de apagar ("O link público `` deixa de funcionar… Esta ação não pode ser desfeita.") é paralela entre a barra de lote e o `ContentPieceDeleteDialog` (C222) — o dono único da copy sai quando houver um 3º call site ou quando a copy/design do aviso mudar (DRY de 2 sites não vira item). +- **Explicitamente fora (triage /simplify):** sombra da barra como valor arbitrário (port classe-a-classe do `bar-shadow` do artefato; tokenização só se a regra do DESIGN.md §7 for aprovada no sign-off do PR); ids obsoletos no estado do provider (filtrados por `visibleSelectedIds` em todo consumidor); esconder o entry "Selecionar" com lista vazia já resolvido (`page.tsx` condiciona a `data.rows.length > 0`). + +## Riscos e mitigação + +- **Estado client sobrevive a refresh/navegação** — a key do provider é a URL canônica da lista (`page`+filtros): navegação remonta e zera; o refresh pós-ação/poll não muda a key (seleção pós-ação é limpa por `clear()`; renovação de status preserva os ids válidos). Risco residual: o poll renovar rows durante a seleção — aceitável e coberto por testar que a seleção sobrevive ao refresh de mesma key. +- **Dois corpos (tabela + card) com um estado** — provider único envolvendo os dois; cada corpo renderiza sua ilha; teste unitário garante que marcar no card reflete na barra e no select-all (e vice-versa) — na prática o mesmo contexto. +- **`CampaignTable` server sem API de seleção** — coluna estática + CSS `data-selection-mode` + ilhas; `mandatory` contra cookie; `ui/Table` já remove `pr-0` quando há `role=checkbox`. Se um dia o shell ganhar seleção nativa, revisitar (barato). +- **Falha parcial silenciosa** — o outcome sempre separa `affected` de `failures` e o recibo nunca diz "N apagadas" com falha; o literal parcial é o do plano (`2 de 3 peças apagadas. 1 falhou.`); `role="alert"` na parcial. +- **Mídia privada best-effort / `ContentEvent` intocado** — o lote não toca nesses caminhos: reusa `deleteContentPieceForActor` (transação + `onPayloadTransactionCommit` + `overrideAccess` intencional só da mídia) e não faz nenhuma escrita em `contentEvent`. +- **Latência de até 25 itens em loop sequencial** — aceitável para um gesto de mesa com teto da página; gatilho de revisitação: se o e2e/int acusar latência, paralelizar com `Promise.allSettled` mantendo o outcome. +- **Convenção de rotas** — POST novo usa `campaignJsonMutationRoute` (guard `codebaseConventions:251`); nenhuma rota DELETE nova (guard `:271` fica satisfeito por não existir). +- **Sessão/role expirada no meio do lote** — gate e auth são checados antes do loop; negado vira 400/401 inteiro, não falha parcial disfarçada. +- **Dupla renderização a11y** — o corpo oculto por breakpoint sai da árvore de acessibilidade (`display:none`); labels dos checkboxes incluem o título da peça (`Selecionar `), select-all `Selecionar todas as peças desta página`. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: seleção por página nos dois corpos; barra só com seleção; `Publicar`/`Despublicar` sem confirmação e `Apagar` sempre confirmando (com aviso de publicadas); recibo com afetadas × falhas e sem sucesso parcial silencioso; elegibilidade idêntica ao unitário; seleção morre em página/filtro e pós-ação; audiência `communicator|coordinator|candidate` fail-closed; nada de rota pública. +- [ ] Invariantes AGENTS/engineering-standards: Local API sempre com `user` + `overrideAccess: false` (herdado das ações reusadas); escritas multi-collection transacionais com `req` (herdado); copy pt-BR / identificadores em inglês; sem `Consent` novo; sem tocar S27; sem editar migration existente nem criar migration (N/A); "edite o dono, não crie o gêmeo" (diálogo e shells reusados). +- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: int do lote (papéis, transações por item, mídia, `ContentEvent`, tag, falha parcial, schema), unit da barra/seleção (transporte, limpeza, recibos, diálogo) e unit do builder puro; e2e de rota no arquivo existente. +- [ ] Gates: `pnpm gate:fast` verde; push via `pnpm push`; sign-off humano do design DEGRADED registrado no PR. + +## Self-score (decision-quality, gate ≥4) + +**5/5** — (1) decisões caras com Opções A|B|C e rejeitadas explícitas (transporte, estado, coluna, barra/diálogo, recibo, migration/access); (2) cabe no appetite herdado (~1–1,5 dia; server ~0,5, UI ~0,5–0,75, sem rabbit hole); (3) rabbit holes de engenharia nomeados (cross-page, persistência, progresso, toast duplo, API de seleção no shell); (4) depth check: reusa `CampaignDeleteDialog`, `CampaignTable`, `Checkbox`, `postCampaignJson`, `campaignJsonMutationRoute`, `withPayloadTransaction`/ações donas, `CampaignListResults` e o builder puro centraliza o recibo; (5) outcome da intenção intocado — nenhuma semântica unitária, access ou rota pública mudou. + +## Crítica de design (trigger c, 2026-09-29) + +**Veredito: DEGRADED — não certificado.** O designer frontier (`openai/gpt-5.6-sol`) rodou a crítica contra o app renderizado (screenshots 390/1280 em `docs/plans/central-conteudos-acoes-em-lote-ui-critique/`), mas não certifica: o artefato de origem é DEGRADED e exige sign-off humano. Ajustes do designer: + +1. **FAB × barra (trigger b, aplicado):** `ContentPieceSelectionBar` → `sticky bottom-32 z-20 mt-4 md:bottom-4 md:mr-16` (no mobile a barra sobe acima do `CampaignQuickActionsFab`; no desktop recua à direita). +2. **Reserva de rolagem (aplicado):** `CampaignListResults` da lista ganha `group-data-[selection-mode=true]:pb-64 md:group-data-[selection-mode=true]:pb-28` — durante a seleção a barra não oculta o foco/último item. +3. **Diálogo translúcido/overlay (deferido ao sign-off):** tocar `AlertDialog` (overlay `bg-black/10` + blur, `max-w-xs` mobile, `leading` da descrição) muda o chrome de **todas** as superfícies do app (corte, gravação, C222) — decisão humana, registrada no comentário da Issue. +4. **Largura do diálogo de lote (aplicado, escopado):** novo `contentClassName` aditivo no `CampaignDeleteDialog`; a barra passa `max-w-sm` (358px em 390) sem tocar os call sites existentes. +5. **Clamp de títulos (aplicado):** `line-clamp-2 break-words` no título da tabela e do card. +6. **Recibo a11y (aplicado):** sucesso com `aria-live="polite" aria-atomic="true"`; fechar com `focus-visible:ring-3 focus-visible:ring-ring/50`. +7. **Semântica aprovada preservada:** rascunhos sem aviso de links; seleção com publicadas com o aviso — inalterado. + +**Estado da entrega:** implementação, testes (int/unit/e2e) e `pnpm gate:fast` verdes. Com `--auto` não há humano para o sign-off, então o fluxo **não abre PR**: Issue comentada e flipada para `blocked`. A proposta de evolução do `DESIGN.md` §7 (modo de seleção de lista) continua proposta, dependente do sign-off. diff --git a/docs/plans/central-conteudos-acoes-em-lote-ui-critique/04-desktop-recibo.png b/docs/plans/central-conteudos-acoes-em-lote-ui-critique/04-desktop-recibo.png new file mode 100644 index 00000000..42fce84c Binary files /dev/null and b/docs/plans/central-conteudos-acoes-em-lote-ui-critique/04-desktop-recibo.png differ diff --git a/docs/plans/central-conteudos-acoes-em-lote-ui-critique/16-desktop-selecao-final.png b/docs/plans/central-conteudos-acoes-em-lote-ui-critique/16-desktop-selecao-final.png new file mode 100644 index 00000000..12e328ae Binary files /dev/null and b/docs/plans/central-conteudos-acoes-em-lote-ui-critique/16-desktop-selecao-final.png differ diff --git a/docs/plans/central-conteudos-acoes-em-lote-ui-critique/17-desktop-dialogo-final.png b/docs/plans/central-conteudos-acoes-em-lote-ui-critique/17-desktop-dialogo-final.png new file mode 100644 index 00000000..74b35a7b Binary files /dev/null and b/docs/plans/central-conteudos-acoes-em-lote-ui-critique/17-desktop-dialogo-final.png differ diff --git a/docs/plans/central-conteudos-acoes-em-lote-ui-critique/18-mobile-selecao-final.png b/docs/plans/central-conteudos-acoes-em-lote-ui-critique/18-mobile-selecao-final.png new file mode 100644 index 00000000..dd4b6a31 Binary files /dev/null and b/docs/plans/central-conteudos-acoes-em-lote-ui-critique/18-mobile-selecao-final.png differ diff --git a/docs/plans/central-conteudos-acoes-em-lote-ui-critique/19-mobile-dialogo-final.png b/docs/plans/central-conteudos-acoes-em-lote-ui-critique/19-mobile-dialogo-final.png new file mode 100644 index 00000000..0f2715d6 Binary files /dev/null and b/docs/plans/central-conteudos-acoes-em-lote-ui-critique/19-mobile-dialogo-final.png differ diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/lote/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/lote/route.ts new file mode 100644 index 00000000..15dc34e9 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/lote/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' + +import { applyContentPieceBatchActionForActor } from '@/app/(campaign)/campanha/actions/contentPieces' +import { + CONTENT_PIECE_FORBIDDEN_MESSAGE, + CONTENT_PIECE_GENERIC_ERROR_MESSAGE, + contentPieceBatchRequestSchema, +} from '@/lib/schemas/contentPiece' +import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' + +import type { ContentPieceBatchResponse } from '../types' + +export const dynamic = 'force-dynamic' + +/** + * C236 — the batch gateway of the Central list: one request carries the verb + * and the ids of the visible page; the action repeats the unit gesture per + * piece and answers the honest outcome (affected × failures). The loop belongs + * to the server, next to the semantics it reuses — the client only renders the + * receipt. + */ +export const POST = campaignJsonMutationRoute( + { + bodySchema: contentPieceBatchRequestSchema, + safeMessages: [CONTENT_PIECE_FORBIDDEN_MESSAGE], + genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE, + }, + async (body) => { + const outcome = await applyContentPieceBatchActionForActor(body) + return NextResponse.json({ status: 'success', outcome }) + }, +) diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx index fcb44b7f..b9d24ec5 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx @@ -4,8 +4,12 @@ import { redirect } from 'next/navigation' import { getPayload } from 'payload' import { AddContentPieceLinkDialog } from '@/components/campaign/content/AddContentPieceLinkDialog' +import { ContentPieceBatchReceipt } from '@/components/campaign/content/ContentPieceBatchReceipt' import { ContentPieceCardList } from '@/components/campaign/content/ContentPieceCardList' import { ContentPieceFilters } from '@/components/campaign/content/ContentPieceFilters' +import { ContentPieceSelectionBar } from '@/components/campaign/content/ContentPieceSelectionBar' +import { ContentPieceSelectionModeControl } from '@/components/campaign/content/ContentPieceSelectionControls' +import { ContentPieceSelectionProvider } from '@/components/campaign/content/ContentPieceSelectionProvider' import { ContentPieceStatusRefresher } from '@/components/campaign/content/ContentPieceStatusRefresher' import { ContentPieceTable } from '@/components/campaign/content/ContentPieceTable' import { ContentPieceUploadDialog } from '@/components/campaign/content/ContentPieceUploadDialog' @@ -20,6 +24,7 @@ import { import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import type { ContentPieceSelectionRow } from '@/lib/contentPieceBatch' import { CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE } from '@/lib/schemas/contentPiece' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' @@ -57,6 +62,19 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP processingStatus: row.processingStatus, })) + // C236 — the serializable projection the client selection provider needs: + // the delete warning (status + public path) and the ids of "selecionar + // todas" (the visible page). The checkbox labels come from the row islands, + // which already render the piece title. + const selectionRows: ContentPieceSelectionRow[] = data.rows.map((row) => ({ + id: row.id, + status: row.status, + publicPath: row.publicPath, + })) + // The selection is page-scoped: the canonical list URL is the provider's key, + // so navigating to another page/filter remounts it and forgets the selection. + const selectionResetKey = buildContentPieceListHref(data.state, data.state.page) + const columns = toCampaignColumnPickerColumns([ { id: 'piece', label: 'Peça', mandatory: true }, { id: 'processing', label: 'Processamento' }, @@ -142,24 +160,31 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP ) : null} - - - - {data.rows.length > 0 ? ( - buildContentPieceListHref(data.state, page)} + + {data.rows.length > 0 ? : null} + + + + + - ) : null} - + {data.rows.length > 0 ? ( + buildContentPieceListHref(data.state, page)} + /> + ) : null} + + + + ) diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts index a7974595..47b13700 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts +++ b/src/app/(campaign)/campanha/(app)/comunicacao/conteudos/types.ts @@ -1,4 +1,5 @@ import type { ContentPieceProfileCandidate, ContentPieceViewModel } from '@/lib/contentPiece' +import type { ContentPieceBatchOutcome } from '@/lib/contentPieceBatch' /** Wire contract of `POST .../conteudos/enviar` (C211) — raw-body upload. */ export type ContentPieceUploadResponse = @@ -51,3 +52,8 @@ export type ContentPieceAttachResponse = export type ContentPieceDeleteResponse = | { status: 'success'; deleted: true } | { status: 'error'; message: string } + +/** Wire contract of `POST .../conteudos/lote` (C236) — the batch verdict. */ +export type ContentPieceBatchResponse = + | { status: 'success'; outcome: ContentPieceBatchOutcome } + | { status: 'error'; message: string } diff --git a/src/app/(campaign)/campanha/actions/contentPieces.ts b/src/app/(campaign)/campanha/actions/contentPieces.ts index e601330f..22cad45c 100644 --- a/src/app/(campaign)/campanha/actions/contentPieces.ts +++ b/src/app/(campaign)/campanha/actions/contentPieces.ts @@ -12,13 +12,17 @@ import { type ContentPieceCuratedField, type ContentPieceViewModel, } from '@/lib/contentPiece' +import type { ContentPieceBatchOutcome } from '@/lib/contentPieceBatch' import { normalizeContentPiecePublicFigures } from '@/lib/publicFigureCatalog' import { CONTENT_PIECE_FORBIDDEN_MESSAGE, + CONTENT_PIECE_GENERIC_ERROR_MESSAGE, CONTENT_PIECE_LINK_DUPLICATE_MESSAGE, CONTENT_PIECE_LINK_INVALID_MESSAGE, CONTENT_PIECE_NOT_FOUND_MESSAGE, CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE, + CONTENT_PIECE_SAFE_MESSAGES, + contentPieceBatchRequestSchema, contentPieceDeleteRequestSchema, contentPieceLinkRequestSchema, contentPieceProfileImportRequestSchema, @@ -26,12 +30,14 @@ import { contentPieceRetryRequestSchema, contentPieceStatusRequestSchema, contentPieceUpdateRequestSchema, + type ContentPieceBatchRequest, type ContentPieceStatusRequest, type ContentPieceUpdateRequest, } from '@/lib/schemas/contentPiece' import type { SpeechTopic } from '@/lib/speechFacets' import type { CampaignUser } from '@/payload-types' import { getCampaignActionContext } from '@/utilities/campaignActionContext' +import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError' import { reapStaleContentPiece } from '@/utilities/content/contentPieceJob' import { searchContentPieceLeaderOptions, @@ -278,6 +284,58 @@ export const deleteContentPieceForActor = async (input: { return { deleted: true } } +/** + * C236 — applies the same verdict to a bounded set of pieces. The batch is the + * unit gesture repeated, so every id goes through the unit action: its own + * transaction, its own fresh gate, its own post-commit media cleanup, its own + * collection access as the final barrier. A failure is named in the outcome and + * never reverts the pieces that succeeded (the product's honest partial + * failure); the communication gate is checked once before the loop, so a denied + * actor fails the whole request instead of turning into per-item noise. The + * loop is sequential on purpose: the page ceiling is 25 ids and determinism + * beats latency for a desk gesture. + */ +export const applyContentPieceBatchActionForActor = async ( + input: ContentPieceBatchRequest, +): Promise => { + const parsed = contentPieceBatchRequestSchema.parse(input) + const { actor } = await getCampaignActionContext() + + if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE) + + // A repeated id would act twice on the same row (the second one failing as + // not found), so the batch normalizes the selection before looping. + const contentPieceIds = [...new Set(parsed.contentPieceIds)] + const failures: ContentPieceBatchOutcome['failures'] = [] + let affected = 0 + + for (const contentPieceId of contentPieceIds) { + try { + if (parsed.action === 'apagar') { + await deleteContentPieceForActor({ contentPieceId }) + } else { + await setContentPiecePublishedForActor({ + contentPieceId, + published: parsed.action === 'publicar', + }) + } + affected += 1 + } catch (error) { + failures.push({ + contentPieceId, + message: + mapCampaignFormActionError({ + error, + safeMessages: CONTENT_PIECE_SAFE_MESSAGES, + genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE, + }).message ?? CONTENT_PIECE_GENERIC_ERROR_MESSAGE, + }) + } + } + + return { action: parsed.action, affected, failures } +} + /** * Polls the statuses of the visible pieces; a stale `processando` row is reaped * to `falhou` on the way (there is no queue to ask), then the page is read diff --git a/src/components/campaign/content/ContentPieceBatchReceipt.tsx b/src/components/campaign/content/ContentPieceBatchReceipt.tsx new file mode 100644 index 00000000..5374aba5 --- /dev/null +++ b/src/components/campaign/content/ContentPieceBatchReceipt.tsx @@ -0,0 +1,60 @@ +'use client' + +import { CircleAlertIcon, CircleCheckIcon, XIcon } from 'lucide-react' +import { useEffect } from 'react' + +import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider' +import { cn } from '@/lib/utils' + +/** How long an all-success receipt stays before it fades on its own. */ +const RECEIPT_AUTO_DISMISS_MS = 6_000 + +/** + * C236 — the honest receipt of the batch (approved design scene 5): a single + * inline banner at the top of the list, never over it. Success is one neutral + * phrase with a green icon and dismisses itself; a partial failure is an + * assertive `role="alert"` that always separates affected from failures and + * stays until it is closed. + */ +export const ContentPieceBatchReceipt = () => { + const { receipt, dismissReceipt } = useContentPieceSelection() + + useEffect(() => { + if (receipt?.kind !== 'success') return + const timer = window.setTimeout(dismissReceipt, RECEIPT_AUTO_DISMISS_MS) + return () => window.clearTimeout(timer) + }, [receipt, dismissReceipt]) + + if (!receipt) return null + + const success = receipt.kind === 'success' + + return ( +
+ {success ? ( +
+ ) +} diff --git a/src/components/campaign/content/ContentPieceCardList.tsx b/src/components/campaign/content/ContentPieceCardList.tsx index dafc0445..5997afe3 100644 --- a/src/components/campaign/content/ContentPieceCardList.tsx +++ b/src/components/campaign/content/ContentPieceCardList.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { ContentPieceCirculationCounters } from '@/components/campaign/content/ContentPieceCirculationCounters' import { ContentPieceDeleteDialog } from '@/components/campaign/content/ContentPieceDeleteDialog' import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton' +import { ContentPieceCardSelectionFrame } from '@/components/campaign/content/ContentPieceSelectionControls' import { ContentPieceProcessingBadge, ContentPiecePublicationBadge, @@ -18,7 +19,9 @@ import type { ContentPieceRowViewModel } from '@/lib/contentPieceCirculation' * carries the neutral "Peça-link" label — the honest reason lives on the ficha, * never on the list (C220 D7). C213 adds the piece's circulation counters (2×2 * grid) right above the action. C222 adds "Apagar" beside the routine action - * (below it when the piece can be reprocessed), per the approved scene B. + * (below it when the piece can be reprocessed), per the approved scene B. C236 + * hands the article box to the selection frame, which adds the 44px checkbox + * and the primary ring in selection mode without touching the body. */ export const ContentPieceCardList = ({ rows, @@ -40,21 +43,31 @@ export const ContentPieceCardList = ({ ...(isLinkOnly ? [] : [piece.typeLabel, piece.durationLabel]), ] return ( -
-
- - {piece.title} - - -
-
- - - {meta.filter(Boolean).join(' · ')} - {piece.cityLabel ? ` · ${piece.cityLabel}` : ''} - -
- + +
+ + {piece.title} + + +
+
+ + + {meta.filter(Boolean).join(' · ')} + {piece.cityLabel ? ` · ${piece.cityLabel}` : ''} + +
+ + } + > {piece.processingStatus === 'processando' ? ( <>
@@ -96,7 +109,7 @@ export const ContentPieceCardList = ({ />
)} -
+ ) })} diff --git a/src/components/campaign/content/ContentPieceSelectionBar.tsx b/src/components/campaign/content/ContentPieceSelectionBar.tsx new file mode 100644 index 00000000..ba6f8f00 --- /dev/null +++ b/src/components/campaign/content/ContentPieceSelectionBar.tsx @@ -0,0 +1,141 @@ +'use client' + +import { BookmarkIcon, CheckIcon, Trash2Icon, XIcon } from 'lucide-react' +import { useRouter } from 'next/navigation' + +import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider' +import { CampaignDeleteDialog } from '@/components/campaign/shared/CampaignDeleteDialog' +import { Button } from '@/components/ui/button' +import { Spinner } from '@/components/ui/Spinner' +import { + CONTENT_PIECE_BATCH_DELETE_IRREVERSIBLE_MESSAGE, + CONTENT_PIECE_BATCH_DELETE_LINKS_MESSAGE, + CONTENT_PIECE_BATCH_ERROR_MESSAGE, + contentPieceBatchDeleteTitle, + contentPieceSelectionLabel, + type ContentPieceBatchAction, +} from '@/lib/contentPieceBatch' + +/** + * C236 — the batch action bar (approved design scenes 2, 3b and 6): it exists + * only while something is selected, floats at the footer of the content and + * owns the three verbs. `Publicar`/`Despublicar` are the reversible kill switch + * and act on the spot, with the receipt in the list; `Apagar` always opens the + * one confirmation machine (the C222 chrome) with the link warning when the + * selection carries published pieces. While the loop runs every target blocks: + * `aria-busy` on the bar, spinner on the clicked verb, the rest disabled. + */ +export const ContentPieceSelectionBar = () => { + const router = useRouter() + const { selectedCount, selectedPublishedPaths, pendingAction, exitSelection, runBatch } = + useContentPieceSelection() + + if (selectedCount === 0) return null + + const pending = pendingAction !== null + const barButtonClassName = 'min-h-11 px-1 text-xs sm:px-3 sm:text-sm' + + const run = async (action: ContentPieceBatchAction) => { + const result = await runBatch(action) + if (result.ok) router.refresh() + } + + const description = (() => { + if (selectedPublishedPaths.length === 0) return CONTENT_PIECE_BATCH_DELETE_IRREVERSIBLE_MESSAGE + if (selectedCount > 1) return CONTENT_PIECE_BATCH_DELETE_LINKS_MESSAGE + return ( + <> + O link público{' '} + {selectedPublishedPaths[0]} deixa de + funcionar para quem já recebeu. Esta ação não pode ser desfeita. + + ) + })() + + const deleteTrigger = ( + + ) + + return ( +
+
+
+

+ {contentPieceSelectionLabel(selectedCount)} +

+ +
+ +
+ + + { + const result = await runBatch('apagar') + return result.ok ? { ok: true } : { ok: false, message: result.message } + }} + /> +
+
+
+ ) +} diff --git a/src/components/campaign/content/ContentPieceSelectionControls.tsx b/src/components/campaign/content/ContentPieceSelectionControls.tsx new file mode 100644 index 00000000..0466ddf5 --- /dev/null +++ b/src/components/campaign/content/ContentPieceSelectionControls.tsx @@ -0,0 +1,156 @@ +'use client' + +import { SquareCheckIcon, XIcon } from 'lucide-react' +import type { ReactNode } from 'react' + +import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/Checkbox' +import { cn } from '@/lib/utils' + +/** + * C236 — the affordances of the selection mode (approved design scenes 1–3): + * the single entry at the top of the list, the mode badge with its exit, the + * per-row checkboxes of the table and the mobile card frame. All of them are + * client islands reading the one selection context; the table shell and the + * card bodies stay server-rendered. + */ + +/** The opt-in entry ("Selecionar") and the active-mode chrome of the top of the list. */ +export const ContentPieceSelectionModeControl = () => { + const { selectionMode, enterSelection, exitSelection, pendingAction } = useContentPieceSelection() + + if (!selectionMode) { + return ( +
+ +
+ ) + } + + return ( +
+ + + +
+ ) +} + +/** The header checkbox: all of the visible page, or none. */ +export const ContentPieceSelectAllCheckbox = () => { + const { allSelected, selectedCount, toggleAll, pendingAction } = useContentPieceSelection() + + return ( + + 0 ? 'indeterminate' : false} + onCheckedChange={() => toggleAll()} + disabled={pendingAction !== null} + aria-label="Selecionar todas as peças desta página" + /> + + ) +} + +/** + * One table-row checkbox. The wrapper carries `data-selection-state`, which the + * row's `has-[…]:bg-primary/5` class uses to tint the selected line. + */ +export const ContentPieceRowCheckbox = ({ + contentPieceId, + title, +}: { + contentPieceId: number + title: string +}) => { + const { isSelected, toggle, pendingAction } = useContentPieceSelection() + const selected = isSelected(contentPieceId) + + return ( + + toggle(contentPieceId)} + disabled={pendingAction !== null} + aria-label={`Selecionar ${title}`} + /> + + ) +} + +/** + * The mobile card frame (design scene 3b): in selection mode the article gains + * the 44px touch target with the checkbox and the primary ring; outside it the + * frame renders the exact resting article, so the card look at rest does not + * depend on this island. `header` is the card's title/meta block (which sits + * beside the checkbox); children are the rest of the card body. + */ +export const ContentPieceCardSelectionFrame = ({ + contentPieceId, + title, + header, + children, +}: { + contentPieceId: number + title: string + header: ReactNode + children: ReactNode +}) => { + const { selectionMode, isSelected, toggle, pendingAction } = useContentPieceSelection() + const selected = selectionMode && isSelected(contentPieceId) + + if (!selectionMode) { + return ( +
+ {header} + {children} +
+ ) + } + + return ( +
+
+ + toggle(contentPieceId)} + disabled={pendingAction !== null} + aria-label={`Selecionar ${title}`} + /> + +
{header}
+
+ {children} +
+ ) +} diff --git a/src/components/campaign/content/ContentPieceSelectionProvider.tsx b/src/components/campaign/content/ContentPieceSelectionProvider.tsx new file mode 100644 index 00000000..9a1c0959 --- /dev/null +++ b/src/components/campaign/content/ContentPieceSelectionProvider.tsx @@ -0,0 +1,201 @@ +'use client' + +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react' + +import type { ContentPieceBatchResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/types' +import { postCampaignJson } from '@/lib/campaignJsonRequest' +import { CAMPAIGN_CONTENT_PIECE_BATCH_HREF } from '@/lib/campaignPaths' +import { + CONTENT_PIECE_BATCH_ERROR_MESSAGE, + contentPieceBatchReceiptMessage, + type ContentPieceBatchAction, + type ContentPieceBatchOutcome, + type ContentPieceSelectionRow, +} from '@/lib/contentPieceBatch' + +/** What the receipt banner renders: success is `status`, a failure is `alert`. */ +export type ContentPieceReceipt = { + kind: 'success' | 'failure' + message: string +} + +export type ContentPieceBatchResult = + | { ok: true; outcome: ContentPieceBatchOutcome } + | { ok: false; message: string } + +type ContentPieceSelectionValue = { + selectionMode: boolean + selectedCount: number + allSelected: boolean + isSelected: (id: number) => boolean + pendingAction: ContentPieceBatchAction | null + receipt: ContentPieceReceipt | null + /** `/conteudos/` of the selected published pieces (the delete warning). */ + selectedPublishedPaths: readonly string[] + enterSelection: () => void + exitSelection: () => void + toggle: (id: number) => void + toggleAll: () => void + dismissReceipt: () => void + runBatch: (action: ContentPieceBatchAction) => Promise +} + +const ContentPieceSelectionContext = createContext(null) + +export const useContentPieceSelection = (): ContentPieceSelectionValue => { + const value = useContext(ContentPieceSelectionContext) + if (!value) { + throw new Error('useContentPieceSelection precisa de um ContentPieceSelectionProvider.') + } + return value +} + +/** + * C236 — the one selection state shared by the two bodies of the list (desktop + * table + mobile cards) and by the action bar. Client-only by design: the two + * RSC bodies and the shell stay untouched; the provider receives a serializable + * projection of the visible page and wraps them via `children`. + * + * The selection is page-scoped and dies on navigation: the page keys this + * provider by the canonical list URL, so a new page/filter remounts it. A + * refresh of the same key (post-action, status poll) preserves the state, and + * `runBatch` clears the ids on success so the receipt is about a finished + * gesture. A whole-request failure keeps the selection — nothing changed. + */ +export const ContentPieceSelectionProvider = ({ + rows, + children, +}: { + rows: readonly ContentPieceSelectionRow[] + children: ReactNode +}) => { + const [selectionMode, setSelectionMode] = useState(false) + const [selectedIds, setSelectedIds] = useState([]) + const [pendingAction, setPendingAction] = useState(null) + const [receipt, setReceipt] = useState(null) + + const pageIds = useMemo(() => rows.map((row) => row.id), [rows]) + const pageIdSet = useMemo(() => new Set(pageIds), [pageIds]) + // The selection never outgrows the visible page: an id that left the page + // (the status poll can change the filtered rows under the same URL key) stops + // counting and never reaches the batch. + const visibleSelectedIds = useMemo( + () => selectedIds.filter((id) => pageIdSet.has(id)), + [selectedIds, pageIdSet], + ) + const selectedSet = useMemo(() => new Set(visibleSelectedIds), [visibleSelectedIds]) + const isSelected = useCallback((id: number) => selectedSet.has(id), [selectedSet]) + const selectedPublishedPaths = useMemo( + () => + rows.flatMap((row) => + selectedSet.has(row.id) && row.status === 'publicado' && row.publicPath !== null + ? [row.publicPath] + : [], + ), + [rows, selectedSet], + ) + + const enterSelection = useCallback(() => { + setSelectionMode(true) + setReceipt(null) + }, []) + + const exitSelection = useCallback(() => { + setSelectionMode(false) + setSelectedIds([]) + }, []) + + const toggle = useCallback((id: number) => { + setSelectedIds((current) => + current.includes(id) ? current.filter((value) => value !== id) : [...current, id], + ) + }, []) + + const toggleAll = useCallback(() => { + setSelectedIds((current) => + current.filter((id) => pageIdSet.has(id)).length === pageIds.length ? [] : [...pageIds], + ) + }, [pageIds, pageIdSet]) + + const dismissReceipt = useCallback(() => setReceipt(null), []) + + const runBatch = useCallback( + async (action: ContentPieceBatchAction): Promise => { + setPendingAction(action) + + try { + const { ok, payload } = await postCampaignJson( + CAMPAIGN_CONTENT_PIECE_BATCH_HREF, + { action, contentPieceIds: visibleSelectedIds }, + ) + + if (!ok || payload.status !== 'success') { + const message = + payload.status === 'error' ? payload.message : CONTENT_PIECE_BATCH_ERROR_MESSAGE + // The delete failure belongs to the confirmation dialog; the + // reversible verbs answer in the list, right where the selection is. + if (action !== 'apagar') setReceipt({ kind: 'failure', message }) + return { ok: false, message } + } + + setReceipt({ + kind: payload.outcome.failures.length === 0 ? 'success' : 'failure', + message: contentPieceBatchReceiptMessage(payload.outcome), + }) + setSelectedIds([]) + return { ok: true, outcome: payload.outcome } + } catch { + if (action !== 'apagar') { + setReceipt({ kind: 'failure', message: CONTENT_PIECE_BATCH_ERROR_MESSAGE }) + } + return { ok: false, message: CONTENT_PIECE_BATCH_ERROR_MESSAGE } + } finally { + setPendingAction(null) + } + }, + [visibleSelectedIds], + ) + + const value = useMemo( + () => ({ + selectionMode, + selectedCount: visibleSelectedIds.length, + allSelected: pageIds.length > 0 && visibleSelectedIds.length === pageIds.length, + isSelected, + pendingAction, + receipt, + selectedPublishedPaths, + enterSelection, + exitSelection, + toggle, + toggleAll, + dismissReceipt, + runBatch, + }), + [ + selectionMode, + visibleSelectedIds, + pageIds, + isSelected, + pendingAction, + receipt, + selectedPublishedPaths, + enterSelection, + exitSelection, + toggle, + toggleAll, + dismissReceipt, + runBatch, + ], + ) + + return ( + + {/* `contents` keeps the wrapper out of the layout; `group` + the data + attribute are what the table column CSS reads to show/hide itself. */} +
+ {children} +
+
+ ) +} diff --git a/src/components/campaign/content/ContentPieceTable.tsx b/src/components/campaign/content/ContentPieceTable.tsx index 8ea43e0b..cbe01202 100644 --- a/src/components/campaign/content/ContentPieceTable.tsx +++ b/src/components/campaign/content/ContentPieceTable.tsx @@ -4,6 +4,10 @@ import Link from 'next/link' import { ContentPieceCirculationCounters } from '@/components/campaign/content/ContentPieceCirculationCounters' import { ContentPieceDeleteDialog } from '@/components/campaign/content/ContentPieceDeleteDialog' import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton' +import { + ContentPieceRowCheckbox, + ContentPieceSelectAllCheckbox, +} from '@/components/campaign/content/ContentPieceSelectionControls' import { ContentPieceProcessingBadge, ContentPiecePublicationBadge, @@ -60,7 +64,24 @@ export const ContentPieceTable = ({ rows={rows} rowKey={(row) => row.id} empty={empty} + rowClassName="has-[[data-selection-state=selected]]:bg-primary/5" columns={[ + { + // C236 — the selection column is chrome of the selection mode, not a + // data column: it is mandatory (a stale cookie can never hide it) and + // the CSS shows it only while the provider is in selection mode. It is + // deliberately absent from the column picker of the page. + id: 'selection', + label: 'Seleção', + mandatory: true, + head: ( + + + + ), + cellClassName: 'hidden group-data-[selection-mode=true]:table-cell', + cell: (piece) => , + }, { id: 'piece', label: 'Peça', @@ -75,7 +96,10 @@ export const ContentPieceTable = ({