Move the level control onto B42's shared cell-edit overlay.

B42 landed on main during this session and extracted `CampaignCellEditOverlay`
— Popover on `md+`, Drawer below it, where a popover fights the virtual
keyboard. The rebase auto-merged E14's "Nível" block into what had become the
Drawer, which compiles and would have opened a popover inside a sheet, so the
control now takes `variant` like its three siblings. The container's Popover
branch is point for point what E14 had hand-written, so the desktop is
unchanged; on touch, where there is no hover to carry it, the trigger now
spells the level out instead of showing the bare numeral.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 21:24:45 -03:00
parent c7001b75f0
commit 8d6c8e3313
4 changed files with 130 additions and 133 deletions

File diff suppressed because one or more lines are too long

View file

@ -107,6 +107,7 @@ O que ficou diferente do plano, e o que só se soube implementando:
- **Custo:** `/campanha/municipios` fica em 17,7 kB / 322 kB de First Load JS. Suítes: 64 unit (604 testes) e 53 int (427). Nada novo no bundle do cliente: a revisão de performance conferiu contra o build de produção que todo módulo que a ilha importa já estava nos chunks da rota.
- **O que a revisão pós-entrega corrigiu:** (1) um `blocked` vindo do **servidor** — o caso que só existe sob concorrência, quando outra aba moveu o município — virava string no box de erro, sem o checkbox de override ao lado; agora as violações tipadas voltam ao mesmo lugar que as locais, deduplicadas por `id` e presas ao nível para o qual foram levantadas, que é a única forma de o terceiro estado da rota valer para quem mais precisa dele. (2) `formatEngagementLevelLabel` / `EMPTY_ENGAGEMENT_LEVEL_LABEL` mudaram de `municipalityLabels` para `lib/engagementLevel.ts`: importar um valor daquele módulo arrasta o catálogo de 435 entradas e o glossário de conceitos para dentro da ilha (o padrão de 21 kB do B14), hoje inócuo só porque o controle de tendência já mantém a cadeia viva. (3) `isEngagementLevel` saiu de três chamadas onde o tipo gerado pelo Payload já era a união fechada.
- **Débito conhecido (não é do E14 sozinho):** `levelNote` entrou em `municipalityListSelect`, e o sort padrão do staff (`deficit`) roda esse select sobre os 435 municípios do escopo para renderizar 25 linhas — no teto de 2000 caracteres são ~870 KB lidos por request. Hoje custa zero (o campo nasce nulo em todos), e `politicalTrend.note` tem exatamente a mesma forma e já estava lá: o conserto é dos dois juntos (ler as notas só para a fatia da página), não de um.
- **No rebase, o controle deixou de ter Popover próprio.** O **B42** entrou no `main` durante esta sessão e extraiu `CampaignCellEditOverlay` — Popover no `md+`, Drawer abaixo dele, porque no celular o popover disputa espaço com o teclado virtual. O merge automático encaixou o bloco "Nível" dentro do que virou o Drawer, o que compilava e teria aberto um popover dentro de uma gaveta; o controle passou a receber `variant` como os três irmãos (`popover` na tabela, `sheet` no card). O ramo Popover do container é ponto a ponto o que o E14 tinha escrito à mão (mesma tooltip, mesmas classes de trigger, mesmo `onOpenAutoFocus`), então a troca não muda o desktop — e no toque, onde não há hover, o trigger passa a soletrar o nível em vez de mostrar só o numeral.
- **Fora do escopo, confirmado na entrega:** `priority` continua intocado (18 arquivos + o seed `projectionSheetParse.ts` que escreve o campo), e nem o E8 nem o E9 leem o nível.
## Referências

View file

