feat(portal): add desktop-style filters and breadcrumbs
This commit is contained in:
parent
f5898153fe
commit
004f345d92
6 changed files with 337 additions and 43 deletions
|
|
@ -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
|
|||
<SiteHeader
|
||||
title={`Ticket #${id}`}
|
||||
lead={"Detalhes do ticket"}
|
||||
breadcrumbs={<TicketBreadcrumbs ticketId={id} />}
|
||||
secondaryAction={<NewTicketDialogDeferred />}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-6">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/portal/tickets">Tickets</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Ticket #{ticket.reference}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="px-5 pb-3 pt-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
|
|
|
|||
187
src/components/portal/portal-ticket-filters.tsx
Normal file
187
src/components/portal/portal-ticket-filters.tsx
Normal file
|
|
@ -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<PortalTicketFiltersState>) => 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<PortalTicketFiltersState>) => {
|
||||
onFiltersChange(partial)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Select
|
||||
value={filters.queue ?? ALL_VALUE}
|
||||
onValueChange={(value) => handleChange({ queue: value === ALL_VALUE ? null : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Todas as filas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>Todas as filas</SelectItem>
|
||||
{queues.map((queue) => (
|
||||
<SelectItem key={queue} value={queue}>
|
||||
{queue}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.company ?? ALL_VALUE}
|
||||
onValueChange={(value) => handleChange({ company: value === ALL_VALUE ? null : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>Todas as empresas</SelectItem>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company} value={company}>
|
||||
{company}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.categoryId ?? ALL_VALUE}
|
||||
onValueChange={(value) => handleChange({ categoryId: value === ALL_VALUE ? null : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Todas as categorias" />
|
||||
</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>
|
||||
<Select
|
||||
value={filters.assigneeId ?? ALL_VALUE}
|
||||
onValueChange={(value) => handleChange({ assigneeId: value === ALL_VALUE ? null : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Todos os responsáveis" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>Todos os responsáveis</SelectItem>
|
||||
{assignees.map((assignee) => (
|
||||
<SelectItem key={assignee.id} value={assignee.id}>
|
||||
{assignee.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filters.status} onValueChange={(value) => handleChange({ status: value as PortalTicketFiltersState["status"] })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Em andamento</SelectItem>
|
||||
<SelectItem value="resolved">Concluídos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filters.sort} onValueChange={(value) => handleChange({ sort: value as PortalTicketFiltersState["sort"] })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Ordenação" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="recent">Mais recentes</SelectItem>
|
||||
<SelectItem value="oldest">Mais antigos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-neutral-800"
|
||||
>
|
||||
<IconFilter className="size-4" /> Período
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] space-y-3" align="start">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">A partir de</p>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateFrom ?? ""}
|
||||
onChange={(event) => handleChange({ dateFrom: event.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Até</p>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateTo ?? ""}
|
||||
onChange={(event) => handleChange({ dateTo: event.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{(filters.queue || filters.company || filters.categoryId || filters.assigneeId || filters.dateFrom || filters.dateTo || filters.status !== "active" || filters.sort !== "recent") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 rounded-full px-4 py-2 text-sm font-medium text-neutral-700"
|
||||
onClick={onReset}
|
||||
>
|
||||
<IconRefresh className="size-4" /> Resetar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/tickets">Tickets</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Ticket #{ticketId}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue