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"
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 {
ticketChannelSchema,
ticketPrioritySchema,
type TicketStatus,
} from "@/lib/schemas/ticket"
import { ticketPrioritySchema, type TicketStatus } from "@/lib/schemas/ticket"
import { PriorityIcon } from "@/components/tickets/priority-select"
import type { TicketFiltersState } 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 { Input } from "@/components/ui/input"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import {
Select,
SelectContent,
@ -32,6 +34,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type QueueOption = string
@ -69,18 +72,6 @@ const priorityOptions = ticketPrioritySchema.options.map((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 {
if (!value) return undefined
const [y, m, d] = value.split("-").map(Number)
@ -201,6 +192,13 @@ export function TicketsFilters({
const normalizedRole = viewerRole?.toLowerCase() ?? null
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 chips: string[] = []
if (filters.status) chips.push(`Status: ${statusLabelMap[filters.status] ?? filters.status}`)
@ -223,7 +221,7 @@ export function TicketsFilters({
return chips
}, [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 (
<div className="flex flex-col gap-4">
@ -251,8 +249,8 @@ export function TicketsFilters({
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
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"
variant="ghost"
className="h-10 gap-2 rounded-full px-4 text-sm font-medium text-neutral-700 hover:bg-slate-100"
>
<IconFilter className="size-4" />
<span>Filtros rápidos</span>
@ -298,26 +296,10 @@ export function TicketsFilters({
<SelectItem value={ALL_VALUE}>Todas</SelectItem>
{priorityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</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}
<span className="flex items-center gap-2">
<PriorityIcon value={option.value} />
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
@ -328,7 +310,7 @@ export function TicketsFilters({
<Button
variant="ghost"
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" />
Limpar
@ -337,12 +319,13 @@ export function TicketsFilters({
</div>
<div className="flex flex-wrap gap-3">
{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
value={filters.queue ?? ALL_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" />
</SelectTrigger>
<SelectContent>
@ -357,31 +340,26 @@ export function TicketsFilters({
</div>
)}
{canUseAdvancedFilters && (
<div className="min-w-[220px] flex-1">
<Select
value={filters.company ?? ALL_VALUE}
onValueChange={(value) => setPartial({ company: value === ALL_VALUE ? null : value })}
>
<SelectTrigger className="w-full rounded-2xl border-slate-300 bg-slate-50/80 focus:ring-neutral-300">
<SelectValue placeholder="Empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Todas as empresas</SelectItem>
{companies.map((company) => (
<SelectItem key={company!} value={company!}>
{company}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative min-w-[220px] flex-1">
<IconBuilding className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
<SearchableCombobox
value={filters.company}
onValueChange={(value) => setPartial({ company: value })}
options={companyOptionsMemo}
placeholder="Empresa"
allowClear
clearLabel="Todas as empresas"
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"
/>
</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
value={filters.categoryId ?? ALL_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" />
</SelectTrigger>
<SelectContent>
@ -395,12 +373,13 @@ export function TicketsFilters({
</Select>
</div>
{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
value={filters.assigneeId ?? ALL_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" />
</SelectTrigger>
<SelectContent>
@ -420,18 +399,12 @@ export function TicketsFilters({
type="single"
value={filters.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
</ToggleGroupItem>
<ToggleGroupItem
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"
>
<ToggleGroupItem value="completed" className={segmentedItem}>
Concluídos
</ToggleGroupItem>
</ToggleGroup>
@ -439,18 +412,12 @@ export function TicketsFilters({
type="single"
value={filters.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
</ToggleGroupItem>
<ToggleGroupItem
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"
>
<ToggleGroupItem value="oldest" className={segmentedItem}>
Mais antigos
</ToggleGroupItem>
</ToggleGroup>
@ -501,3 +468,7 @@ function formatDateValue(value: string | null) {
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"