teqo-backup/src/components/campaign/shared/ResponsibleMultiSelect.tsx
Francisco Solla 9a66a23cf6 refactor(campaign): E1 extrai useAsyncSearchOptions (3ª cópia da busca com debounce)
DemandResponsibleMultiSelect (C143), ResponsibleMultiSelect (C90) e
AsyncSearchCombobox trocam o efeito duplicado (requestId/setTimeout 250ms/
loading/failed) pelo hook compartilhado src/hooks/useAsyncSearchOptions.ts,
com isQueryReady opcional preservando a semântica de query vazia de cada
consumidor. Comportamento preservado; unit novo do hook (renderHook +
fake timers); sem schema/server/migration.
2026-08-23 13:16:47 -03:00

205 lines
7.1 KiB
TypeScript

'use client'
import { ChevronsUpDownIcon, XIcon } from 'lucide-react'
import { useState } from 'react'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandDialog,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/Command'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { useAsyncSearchOptions } from '@/hooks/useAsyncSearchOptions'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import {
MAX_ACTIVITY_RESPONSIBLES,
type ActivityResponsibleCollection,
} from '@/lib/schemas/activity'
import { cn } from '@/lib/utils'
export type ResponsibleOption = {
relationTo: ActivityResponsibleCollection
id: number
name: string
typeLabel: string
}
type ResponsibleMultiSelectProps = {
/** FormData field name — a hidden JSON field with the typed entries. */
name: string
label: string
value?: ResponsibleOption[]
error?: string
description?: string
/** Extra classes for the field label (e.g. `sr-only` on label-less sheets). */
labelClassName?: string
/** Trigger text while empty (replaces the "Nenhum" fallback). */
emptyText?: string
/** Extra classes for the trigger button (e.g. borderless list rows). */
triggerClassName?: string
search: (query: string) => Promise<ResponsibleOption[]>
}
/**
* C90 — polymorphic multi-value responsible picker for the activity form
* (later reused by the agenda inline creation, C91). One search catalog across
* staff/leadership/dobradinha, results grouped by type, removable typed chips.
*/
export const ResponsibleMultiSelect = ({
name,
label,
value = [],
error,
description,
labelClassName,
emptyText,
triggerClassName,
search,
}: ResponsibleMultiSelectProps) => {
// Initial-only (like RelationMultiSelect's initialSelectedIDs): on the create
// page `value` is a fresh `[]` every render and a sync effect would wipe the
// user's picks on any failed submit.
const [selected, setSelected] = useState<ResponsibleOption[]>(value)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const { options, loading, failed } = useAsyncSearchOptions({
open,
query,
search,
isQueryReady: isContactSearchQueryReady,
})
const selectedKey = (option: ResponsibleOption) => `${option.relationTo}:${option.id}`
const selectedKeys = new Set(selected.map(selectedKey))
const atCapacity = selected.length >= MAX_ACTIVITY_RESPONSIBLES
const add = (option: ResponsibleOption) => {
if (atCapacity) return
setSelected((current) =>
current.some((entry) => selectedKey(entry) === selectedKey(option))
? current
: [...current, option],
)
}
const remove = (option: ResponsibleOption) => {
const key = selectedKey(option)
setSelected((current) => current.filter((entry) => selectedKey(entry) !== key))
}
const visibleOptions = options.filter((option) => !selectedKeys.has(selectedKey(option)))
const groups = visibleOptions.reduce<Record<string, ResponsibleOption[]>>((acc, option) => {
const group = acc[option.typeLabel] ?? []
group.push(option)
acc[option.typeLabel] = group
return acc
}, {})
const selectedLabel =
selected.length === 0
? (emptyText ?? 'Nenhum')
: `${selected.length} ${selected.length === 1 ? 'responsável' : 'responsáveis'}`
return (
<Field>
<FieldLabel className={labelClassName}>{label}</FieldLabel>
<input
type="hidden"
name={name}
value={JSON.stringify(selected.map(({ relationTo, id }) => ({ relationTo, value: id })))}
/>
{selected.length ? (
<ul className="flex flex-wrap gap-1.5">
{selected.map((option) => (
<li key={selectedKey(option)}>
<Badge variant="secondary" className="gap-1 pr-1">
<span className="font-normal">{option.name}</span>
<span className="text-muted-foreground">{option.typeLabel}</span>
<button
type="button"
aria-label={`Remover ${option.name}`}
className="inline-flex size-6 items-center justify-center rounded-full hover:bg-foreground/10"
onClick={() => remove(option)}
>
<XIcon className="size-3.5" aria-hidden="true" />
</button>
</Badge>
</li>
))}
</ul>
) : null}
<Button
type="button"
variant="outline"
className={cn('min-h-11 w-full justify-between font-normal', triggerClassName)}
aria-label={`${label}: ${selectedLabel}`}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${name}-error` : undefined}
disabled={atCapacity}
onClick={() => setOpen(true)}
>
<span className="truncate">
{atCapacity ? 'Limite de responsáveis alcançado' : selectedLabel}
</span>
<ChevronsUpDownIcon aria-hidden="true" />
</Button>
<CommandDialog
open={open}
onOpenChange={setOpen}
title={label}
description="Busque por nome e escolha um ou mais responsáveis."
showCloseButton
>
<Command shouldFilter={false} label={label}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar responsável"
aria-label={label}
/>
<CommandList aria-busy={loading}>
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner aria-hidden="true" />
Buscando…
</div>
) : failed ? (
<p className="py-6 text-center text-sm text-destructive">
Não foi possível concluir a busca.
</p>
) : Object.keys(groups).length > 0 ? (
Object.entries(groups).map(([typeLabel, groupOptions]) => (
<CommandGroup key={typeLabel} heading={typeLabel}>
{groupOptions.map((option) => (
<CommandItem
key={selectedKey(option)}
value={`${option.typeLabel}-${option.id}`}
onSelect={() => add(option)}
disabled={atCapacity}
>
<span className="truncate">{option.name}</span>
</CommandItem>
))}
</CommandGroup>
))
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
{!isContactSearchQueryReady(query)
? 'Digite ao menos dois caracteres para buscar.'
: 'Nenhum resultado encontrado.'}
</p>
)}
</CommandList>
</Command>
</CommandDialog>
{description ? <FieldDescription>{description}</FieldDescription> : null}
{error ? <FieldError id={`${name}-error`}>{error}</FieldError> : null}
</Field>
)
}