refactor(campanha): simplify B70 wizard loader and tile a11y.
Lean tile loader avoids org/deputy fetches; server sorts names; info button is no longer nested inside the tile button.
This commit is contained in:
parent
dbd9d0fa3e
commit
29f6b32e91
5 changed files with 91 additions and 118 deletions
|
|
@ -10,7 +10,7 @@ import {
|
|||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
LEADERSHIP_INVALID_CONTACT_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
leadershipSupportStatuses,
|
||||
isSupportStatus,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
|
|
@ -38,11 +38,7 @@ const parseExclusive = (formData: FormData): boolean | undefined => {
|
|||
|
||||
const parseSupportStatus = (formData: FormData) => {
|
||||
const supportStatus = optionalFormText(formData, 'supportStatus')
|
||||
return leadershipSupportStatuses.includes(
|
||||
supportStatus as (typeof leadershipSupportStatuses)[number],
|
||||
)
|
||||
? (supportStatus as (typeof leadershipSupportStatuses)[number])
|
||||
: 'a_abordar'
|
||||
return isSupportStatus(supportStatus) ? supportStatus : 'a_abordar'
|
||||
}
|
||||
|
||||
const wizardFieldsFromForm = (formData: FormData) => ({
|
||||
|
|
|
|||
|
|
@ -363,16 +363,9 @@ export const createLeadershipWizard = async (
|
|||
municipalitySlug: string,
|
||||
) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
try {
|
||||
const leadership = await createLeadershipWizardRecord(payload, actor, input)
|
||||
revalidateLeadershipWizardPaths(leadership.id, municipalitySlug)
|
||||
return leadership
|
||||
} catch (error) {
|
||||
if (isUniqueLeadershipConflict(error)) {
|
||||
throw new Error(LEADERSHIP_DUPLICATE_MESSAGE)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const leadership = await createLeadershipWizardRecord(payload, actor, input)
|
||||
revalidateLeadershipWizardPaths(leadership.id, municipalitySlug)
|
||||
return leadership
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { InfoIcon, PlusIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useMemo, useState, useTransition } from 'react'
|
||||
import { useCallback, useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
|
||||
|
|
@ -76,7 +76,7 @@ export const WizardLeadershipStep = ({
|
|||
|
||||
const showSkip = showLeadershipWizardSkip(entryAction)
|
||||
|
||||
const skip = showSkip
|
||||
const skipConfig = showSkip
|
||||
? { label: WIZARD_LEADERSHIP_SKIP_LABEL, href: CAMPAIGN_HOME }
|
||||
: undefined
|
||||
|
||||
|
|
@ -87,9 +87,9 @@ export const WizardLeadershipStep = ({
|
|||
? WIZARD_LEADERSHIP_FORM_EDIT_TITLE
|
||||
: WIZARD_LEADERSHIP_FORM_CREATE_TITLE
|
||||
|
||||
const trailingAction = showSkip ? (
|
||||
const trailingAction = skipConfig ? (
|
||||
<Button variant="ghost" size="sm" className="min-h-11 px-2 text-sm" asChild>
|
||||
<Link href={CAMPAIGN_HOME}>{WIZARD_LEADERSHIP_SKIP_LABEL}</Link>
|
||||
<Link href={skipConfig.href}>{skipConfig.label}</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
|
||||
|
|
@ -106,11 +106,6 @@ export const WizardLeadershipStep = ({
|
|||
})
|
||||
}
|
||||
|
||||
const sortedTiles = useMemo(
|
||||
() => [...initialTiles].sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')),
|
||||
[initialTiles],
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignWizardShell
|
||||
flowTitle={wizardFlowTitleForSlug(actionSlug)}
|
||||
|
|
@ -119,7 +114,7 @@ export const WizardLeadershipStep = ({
|
|||
previousHref={wizardActionHref(actionSlug)}
|
||||
dismissHref={CAMPAIGN_HOME}
|
||||
municipalityLabel={municipalityName}
|
||||
skip={skip}
|
||||
skip={skipConfig}
|
||||
trailingAction={trailingAction}
|
||||
>
|
||||
{mode.kind === 'form' ? (
|
||||
|
|
@ -133,11 +128,11 @@ export const WizardLeadershipStep = ({
|
|||
) : (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{sortedTiles.length === 0 ? (
|
||||
{initialTiles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{WIZARD_LEADERSHIP_EMPTY_GRID}</p>
|
||||
) : null}
|
||||
<ul className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
|
||||
{sortedTiles.map((tile) => (
|
||||
{initialTiles.map((tile) => (
|
||||
<li key={tile.id}>
|
||||
<LeadershipTileButton
|
||||
tile={tile}
|
||||
|
|
@ -197,59 +192,48 @@ const LeadershipTileButton = ({ tile, onOpen, onInfo }: LeadershipTileButtonProp
|
|||
const noteText = notesLabel(tile.notes)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative flex aspect-square w-full flex-col rounded-xl border border-border bg-transparent p-3 text-left transition-colors',
|
||||
'hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'pointer-coarse:hover:bg-transparent',
|
||||
)}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<div className="max-w-[calc(100%-2rem)]">
|
||||
<div className="relative aspect-square w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col rounded-xl border border-border bg-transparent p-3 pr-11 text-left transition-colors',
|
||||
'hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'pointer-coarse:hover:bg-transparent',
|
||||
)}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<div className="max-w-full">
|
||||
{tile.supportStatus ? <SupportStatusBadge status={tile.supportStatus} /> : null}
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onInfo()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onInfo()
|
||||
}
|
||||
}}
|
||||
aria-label={`Ver observação de ${tile.name}`}
|
||||
>
|
||||
<InfoIcon className="size-4" aria-hidden />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-1">
|
||||
<p className="text-sm font-medium leading-snug text-foreground">
|
||||
{truncateNameAtWordBoundary(tile.name, 28)}
|
||||
</p>
|
||||
{secondary ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{secondary}</p>
|
||||
) : null}
|
||||
{!tile.exclusive ? (
|
||||
<Badge variant="outline" className="mt-1 w-fit text-xs">
|
||||
Não exclusivo
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="pointer-coarse:hidden max-w-xs whitespace-pre-wrap">
|
||||
{noteText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="mt-auto flex flex-col gap-1">
|
||||
<p className="text-sm font-medium leading-snug text-foreground">
|
||||
{truncateNameAtWordBoundary(tile.name, 28)}
|
||||
</p>
|
||||
{secondary ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{secondary}</p>
|
||||
) : null}
|
||||
{!tile.exclusive ? (
|
||||
<Badge variant="outline" className="mt-1 w-fit text-xs">
|
||||
Não exclusivo
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="pointer-coarse:hidden max-w-xs whitespace-pre-wrap">
|
||||
{noteText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3 right-3 inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onInfo}
|
||||
aria-label={`Ver observação de ${tile.name}`}
|
||||
>
|
||||
<InfoIcon className="size-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { SupportStatus } from '@/lib/schemas/leadership'
|
|||
/** Client-safe tile payload for the B70 leadership wizard grid. */
|
||||
export type WizardLeadershipTileViewModel = {
|
||||
id: number
|
||||
contactID: number
|
||||
name: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
|
|
|
|||
|
|
@ -54,6 +54,38 @@ const contactSummary = (
|
|||
return { id: Number(contact), name: 'Contato', phone: null, email: null }
|
||||
}
|
||||
|
||||
const findMunicipalityLeadershipDocs = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
municipalityID: number,
|
||||
sort = 'createdAt',
|
||||
): Promise<Leadership[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'leadership',
|
||||
where: { municipalities: { in: [municipalityID] } },
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return result.docs as Leadership[]
|
||||
}
|
||||
|
||||
const toWizardLeadershipTile = (doc: Leadership): WizardLeadershipTileViewModel => {
|
||||
const contact = contactSummary(doc.contact)
|
||||
return {
|
||||
id: doc.id,
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
email: contact.email,
|
||||
supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null,
|
||||
exclusive: doc.exclusive ?? true,
|
||||
notes: doc.notes ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const organizationNamesForIds = (payload: Payload, ids: number[]): Promise<Map<number, string>> =>
|
||||
loadOrganizationNamesByIds(payload, ids)
|
||||
|
||||
|
|
@ -113,17 +145,8 @@ export const loadMunicipalityLeaderships = async (
|
|||
user: CampaignUser,
|
||||
municipalityID: number,
|
||||
): Promise<LeadershipRowViewModel[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'leadership',
|
||||
where: { municipalities: { in: [municipalityID] } },
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'createdAt',
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return toLeadershipRows(payload, result.docs as Leadership[])
|
||||
const docs = await findMunicipalityLeadershipDocs(payload, user, municipalityID)
|
||||
return toLeadershipRows(payload, docs)
|
||||
}
|
||||
|
||||
export const loadWizardLeadershipTiles = async (
|
||||
|
|
@ -131,32 +154,10 @@ export const loadWizardLeadershipTiles = async (
|
|||
user: CampaignUser,
|
||||
municipalityID: number,
|
||||
): Promise<WizardLeadershipTileViewModel[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'leadership',
|
||||
where: { municipalities: { in: [municipalityID] } },
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'createdAt',
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const rows = await toLeadershipRows(payload, result.docs as Leadership[])
|
||||
const notesById = new Map(
|
||||
(result.docs as Leadership[]).map((doc) => [doc.id, doc.notes ?? null] as const),
|
||||
)
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
contactID: row.contactID,
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
email: row.email,
|
||||
supportStatus: row.supportStatus,
|
||||
exclusive: row.exclusive,
|
||||
notes: notesById.get(row.id) ?? null,
|
||||
}))
|
||||
const docs = await findMunicipalityLeadershipDocs(payload, user, municipalityID)
|
||||
return docs
|
||||
.map(toWizardLeadershipTile)
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue