feat: improve ticket navigation and filters

This commit is contained in:
Esdras Renan 2025-11-12 20:40:38 -03:00
parent ff41a8bd4e
commit f5898153fe
7 changed files with 199 additions and 4 deletions

View file

@ -10,6 +10,7 @@ import { cn } from "@/lib/utils"
interface SiteHeaderProps {
title: string
lead?: string
breadcrumbs?: ReactNode
primaryAction?: ReactNode
secondaryAction?: ReactNode
primaryAlignment?: "right" | "center"
@ -18,6 +19,7 @@ interface SiteHeaderProps {
function SiteHeaderBase({
title,
lead,
breadcrumbs,
primaryAction,
secondaryAction,
primaryAlignment = "right",
@ -32,6 +34,7 @@ function SiteHeaderBase({
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mx-3 hidden h-6 sm:block" />
<div className="flex flex-1 flex-col gap-1">
{breadcrumbs ? <div className="text-xs text-muted-foreground">{breadcrumbs}</div> : null}
{lead ? <span className="text-sm text-muted-foreground">{lead}</span> : null}
<h1 className="text-lg font-semibold">{title}</h1>
</div>

View file

@ -0,0 +1,32 @@
import Link from "next/link"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
type TicketBreadcrumbsProps = {
ticketId: string
}
export function TicketBreadcrumbs({ ticketId }: TicketBreadcrumbsProps) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/tickets">Tickets</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Ticket #{ticketId}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)
}

View file

@ -70,12 +70,20 @@ interface TicketsFiltersProps {
queues?: QueueOption[]
companies?: string[]
assignees?: Array<{ id: string; name: string }>
categories?: Array<{ id: string; name: string }>
initialState?: Partial<TicketFiltersState>
}
const ALL_VALUE = "ALL"
export function TicketsFilters({ onChange, queues = [], companies = [], assignees = [], initialState }: TicketsFiltersProps) {
export function TicketsFilters({
onChange,
queues = [],
companies = [],
assignees = [],
categories = [],
initialState,
}: TicketsFiltersProps) {
const mergedDefaults = useMemo(
() => ({
...defaultTicketFilters,
@ -109,10 +117,16 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
const found = assignees.find((a) => a.id === filters.assigneeId)
chips.push(`Responsável: ${found?.name ?? filters.assigneeId}`)
}
if (filters.categoryId) {
const category = categories.find((category) => category.id === filters.categoryId)
chips.push(`Categoria: ${category?.name ?? filters.categoryId}`)
}
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
if (filters.focusVisits) chips.push("Somente visitas/lab")
if (filters.dateFrom || filters.dateTo) chips.push(formatDateRangeChip(filters.dateFrom, filters.dateTo))
if (filters.sort === "oldest") chips.push("Ordenados por mais antigos")
return chips
}, [filters, assignees])
}, [filters, assignees, categories])
return (
<div className="flex flex-col gap-4">
@ -156,6 +170,22 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
))}
</SelectContent>
</Select>
<Select
value={filters.categoryId ?? ALL_VALUE}
onValueChange={(value) => setPartial({ categoryId: value === ALL_VALUE ? null : value })}
>
<SelectTrigger className="md:w-[220px]">
<SelectValue placeholder="Categoria" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Todas as categorias</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Select
@ -186,6 +216,18 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
<SelectItem value="completed">Concluídos</SelectItem>
</SelectContent>
</Select>
<Select
value={filters.sort}
onValueChange={(value) => setPartial({ sort: value as TicketFiltersState["sort"] })}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Ordenar" />
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">Mais recentes</SelectItem>
<SelectItem value="oldest">Mais antigos</SelectItem>
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button
@ -263,6 +305,23 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-neutral-500">
Período
</p>
<div className="flex gap-2">
<Input
type="date"
value={filters.dateFrom ?? ""}
onChange={(event) => setPartial({ dateFrom: event.target.value || null })}
/>
<Input
type="date"
value={filters.dateTo ?? ""}
onChange={(event) => setPartial({ dateTo: event.target.value || null })}
/>
</div>
</div>
</PopoverContent>
</Popover>
<Button
@ -288,3 +347,32 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
</div>
)
}
function formatDateRangeChip(from: string | null, to: string | null) {
const fromLabel = formatDateValue(from)
const toLabel = formatDateValue(to)
if (fromLabel && toLabel) {
return `Período: ${fromLabel} até ${toLabel}`
}
if (fromLabel) {
return `A partir de ${fromLabel}`
}
if (toLabel) {
return `Até ${toLabel}`
}
return "Período personalizado"
}
function formatDateValue(value: string | null) {
if (!value) return null
const [year, month, day] = value.split("-").map((part) => Number(part))
if (!year || !month || !day) {
return value
}
try {
return new Date(year, month - 1, day).toLocaleDateString("pt-BR")
} catch {
return value
}
}

View file

@ -16,6 +16,7 @@ import { useDefaultQueues } from "@/hooks/use-default-queues"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { LayoutGrid, List } from "lucide-react"
import { isVisitTicket } from "@/lib/ticket-matchers"
import { useTicketCategories } from "@/hooks/use-ticket-categories"
type TicketsViewProps = {
initialFilters?: Partial<TicketFiltersState>
@ -64,6 +65,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
}, [viewMode, viewModeStorageKey])
useDefaultQueues(tenantId)
const { categories: ticketCategories } = useTicketCategories(tenantId)
const queuesEnabled = Boolean(isStaff && convexUserId)
const queuesResult = useQuery(
@ -167,9 +169,36 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
if (filters.focusVisits) {
working = working.filter((t) => isVisitTicket(t))
}
if (filters.categoryId) {
working = working.filter((t) => (t.category?.id ?? null) === filters.categoryId)
}
const fromDate = parseDateInput(filters.dateFrom)
if (fromDate) {
working = working.filter((t) => getTicketDate(t) >= fromDate.getTime())
}
const toDate = parseDateInput(filters.dateTo, { endOfDay: true })
if (toDate) {
working = working.filter((t) => getTicketDate(t) <= toDate.getTime())
}
return working
}, [tickets, filters.queue, filters.status, filters.view, filters.company, filters.focusVisits])
const sorted = [...working].sort((a, b) => {
const diff = getTicketDate(a) - getTicketDate(b)
return filters.sort === "oldest" ? diff : -diff
})
return sorted
}, [
tickets,
filters.queue,
filters.status,
filters.view,
filters.company,
filters.focusVisits,
filters.categoryId,
filters.dateFrom,
filters.dateTo,
filters.sort,
])
const previousIdsRef = useRef<string[]>([])
const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set())
@ -199,6 +228,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
queues={queues.map((q) => q.name)}
companies={companies}
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
categories={ticketCategories.map((category) => ({ id: category.id, name: category.name }))}
initialState={mergedInitialFilters}
/>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@ -257,3 +287,25 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
</div>
)
}
function parseDateInput(value: string | null, options?: { endOfDay?: boolean }) {
if (!value) return null
const [year, month, day] = value.split("-").map((part) => Number(part))
if (!year || !month || !day) return null
const date = new Date(
year,
month - 1,
day,
options?.endOfDay ? 23 : 0,
options?.endOfDay ? 59 : 0,
options?.endOfDay ? 59 : 0,
options?.endOfDay ? 999 : 0
)
if (Number.isNaN(date.getTime())) return null
return date
}
function getTicketDate(ticket: Ticket) {
const createdAt = ticket.createdAt instanceof Date ? ticket.createdAt : new Date(ticket.createdAt)
return createdAt.getTime()
}