Enhance tickets filters UI
This commit is contained in:
parent
a08545fd40
commit
feca5dd4a7
1 changed files with 59 additions and 88 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue