fix(miss-52): one shared Drawer for the chip-relation lists too

The B42 fix reached the municípios mobile cards, but the three
chip-relation surfaces still mounted a Drawer root per OPENED cell on
coarse pointers (lideranças 50 cells, dobradinhas 50, assessores 25):
the exact hundreds-of-hidden-instances class that tripped React #130 in
prod. Each now wraps its table in CampaignListSheetProvider — cell
bodies portal into the single host Drawer, fine-pointer popover path
untouched (behavior delta: after opening N cells sequentially on touch,
N drawer roots -> 1).
This commit is contained in:
Cursor Agent 2026-07-31 05:50:41 +00:00 committed by Francisco Ignácio Fontoura Solla
parent bfbb0ec713
commit 6f42bc3bf3
3 changed files with 276 additions and 259 deletions

View file

@ -10,6 +10,7 @@ import {
CampaignListResults,
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import {
CampaignTable,
CampaignTableHead,
@ -249,14 +250,18 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
<CampaignTable
caption={`${sortSummary}. Deputados estaduais com quem a campanha dobra.`}
columns={columns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={<StateDeputyListEmptyState state={state} />}
/>
{/* One shared Drawer for every chip-cell sheet on coarse pointers
(miss #52 — never a Drawer root per opened cell). */}
<CampaignListSheetProvider>
<CampaignTable
caption={`${sortSummary}. Deputados estaduais com quem a campanha dobra.`}
columns={columns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={<StateDeputyListEmptyState state={state} />}
/>
</CampaignListSheetProvider>
{rows.length ? (
<CampaignListFooter
totalDocs={totalDocs}

View file

@ -18,6 +18,7 @@ import {
CampaignListResults,
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import {
CampaignTable,
CampaignTableHead,
@ -402,14 +403,18 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
<CampaignTable
caption={`${sortSummary}. Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.`}
columns={columns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={<LeadershipListEmptyState state={state} />}
/>
{/* One shared Drawer for every chip-cell sheet on coarse pointers
(miss #52 — never a Drawer root per opened cell). */}
<CampaignListSheetProvider>
<CampaignTable
caption={`${sortSummary}. Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.`}
columns={columns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={<LeadershipListEmptyState state={state} />}
/>
</CampaignListSheetProvider>
{rows.length ? (
<CampaignListFooter
totalDocs={totalDocs}

View file

@ -21,6 +21,7 @@ import {
campaignReadCellClassName,
} from '@/components/campaign/shared/CampaignCopyableCell'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -155,223 +156,120 @@ export const AdvisorsTable = ({
const showEmpty = rows.length === 0 && !draft
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap justify-end gap-2">
{editing ? (
<Button type="button" variant="outline" className="min-h-11" onClick={leaveEditing}>
Concluir edição
</Button>
) : (
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => setEditing(true)}
>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Editar nome e contato
</Button>
)}
<Button type="button" className="min-h-11" onClick={startDraft} disabled={Boolean(draft)}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo assessor
</Button>
</div>
{showEmpty ? (
<CampaignListEmptyState
icon={hasQuery ? SearchXIcon : UserCogIcon}
title={hasQuery ? 'Nenhum assessor encontrado' : 'Nenhum assessor cadastrado'}
description={
hasQuery
? 'Ajuste a busca ou limpe o filtro para ver todos.'
: 'Ative a edição e crie a conta na tabela.'
}
>
{!hasQuery ? (
<Button type="button" className="min-h-11" onClick={startDraft}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo assessor
// One shared Drawer for the portfolio chip cells on coarse pointers
// (miss #52 — never a Drawer root per opened cell).
<CampaignListSheetProvider>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap justify-end gap-2">
{editing ? (
<Button type="button" variant="outline" className="min-h-11" onClick={leaveEditing}>
Concluir edição
</Button>
) : null}
</CampaignListEmptyState>
) : (
<div className="overflow-x-auto rounded-xl border">
<Table className="min-w-[56rem] table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[20%]">Nome</TableHead>
<TableHead className="w-[24%]">E-mail</TableHead>
<TableHead className="w-[14%]">Celular</TableHead>
<TableHead>Municípios</TableHead>
<TableHead className="w-28 text-right">
<span className="sr-only">Ações</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{draft ? (
<TableRow className="bg-muted/20">
<TableCell>
<Input
value={draft.name}
onChange={(event) => {
const name = event.currentTarget.value
setDraft((current) => (current ? { ...current, name } : current))
}}
placeholder="Nome"
aria-label="Nome do novo assessor"
className={campaignInlineInputClassName}
autoFocus
/>
</TableCell>
<TableCell>
<Input
type="email"
value={draft.email}
onChange={(event) => {
const email = event.currentTarget.value
setDraft((current) => (current ? { ...current, email } : current))
}}
placeholder="E-mail"
aria-label="E-mail do novo assessor"
className={campaignInlineInputClassName}
/>
</TableCell>
<TableCell>
<Input
type="tel"
inputMode="numeric"
value={draft.phone}
onChange={(event) => {
const phone = formatBrazilianPhoneInput(
sanitizeBrazilianPhoneInput(event.currentTarget.value),
)
setDraft((current) => (current ? { ...current, phone } : current))
}}
placeholder="Celular"
aria-label="Celular do novo assessor"
className={campaignInlineInputClassName}
/>
</TableCell>
<TableCell>
<MunicipalityPortfolioCell
ownerId={null}
ownerName={draft.name.trim() || 'um novo assessor'}
municipalityIds={draft.municipalityIds}
municipalityIndex={municipalityIndex}
draft
onDraftChange={(municipalityIds) =>
setDraft((current) => (current ? { ...current, municipalityIds } : current))
}
commitAction={municipalitiesAction}
drawerTitle="Carteira do assessor"
updateErrorMessage="Não foi possível atualizar a carteira."
/>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
aria-label="Cancelar novo assessor"
disabled={isCreating}
onClick={cancelDraft}
>
<XIcon className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
size="icon"
className="size-10"
aria-label="Salvar novo assessor"
disabled={isCreating}
onClick={saveDraft}
>
{isCreating ? (
<Spinner className="size-4" aria-hidden="true" />
) : (
<SaveIcon className="size-4" aria-hidden="true" />
)}
</Button>
</div>
</TableCell>
) : (
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => setEditing(true)}
>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Editar nome e contato
</Button>
)}
<Button type="button" className="min-h-11" onClick={startDraft} disabled={Boolean(draft)}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo assessor
</Button>
</div>
{showEmpty ? (
<CampaignListEmptyState
icon={hasQuery ? SearchXIcon : UserCogIcon}
title={hasQuery ? 'Nenhum assessor encontrado' : 'Nenhum assessor cadastrado'}
description={
hasQuery
? 'Ajuste a busca ou limpe o filtro para ver todos.'
: 'Ative a edição e crie a conta na tabela.'
}
>
{!hasQuery ? (
<Button type="button" className="min-h-11" onClick={startDraft}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo assessor
</Button>
) : null}
</CampaignListEmptyState>
) : (
<div className="overflow-x-auto rounded-xl border">
<Table className="min-w-[56rem] table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[20%]">Nome</TableHead>
<TableHead className="w-[24%]">E-mail</TableHead>
<TableHead className="w-[14%]">Celular</TableHead>
<TableHead>Municípios</TableHead>
<TableHead className="w-28 text-right">
<span className="sr-only">Ações</span>
</TableHead>
</TableRow>
) : null}
{rows.map((row) => {
const whatsAppHref = whatsAppHrefForPhone(row.phone)
const emailShown = displayEmail(row.email)
return (
<TableRow key={row.id}>
</TableHeader>
<TableBody>
{draft ? (
<TableRow className="bg-muted/20">
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="name"
defaultValue={row.name}
ariaLabel={`Nome de ${row.name}`}
formAction={updateProfileAction}
/>
) : (
<Link
href={`/campanha/assessores/${row.id}`}
className={cn(
campaignReadCellClassName,
'font-medium text-primary underline-offset-4 hover:underline',
)}
>
<span className="truncate">{row.name}</span>
</Link>
)}
<Input
value={draft.name}
onChange={(event) => {
const name = event.currentTarget.value
setDraft((current) => (current ? { ...current, name } : current))
}}
placeholder="Nome"
aria-label="Nome do novo assessor"
className={campaignInlineInputClassName}
autoFocus
/>
</TableCell>
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="email"
type="email"
defaultValue={row.email ?? ''}
placeholder="E-mail"
ariaLabel={`E-mail de ${row.name}`}
placeholderEmailFallback={row.email}
formAction={updateProfileAction}
/>
) : (
<CampaignCopyableCell value={emailShown} label="E-mail" />
)}
<Input
type="email"
value={draft.email}
onChange={(event) => {
const email = event.currentTarget.value
setDraft((current) => (current ? { ...current, email } : current))
}}
placeholder="E-mail"
aria-label="E-mail do novo assessor"
className={campaignInlineInputClassName}
/>
</TableCell>
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="phone"
type="tel"
defaultValue={row.phone ? formatBrazilianPhoneInput(row.phone) : ''}
placeholder="Celular"
ariaLabel={`Celular de ${row.name}`}
formAction={updateProfileAction}
/>
) : (
<CampaignCopyableCell
value={row.phone}
label="Celular"
displayValue={
row.phone ? formatBrazilianPhoneInput(row.phone) : undefined
}
className="tabular-nums"
/>
)}
<Input
type="tel"
inputMode="numeric"
value={draft.phone}
onChange={(event) => {
const phone = formatBrazilianPhoneInput(
sanitizeBrazilianPhoneInput(event.currentTarget.value),
)
setDraft((current) => (current ? { ...current, phone } : current))
}}
placeholder="Celular"
aria-label="Celular do novo assessor"
className={campaignInlineInputClassName}
/>
</TableCell>
<TableCell>
<MunicipalityPortfolioCell
ownerId={row.id}
ownerName={row.name}
municipalityIds={municipalityIdsByAdvisor.get(row.id) ?? []}
ownerId={null}
ownerName={draft.name.trim() || 'um novo assessor'}
municipalityIds={draft.municipalityIds}
municipalityIndex={municipalityIndex}
draft
onDraftChange={(municipalityIds) =>
setDraft((current) =>
current ? { ...current, municipalityIds } : current,
)
}
commitAction={municipalitiesAction}
drawerTitle="Carteira do assessor"
updateErrorMessage="Não foi possível atualizar a carteira."
@ -379,45 +277,154 @@ export const AdvisorsTable = ({
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
disabled
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</Button>
)}
<AdvisorPasswordResetButton
advisorId={row.id}
disabled={!emailShown}
layout="icon"
accessibleName={`Enviar link de senha para ${row.name}`}
formAction={passwordResetAction}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
aria-label="Cancelar novo assessor"
disabled={isCreating}
onClick={cancelDraft}
>
<XIcon className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
size="icon"
className="size-10"
aria-label="Salvar novo assessor"
disabled={isCreating}
onClick={saveDraft}
>
{isCreating ? (
<Spinner className="size-4" aria-hidden="true" />
) : (
<SaveIcon className="size-4" aria-hidden="true" />
)}
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</div>
) : null}
{rows.map((row) => {
const whatsAppHref = whatsAppHrefForPhone(row.phone)
const emailShown = displayEmail(row.email)
return (
<TableRow key={row.id}>
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="name"
defaultValue={row.name}
ariaLabel={`Nome de ${row.name}`}
formAction={updateProfileAction}
/>
) : (
<Link
href={`/campanha/assessores/${row.id}`}
className={cn(
campaignReadCellClassName,
'font-medium text-primary underline-offset-4 hover:underline',
)}
>
<span className="truncate">{row.name}</span>
</Link>
)}
</TableCell>
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="email"
type="email"
defaultValue={row.email ?? ''}
placeholder="E-mail"
ariaLabel={`E-mail de ${row.name}`}
placeholderEmailFallback={row.email}
formAction={updateProfileAction}
/>
) : (
<CampaignCopyableCell value={emailShown} label="E-mail" />
)}
</TableCell>
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="phone"
type="tel"
defaultValue={row.phone ? formatBrazilianPhoneInput(row.phone) : ''}
placeholder="Celular"
ariaLabel={`Celular de ${row.name}`}
formAction={updateProfileAction}
/>
) : (
<CampaignCopyableCell
value={row.phone}
label="Celular"
displayValue={
row.phone ? formatBrazilianPhoneInput(row.phone) : undefined
}
className="tabular-nums"
/>
)}
</TableCell>
<TableCell>
<MunicipalityPortfolioCell
ownerId={row.id}
ownerName={row.name}
municipalityIds={municipalityIdsByAdvisor.get(row.id) ?? []}
municipalityIndex={municipalityIndex}
commitAction={municipalitiesAction}
drawerTitle="Carteira do assessor"
updateErrorMessage="Não foi possível atualizar a carteira."
/>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
disabled
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</Button>
)}
<AdvisorPasswordResetButton
advisorId={row.id}
disabled={!emailShown}
layout="icon"
accessibleName={`Enviar link de senha para ${row.name}`}
formAction={passwordResetAction}
/>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</div>
</CampaignListSheetProvider>
)
}