feat(portal): add desktop-style filters and breadcrumbs

This commit is contained in:
Esdras Renan 2025-11-12 21:03:42 -03:00
parent f5898153fe
commit 004f345d92
6 changed files with 337 additions and 43 deletions

View file

@ -1,6 +1,6 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -14,6 +14,11 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/
import { Spinner } from "@/components/ui/spinner"
import { Button } from "@/components/ui/button"
import { PortalTicketCard } from "@/components/portal/portal-ticket-card"
import {
PortalTicketFilters,
defaultPortalTicketFilters,
type PortalTicketFiltersState,
} from "@/components/portal/portal-ticket-filters"
export function PortalTicketList() {
const { convexUserId, session, machineContext } = useAuth()
@ -36,6 +41,51 @@ export function PortalTicketList() {
return mapTicketsFromServerList((ticketsRaw as unknown[]) ?? [])
}, [ticketsRaw])
const [filters, setFilters] = useState<PortalTicketFiltersState>(defaultPortalTicketFilters)
const queueOptions = useMemo(() => {
const set = new Set<string>()
;(tickets as Ticket[]).forEach((ticket) => {
if (ticket.queue) {
set.add(ticket.queue)
}
})
return Array.from(set).sort((a, b) => a.localeCompare(b, "pt-BR"))
}, [tickets])
const companyOptions = useMemo(() => {
const set = new Set<string>()
;(tickets as Ticket[]).forEach((ticket) => {
const name = ticket.company?.name
if (name) set.add(name)
})
return Array.from(set).sort((a, b) => a.localeCompare(b, "pt-BR"))
}, [tickets])
const categoryOptions = useMemo(() => {
const map = new Map<string, string>()
;(tickets as Ticket[]).forEach((ticket) => {
if (ticket.category?.id && ticket.category.name) {
map.set(ticket.category.id, ticket.category.name)
}
})
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
}, [tickets])
const assigneeOptions = useMemo(() => {
const map = new Map<string, string>()
;(tickets as Ticket[]).forEach((ticket) => {
if (ticket.assignee?.id && ticket.assignee.name) {
map.set(ticket.assignee.id, ticket.assignee.name)
}
})
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
}, [tickets])
const lastResolvedNoCsat = useMemo(() => {
const resolved = (tickets as Ticket[])
.filter((t) => t.status === "RESOLVED" && (t.csatScore == null))
@ -43,6 +93,55 @@ export function PortalTicketList() {
return resolved[0] ?? null
}, [tickets])
const 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
}
const filteredTickets = useMemo(() => {
const fromDate = parseDateInput(filters.dateFrom)
const toDate = parseDateInput(filters.dateTo, { endOfDay: true })
return (tickets as Ticket[])
.filter((ticket) => {
if (filters.queue && ticket.queue !== filters.queue) return false
if (filters.company && ticket.company?.name !== filters.company) return false
if (filters.categoryId && ticket.category?.id !== filters.categoryId) return false
if (filters.assigneeId && ticket.assignee?.id !== filters.assigneeId) return false
const isResolved = ticket.status === "RESOLVED"
if (filters.status === "active" && isResolved) return false
if (filters.status === "resolved" && !isResolved) return false
const createdAt = ticket.createdAt instanceof Date ? ticket.createdAt : new Date(ticket.createdAt)
if (fromDate && createdAt < fromDate) return false
if (toDate && createdAt > toDate) return false
return true
})
.sort((a, b) => {
const aDate = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime()
const bDate = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime()
return filters.sort === "oldest" ? aDate - bDate : bDate - aDate
})
}, [filters, tickets])
const handleFiltersChange = (partial: Partial<PortalTicketFiltersState>) => {
setFilters((prev) => ({ ...prev, ...partial }))
}
const handleResetFilters = () => {
setFilters(defaultPortalTicketFilters)
}
const isLoading = Boolean(viewerId && ticketsRaw === undefined)
if (isLoading) {
@ -107,11 +206,34 @@ export function PortalTicketList() {
</Link>
</div>
) : null}
<div className="grid gap-4">
{(tickets as Ticket[]).map((ticket) => (
<PortalTicketCard key={ticket.id} ticket={ticket} />
))}
</div>
<PortalTicketFilters
filters={filters}
onFiltersChange={handleFiltersChange}
onReset={handleResetFilters}
queues={queueOptions}
companies={companyOptions}
categories={categoryOptions}
assignees={assigneeOptions}
/>
{filteredTickets.length === 0 ? (
<Card className="rounded-2xl border border-slate-200 bg-white py-8 text-center shadow-sm">
<CardContent className="space-y-3">
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhum ticket corresponde aos filtros</CardTitle>
<p className="text-sm text-neutral-600">
Ajuste as opções acima ou limpe os filtros para visualizar novamente.
</p>
<Button variant="outline" onClick={handleResetFilters} className="mt-2 rounded-full">
Limpar filtros
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{filteredTickets.map((ticket) => (
<PortalTicketCard key={ticket.id} ticket={ticket} />
))}
</div>
)}
</div>
)
}