feat: improve requester combobox and admin cleanup flows

This commit is contained in:
Esdras Renan 2025-10-24 00:45:41 -03:00
parent 788f6928a1
commit 37c32149a6
13 changed files with 923 additions and 180 deletions

View file

@ -0,0 +1,174 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { ChevronsUpDown, Check, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
export type SearchableComboboxOption = {
value: string
label: string
description?: string
keywords?: string[]
disabled?: boolean
}
type SearchableComboboxProps = {
value: string | null
onValueChange: (value: string | null) => void
options: SearchableComboboxOption[]
placeholder?: string
searchPlaceholder?: string
emptyText?: string
className?: string
disabled?: boolean
allowClear?: boolean
clearLabel?: string
renderValue?: (option: SearchableComboboxOption | null) => React.ReactNode
renderOption?: (option: SearchableComboboxOption, active: boolean) => React.ReactNode
}
export function SearchableCombobox({
value,
onValueChange,
options,
placeholder = "Selecionar...",
searchPlaceholder = "Buscar...",
emptyText = "Nenhuma opção encontrada.",
className,
disabled,
allowClear = false,
clearLabel = "Limpar seleção",
renderValue,
renderOption,
}: SearchableComboboxProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState("")
const selected = useMemo(() => {
if (value === null) return null
return options.find((option) => option.value === value) ?? null
}, [options, value])
const filtered = useMemo(() => {
const term = search.trim().toLowerCase()
if (!term) {
return options
}
return options.filter((option) => {
const labelMatch = option.label.toLowerCase().includes(term)
const descriptionMatch = option.description?.toLowerCase().includes(term) ?? false
const keywordMatch = option.keywords?.some((keyword) => keyword.toLowerCase().includes(term)) ?? false
return labelMatch || descriptionMatch || keywordMatch
})
}, [options, search])
useEffect(() => {
if (!open) {
setSearch("")
}
}, [open])
const handleSelect = (nextValue: string) => {
if (nextValue === value) {
setOpen(false)
return
}
onValueChange(nextValue)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"flex h-9 w-full items-center justify-between rounded-full border border-input bg-background px-3 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
<span className="truncate text-left">
{renderValue ? renderValue(selected) : selected?.label ?? <span className="text-muted-foreground">{placeholder}</span>}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="z-50 w-[var(--radix-popover-trigger-width)] p-0">
<div className="border-b border-border/80 p-2">
<Input
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={searchPlaceholder}
className="h-9"
/>
</div>
{allowClear ? (
<>
<button
type="button"
onClick={() => {
onValueChange(null)
setOpen(false)
}}
className={cn(
"flex w-full items-center justify-between gap-2 px-3 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted",
selected === null ? "text-foreground" : "",
)}
>
<span>{clearLabel}</span>
<X className="size-4 opacity-60" />
</button>
<Separator />
</>
) : null}
<ScrollArea className="max-h-60">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">{emptyText}</div>
) : (
<div className="py-1">
{filtered.map((option) => {
const isActive = option.value === value
const content = renderOption ? (
renderOption(option, isActive)
) : (
<div className="flex flex-col">
<span className="font-medium text-foreground">{option.label}</span>
{option.description ? <span className="text-xs text-muted-foreground">{option.description}</span> : null}
</div>
)
return (
<button
type="button"
key={option.value}
disabled={option.disabled}
onClick={() => handleSelect(option.value)}
className={cn(
"flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition",
option.disabled ? "cursor-not-allowed opacity-50" : "hover:bg-muted",
isActive ? "bg-muted" : "",
)}
>
{content}
<Check className={cn("size-4 shrink-0 text-primary", isActive ? "opacity-100" : "opacity-0")} />
</button>
)
})}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
)
}