From 004f345d92956cfa8f7c8a170eddfd871cf03314 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Wed, 12 Nov 2025 21:03:42 -0300 Subject: [PATCH] feat(portal): add desktop-style filters and breadcrumbs --- src/app/tickets/[id]/page.tsx | 2 - .../portal/portal-ticket-detail.tsx | 22 +++ .../portal/portal-ticket-filters.tsx | 187 ++++++++++++++++++ src/components/portal/portal-ticket-list.tsx | 134 ++++++++++++- src/components/site-header.tsx | 3 - src/components/tickets/ticket-breadcrumbs.tsx | 32 --- 6 files changed, 337 insertions(+), 43 deletions(-) create mode 100644 src/components/portal/portal-ticket-filters.tsx delete mode 100644 src/components/tickets/ticket-breadcrumbs.tsx diff --git a/src/app/tickets/[id]/page.tsx b/src/app/tickets/[id]/page.tsx index 4ed9d6c..b0b7b4f 100644 --- a/src/app/tickets/[id]/page.tsx +++ b/src/app/tickets/[id]/page.tsx @@ -2,7 +2,6 @@ import { AppShell } from "@/components/app-shell" import { SiteHeader } from "@/components/site-header" import { TicketDetailView } from "@/components/tickets/ticket-detail-view" import { NewTicketDialogDeferred } from "@/components/tickets/new-ticket-dialog.client" -import { TicketBreadcrumbs } from "@/components/tickets/ticket-breadcrumbs" import { requireAuthenticatedSession } from "@/lib/auth-server" type TicketDetailPageProps = { @@ -19,7 +18,6 @@ export default async function TicketDetailPage({ params }: TicketDetailPageProps } secondaryAction={} /> } diff --git a/src/components/portal/portal-ticket-detail.tsx b/src/components/portal/portal-ticket-detail.tsx index d42a490..7dec4a2 100644 --- a/src/components/portal/portal-ticket-detail.tsx +++ b/src/components/portal/portal-ticket-detail.tsx @@ -1,6 +1,7 @@ "use client" import { useCallback, useEffect, useMemo, useState } from "react" +import Link from "next/link" import { useAction, useMutation, useQuery } from "convex/react" import { format, formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" @@ -22,6 +23,14 @@ import { Skeleton } from "@/components/ui/skeleton" // removed wrong import; RichTextEditor comes from rich-text-editor import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { sanitizeEditorHtml, RichTextEditor } from "@/components/ui/rich-text-editor" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" import { TicketStatusBadge } from "@/components/tickets/status-badge" import { Spinner } from "@/components/ui/spinner" import { TicketCsatCard } from "@/components/tickets/ticket-csat-card" @@ -355,6 +364,19 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { return (
+ + + + + Tickets + + + + + Ticket #{ticket.reference} + + +
diff --git a/src/components/portal/portal-ticket-filters.tsx b/src/components/portal/portal-ticket-filters.tsx new file mode 100644 index 0000000..1a8fa84 --- /dev/null +++ b/src/components/portal/portal-ticket-filters.tsx @@ -0,0 +1,187 @@ +"use client" + +import { IconFilter, IconRefresh } from "@tabler/icons-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" + +export type PortalTicketFiltersState = { + queue: string | null + company: string | null + categoryId: string | null + assigneeId: string | null + status: "active" | "resolved" + sort: "recent" | "oldest" + dateFrom: string | null + dateTo: string | null +} + +export const defaultPortalTicketFilters: PortalTicketFiltersState = { + queue: null, + company: null, + categoryId: null, + assigneeId: null, + status: "active", + sort: "recent", + dateFrom: null, + dateTo: null, +} + +const ALL_VALUE = "ALL" + +type Option = { id: string; name: string } + +type PortalTicketFiltersProps = { + filters: PortalTicketFiltersState + onFiltersChange: (partial: Partial) => void + onReset: () => void + queues: string[] + companies: string[] + categories: Option[] + assignees: Option[] +} + +export function PortalTicketFilters({ + filters, + onFiltersChange, + onReset, + queues, + companies, + categories, + assignees, +}: PortalTicketFiltersProps) { + const handleChange = (partial: Partial) => { + onFiltersChange(partial) + } + + return ( +
+
+ + + + + + +
+
+ + + + + +
+

A partir de

+ handleChange({ dateFrom: event.target.value || null })} + /> +
+
+

Até

+ handleChange({ dateTo: event.target.value || null })} + /> +
+
+
+ {(filters.queue || filters.company || filters.categoryId || filters.assigneeId || filters.dateFrom || filters.dateTo || filters.status !== "active" || filters.sort !== "recent") && ( + + )} +
+
+ ) +} diff --git a/src/components/portal/portal-ticket-list.tsx b/src/components/portal/portal-ticket-list.tsx index fac7bfe..7d94e96 100644 --- a/src/components/portal/portal-ticket-list.tsx +++ b/src/components/portal/portal-ticket-list.tsx @@ -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(defaultPortalTicketFilters) + + const queueOptions = useMemo(() => { + const set = new Set() + ;(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() + ;(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() + ;(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() + ;(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) => { + setFilters((prev) => ({ ...prev, ...partial })) + } + + const handleResetFilters = () => { + setFilters(defaultPortalTicketFilters) + } + const isLoading = Boolean(viewerId && ticketsRaw === undefined) if (isLoading) { @@ -107,11 +206,34 @@ export function PortalTicketList() {
) : null} -
- {(tickets as Ticket[]).map((ticket) => ( - - ))} -
+ + {filteredTickets.length === 0 ? ( + + + Nenhum ticket corresponde aos filtros +

+ Ajuste as opções acima ou limpe os filtros para visualizar novamente. +

+ +
+
+ ) : ( +
+ {filteredTickets.map((ticket) => ( + + ))} +
+ )}
) } diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 1722403..287d515 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -10,7 +10,6 @@ import { cn } from "@/lib/utils" interface SiteHeaderProps { title: string lead?: string - breadcrumbs?: ReactNode primaryAction?: ReactNode secondaryAction?: ReactNode primaryAlignment?: "right" | "center" @@ -19,7 +18,6 @@ interface SiteHeaderProps { function SiteHeaderBase({ title, lead, - breadcrumbs, primaryAction, secondaryAction, primaryAlignment = "right", @@ -34,7 +32,6 @@ function SiteHeaderBase({
- {breadcrumbs ?
{breadcrumbs}
: null} {lead ? {lead} : null}

{title}

diff --git a/src/components/tickets/ticket-breadcrumbs.tsx b/src/components/tickets/ticket-breadcrumbs.tsx deleted file mode 100644 index d97fbe5..0000000 --- a/src/components/tickets/ticket-breadcrumbs.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 ( - - - - - Tickets - - - - - Ticket #{ticketId} - - - - ) -}