@ -378,6 +378,7 @@ const municipalityListColumns = ({
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
variant="popover"
/>
) : (
<MunicipalityLevelBadge
@ -653,6 +654,7 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
level={municipality.engagementLevel}
levelNote={municipality.levelNote}
levelChangedAt={municipality.levelChangedAt}
variant="sheet"
/>
) : (
<MunicipalityLevelBadge

View file

@ -7,13 +7,15 @@ import type {
MunicipalityListSavedEngagementLevel,
} from '@/app/(campaign)/campanha/(app)/municipios/engagement-level/types'
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import {
CampaignCellEditOverlay,
type CampaignCellEditOverlayVariant,
} from '@/components/campaign/shared/CampaignCellEditOverlay'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldContent, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
@ -27,7 +29,6 @@ import {
type EngagementLevel,
type EngagementLevelViolation,
} from '@/lib/engagementLevel'
import { cn } from '@/lib/utils'
const ENGAGEMENT_LEVEL_ENDPOINT = '/campanha/municipios/engagement-level'
const SAVE_ERROR_MESSAGE = 'Não foi possível registrar o nível. Tente novamente.'
@ -38,6 +39,7 @@ type MunicipalityListLevelControlProps = {
level: EngagementLevel | null
levelNote: string | null
levelChangedAt: string | null
variant: CampaignCellEditOverlayVariant
}
/**
@ -52,6 +54,7 @@ export const MunicipalityListLevelControl = ({
level,
levelNote,
levelChangedAt,
variant,
}: MunicipalityListLevelControlProps) => {
const [open, setOpen] = useState(false)
const [saved, setSaved] = useState<MunicipalityListSavedEngagementLevel | null>(
@ -189,144 +192,135 @@ export const MunicipalityListLevelControl = ({
const fieldId = (suffix: string) => `municipality-list-level-${suffix}-${municipalityID}`
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<CampaignHoverTooltip
content={
currentLevel ? (
<div className="space-y-1">
<p>{formatEngagementLevelLabel(currentLevel)}</p>
{currentNote ? <p className="whitespace-pre-wrap">{currentNote}</p> : null}
</div>
) : null
}
align="start"
openOnTouch={false}
disabled={open}
>
<PopoverTrigger asChild>
<button
type="button"
aria-expanded={open}
aria-haspopup="dialog"
className={cn(
'min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
open ? 'bg-muted/60' : undefined,
)}
aria-label={`Nível de envolvimento de ${municipalityName}: ${
currentLevel ? formatEngagementLevelLabel(currentLevel) : EMPTY_ENGAGEMENT_LEVEL_LABEL
}`}
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={handleOpenChange}
title="Registrar nível de envolvimento"
description={municipalityName}
triggerLabel={`Nível de envolvimento de ${municipalityName}: ${
currentLevel ? formatEngagementLevelLabel(currentLevel) : EMPTY_ENGAGEMENT_LEVEL_LABEL
}`}
tooltipContent={
currentLevel ? (
<div className="space-y-1">
<p>{formatEngagementLevelLabel(currentLevel)}</p>
{currentNote ? <p className="whitespace-pre-wrap">{currentNote}</p> : null}
</div>
) : null
}
contentClassName="w-80 p-3"
preventPopoverAutoFocus
trigger={
// The sheet has no hover to carry the rest, so the trigger spells the
// level out there and stays the bare numeral in the table.
<MunicipalityLevelBadge
level={currentLevel}
note={variant === 'sheet' ? currentNote : null}
layout={variant === 'sheet' ? 'card' : 'table'}
/>
}
>
<div className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={fieldId('select')}>Nível de envolvimento</FieldLabel>
<NativeSelect
id={fieldId('select')}
value={draftLevel}
onChange={(event) =>
setDraftLevel(isEngagementLevel(event.target.value) ? event.target.value : '')
}
className="min-h-11 w-full"
>
<MunicipalityLevelBadge level={currentLevel} layout="table" />
</button>
</PopoverTrigger>
</CampaignHoverTooltip>
<PopoverContent
align="start"
sideOffset={8}
className="w-80 p-3"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={fieldId('select')}>Nível de envolvimento</FieldLabel>
<NativeSelect
id={fieldId('select')}
value={draftLevel}
onChange={(event) =>
setDraftLevel(isEngagementLevel(event.target.value) ? event.target.value : '')
}
className="min-h-11 w-full"
>
<NativeSelectOption value="">{EMPTY_ENGAGEMENT_LEVEL_LABEL}</NativeSelectOption>
{engagementLevels.map((option) => (
<NativeSelectOption key={option} value={option}>
{formatEngagementLevelLabel(option)}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor={fieldId('note')}>Motivo</FieldLabel>
<Textarea
id={fieldId('note')}
value={note}
onChange={(event) => setNote(event.target.value)}
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
rows={2}
className="min-h-16 resize-y"
placeholder="O que mudou para justificar este nível?"
<NativeSelectOption value="">{EMPTY_ENGAGEMENT_LEVEL_LABEL}</NativeSelectOption>
{engagementLevels.map((option) => (
<NativeSelectOption key={option} value={option}>
{formatEngagementLevelLabel(option)}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor={fieldId('note')}>Motivo</FieldLabel>
<Textarea
id={fieldId('note')}
value={note}
onChange={(event) => setNote(event.target.value)}
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
rows={2}
className="min-h-16 resize-y"
placeholder="O que mudou para justificar este nível?"
/>
</Field>
<Field>
<FieldLabel htmlFor={fieldId('reversal')}>O que faria voltar atrás</FieldLabel>
<Textarea
id={fieldId('reversal')}
value={reversalSignals}
onChange={(event) => setReversalSignals(event.target.value)}
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
rows={2}
className="min-h-16 resize-y"
placeholder="Sinais que fariam rever esta decisão."
/>
</Field>
{isJump ? (
<Field orientation="horizontal">
<Checkbox
id={fieldId('shock')}
checked={triangulatedShock}
onCheckedChange={(checked) => setTriangulatedShock(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={fieldId('shock')}>
Choque triangulado (dois níveis de uma vez)
</FieldLabel>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor={fieldId('reversal')}>O que faria voltar atrás</FieldLabel>
<Textarea
id={fieldId('reversal')}
value={reversalSignals}
onChange={(event) => setReversalSignals(event.target.value)}
maxLength={ENGAGEMENT_LEVEL_TEXT_MAX_LENGTH}
rows={2}
className="min-h-16 resize-y"
placeholder="Sinais que fariam rever esta decisão."
/>
</Field>
{isJump ? (
) : null}
{violations.length > 0 ? (
<>
<Alert className="py-2">
<AlertDescription className="text-xs">
<ul className="list-disc space-y-1 pl-4">
{violations.map((violation) => (
<li key={violation.id}>{violation.message}</li>
))}
</ul>
</AlertDescription>
</Alert>
<Field orientation="horizontal">
<Checkbox
id={fieldId('shock')}
checked={triangulatedShock}
onCheckedChange={(checked) => setTriangulatedShock(checked === true)}
id={fieldId('override')}
checked={override}
onCheckedChange={(checked) => setOverride(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={fieldId('shock')}>
Choque triangulado (dois níveis de uma vez)
<FieldLabel htmlFor={fieldId('override')}>
Registrar mesmo assim, ciente das ressalvas
</FieldLabel>
</FieldContent>
</Field>
) : null}
{violations.length > 0 ? (
<>
<Alert className="py-2">
<AlertDescription className="text-xs">
<ul className="list-disc space-y-1 pl-4">
{violations.map((violation) => (
<li key={violation.id}>{violation.message}</li>
))}
</ul>
</AlertDescription>
</Alert>
<Field orientation="horizontal">
<Checkbox
id={fieldId('override')}
checked={override}
onCheckedChange={(checked) => setOverride(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={fieldId('override')}>
Registrar mesmo assim, ciente das ressalvas
</FieldLabel>
</FieldContent>
</Field>
</>
) : null}
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<Button
type="button"
size="sm"
disabled={!canSubmit || isPending || (violations.length > 0 && !override)}
onClick={() => void submit()}
>
{isPending ? <Spinner className="size-3.5" aria-hidden /> : null}
Registrar movimento
</Button>
<p className="sr-only" aria-live="polite">
{isPending ? 'Registrando nível.' : (errorMessage ?? '')}
</p>
</div>
</PopoverContent>
</Popover>
</>
) : null}
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<Button
type="button"
size="sm"
disabled={!canSubmit || isPending || (violations.length > 0 && !override)}
onClick={() => void submit()}
>
{isPending ? <Spinner className="size-3.5" aria-hidden /> : null}
Registrar movimento
</Button>
<p className="sr-only" aria-live="polite">
{isPending ? 'Registrando nível.' : (errorMessage ?? '')}
</p>
</div>
</CampaignCellEditOverlay>
)
}