Enhance tickets filters UI

This commit is contained in:
Esdras Renan 2025-11-13 20:19:19 -03:00
parent a08545fd40
commit feca5dd4a7

View file

@ -1,14 +1,20 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { IconCalendar, IconFilter, IconRefresh, IconSearch } from "@tabler/icons-react" import {
IconCalendar,
IconFilter,
IconRefresh,
IconSearch,
IconList,
IconBuilding,
IconTags,
IconUser,
} from "@tabler/icons-react"
import type { DateRange } from "react-day-picker" import type { DateRange } from "react-day-picker"
import { import { ticketPrioritySchema, type TicketStatus } from "@/lib/schemas/ticket"
ticketChannelSchema, import { PriorityIcon } from "@/components/tickets/priority-select"
ticketPrioritySchema,
type TicketStatus,
} from "@/lib/schemas/ticket"
import type { TicketFiltersState } from "@/lib/ticket-filters" import type { TicketFiltersState } from "@/lib/ticket-filters"
import { defaultTicketFilters } from "@/lib/ticket-filters" import { defaultTicketFilters } from "@/lib/ticket-filters"
@ -19,11 +25,7 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Calendar } from "@/components/ui/calendar" import { Calendar } from "@/components/ui/calendar"
import { import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { import {
Select, Select,
SelectContent, SelectContent,
@ -32,6 +34,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type QueueOption = string type QueueOption = string
@ -69,18 +72,6 @@ const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
}[priority], }[priority],
})) }))
const channelOptions = ticketChannelSchema.options.map((channel) => ({
value: channel,
label: {
EMAIL: "E-mail",
WHATSAPP: "WhatsApp",
CHAT: "Chat",
PHONE: "Telefone",
API: "API",
MANUAL: "Manual",
}[channel],
}))
function strToDate(value?: string | null): Date | undefined { function strToDate(value?: string | null): Date | undefined {
if (!value) return undefined if (!value) return undefined
const [y, m, d] = value.split("-").map(Number) const [y, m, d] = value.split("-").map(Number)
@ -201,6 +192,13 @@ export function TicketsFilters({
const normalizedRole = viewerRole?.toLowerCase() ?? null const normalizedRole = viewerRole?.toLowerCase() ?? null
const canUseAdvancedFilters = normalizedRole === "admin" || normalizedRole === "agent" const canUseAdvancedFilters = normalizedRole === "admin" || normalizedRole === "agent"
const companyOptionsMemo = useMemo<SearchableComboboxOption[]>(() => {
return Array.from(new Set(companies.filter((company): company is string => Boolean(company)))).map((company) => ({
value: company,
label: company,
}))
}, [companies])
const activeFilters = useMemo(() => { const activeFilters = useMemo(() => {
const chips: string[] = [] const chips: string[] = []
if (filters.status) chips.push(`Status: ${statusLabelMap[filters.status] ?? filters.status}`) if (filters.status) chips.push(`Status: ${statusLabelMap[filters.status] ?? filters.status}`)
@ -223,7 +221,7 @@ export function TicketsFilters({
return chips return chips
}, [filters, assignees, categories, canUseAdvancedFilters]) }, [filters, assignees, categories, canUseAdvancedFilters])
const advancedFiltersCount = Number(Boolean(filters.status)) + Number(Boolean(filters.priority)) + Number(Boolean(filters.channel)) const advancedFiltersCount = Number(Boolean(filters.status)) + Number(Boolean(filters.priority))
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@ -251,8 +249,8 @@ export function TicketsFilters({
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="ghost"
className="h-10 gap-2 rounded-full border-dashed border-slate-300 bg-white/80 px-4 text-sm font-medium text-neutral-700 shadow-none hover:bg-slate-50" className="h-10 gap-2 rounded-full px-4 text-sm font-medium text-neutral-700 hover:bg-slate-100"
> >
<IconFilter className="size-4" /> <IconFilter className="size-4" />
<span>Filtros rápidos</span> <span>Filtros rápidos</span>
@ -298,26 +296,10 @@ export function TicketsFilters({
<SelectItem value={ALL_VALUE}>Todas</SelectItem> <SelectItem value={ALL_VALUE}>Todas</SelectItem>
{priorityOptions.map((option) => ( {priorityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
<span className="flex items-center gap-2">
<PriorityIcon value={option.value} />
{option.label} {option.label}
</SelectItem> </span>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-neutral-500">Canal</p>
<Select
value={filters.channel ?? ALL_VALUE}
onValueChange={(value) => setPartial({ channel: value === ALL_VALUE ? null : value })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Todos</SelectItem>
{channelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -328,7 +310,7 @@ export function TicketsFilters({
<Button <Button
variant="ghost" variant="ghost"
className="h-10 gap-2 rounded-full text-sm font-medium text-neutral-700 hover:bg-slate-100" className="h-10 gap-2 rounded-full text-sm font-medium text-neutral-700 hover:bg-slate-100"
onClick={() => setPartial(defaultTicketFilters)} onClick={() => setFilters(mergedDefaults)}
> >
<IconRefresh className="size-4" /> <IconRefresh className="size-4" />
Limpar Limpar
@ -337,12 +319,13 @@ export function TicketsFilters({
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{canUseAdvancedFilters && ( {canUseAdvancedFilters && (
<div className="min-w-[220px] flex-1"> <div className="relative min-w-[220px] flex-1">
<IconList className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
<Select <Select
value={filters.queue ?? ALL_VALUE} value={filters.queue ?? ALL_VALUE}
onValueChange={(value) => setPartial({ queue: value === ALL_VALUE ? null : value })} onValueChange={(value) => setPartial({ queue: value === ALL_VALUE ? null : value })}
> >
<SelectTrigger className="w-full rounded-2xl border-slate-300 bg-slate-50/80 focus:ring-neutral-300"> <SelectTrigger className="w-full rounded-2xl border-slate-300 bg-slate-50/80 pl-9 focus:ring-neutral-300">
<SelectValue placeholder="Fila" /> <SelectValue placeholder="Fila" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -357,31 +340,26 @@ export function TicketsFilters({
</div> </div>
)} )}
{canUseAdvancedFilters && ( {canUseAdvancedFilters && (
<div className="min-w-[220px] flex-1"> <div className="relative min-w-[220px] flex-1">
<Select <IconBuilding className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
value={filters.company ?? ALL_VALUE} <SearchableCombobox
onValueChange={(value) => setPartial({ company: value === ALL_VALUE ? null : value })} value={filters.company}
> onValueChange={(value) => setPartial({ company: value })}
<SelectTrigger className="w-full rounded-2xl border-slate-300 bg-slate-50/80 focus:ring-neutral-300"> options={companyOptionsMemo}
<SelectValue placeholder="Empresa" /> placeholder="Empresa"
</SelectTrigger> allowClear
<SelectContent> clearLabel="Todas as empresas"
<SelectItem value={ALL_VALUE}>Todas as empresas</SelectItem> className="min-h-[40px] w-full rounded-2xl border border-slate-300 bg-slate-50/80 pl-9 text-left text-sm font-semibold text-neutral-700"
{companies.map((company) => ( />
<SelectItem key={company!} value={company!}>
{company}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
)} )}
<div className="min-w-[220px] flex-1"> <div className="relative min-w-[220px] flex-1">
<IconTags className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
<Select <Select
value={filters.categoryId ?? ALL_VALUE} value={filters.categoryId ?? ALL_VALUE}
onValueChange={(value) => setPartial({ categoryId: value === ALL_VALUE ? null : value })} onValueChange={(value) => setPartial({ categoryId: value === ALL_VALUE ? null : value })}
> >
<SelectTrigger className="w-full rounded-2xl border-slate-300 bg-slate-50/80 focus:ring-neutral-300"> <SelectTrigger className="w-full rounded-2xl border-slate-300 bg-slate-50/80 pl-9 focus:ring-neutral-300">
<SelectValue placeholder="Categoria" /> <SelectValue placeholder="Categoria" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -395,12 +373,13 @@ export function TicketsFilters({
</Select> </Select>
</div> </div>
{canUseAdvancedFilters && ( {canUseAdvancedFilters && (
<div className="min-w-[220px] flex-1"> <div className="relative min-w-[220px] flex-1">
<IconUser className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
<Select <Select
value={filters.assigneeId ?? ALL_VALUE} value={filters.assigneeId ?? ALL_VALUE}
onValueChange={(value) => setPartial({ assigneeId: value === ALL_VALUE ? null : value })} onValueChange={(value) => setPartial({ assigneeId: value === ALL_VALUE ? null : value })}
> >
<SelectTrigger className="w-full rounded-2xl border-slate-300 bg-slate-50/80 focus:ring-neutral-300"> <SelectTrigger className="w-full rounded-2xl border-slate-300 bg-slate-50/80 pl-9 focus:ring-neutral-300">
<SelectValue placeholder="Responsável" /> <SelectValue placeholder="Responsável" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -420,18 +399,12 @@ export function TicketsFilters({
type="single" type="single"
value={filters.view} value={filters.view}
onValueChange={(value) => value && setPartial({ view: value as TicketFiltersState["view"] })} onValueChange={(value) => value && setPartial({ view: value as TicketFiltersState["view"] })}
className="flex h-10 min-w-[220px] flex-1 items-center rounded-full border border-slate-200 bg-slate-50/70 p-1" className={segmentedRoot}
>
<ToggleGroupItem
value="active"
className="inline-flex h-full flex-1 items-center justify-center rounded-full px-4 text-sm font-semibold text-neutral-600 transition data-[state=on]:bg-neutral-900 data-[state=on]:text-white"
> >
<ToggleGroupItem value="active" className={segmentedItem}>
Em andamento Em andamento
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem <ToggleGroupItem value="completed" className={segmentedItem}>
value="completed"
className="inline-flex h-full flex-1 items-center justify-center rounded-full px-4 text-sm font-semibold text-neutral-600 transition data-[state=on]:bg-neutral-900 data-[state=on]:text-white"
>
Concluídos Concluídos
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
@ -439,18 +412,12 @@ export function TicketsFilters({
type="single" type="single"
value={filters.sort} value={filters.sort}
onValueChange={(value) => value && setPartial({ sort: value as TicketFiltersState["sort"] })} onValueChange={(value) => value && setPartial({ sort: value as TicketFiltersState["sort"] })}
className="flex h-10 min-w-[220px] flex-1 items-center rounded-full border border-slate-200 bg-slate-50/70 p-1" className={segmentedRoot}
>
<ToggleGroupItem
value="recent"
className="inline-flex h-full flex-1 items-center justify-center rounded-full px-4 text-sm font-semibold text-neutral-600 transition data-[state=on]:bg-neutral-900 data-[state=on]:text-white"
> >
<ToggleGroupItem value="recent" className={segmentedItem}>
Mais recentes Mais recentes
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem <ToggleGroupItem value="oldest" className={segmentedItem}>
value="oldest"
className="inline-flex h-full flex-1 items-center justify-center rounded-full px-4 text-sm font-semibold text-neutral-600 transition data-[state=on]:bg-neutral-900 data-[state=on]:text-white"
>
Mais antigos Mais antigos
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
@ -501,3 +468,7 @@ function formatDateValue(value: string | null) {
return value return value
} }
} }
const segmentedRoot =
"flex h-10 min-w-[220px] flex-1 items-stretch rounded-full border border-slate-200 bg-slate-50/70 p-1 gap-1"
const segmentedItem =
"inline-flex h-full flex-1 items-center justify-center rounded-full first:rounded-full last:rounded-full px-4 text-sm font-semibold text-neutral-600 transition-colors hover:bg-slate-100 data-[state=on]:bg-neutral-900 data-[state=on]:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-300"