239 lines
9.1 KiB
TypeScript
239 lines
9.1 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import { useQuery } from "convex/react"
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
|
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
|
|
import type { Ticket } from "@/lib/schemas/ticket"
|
|
import { useAuth } from "@/lib/auth-client"
|
|
import Link from "next/link"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
|
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()
|
|
|
|
const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null
|
|
|
|
const ticketsRaw = useQuery(
|
|
api.tickets.list,
|
|
viewerId
|
|
? {
|
|
tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID,
|
|
viewerId,
|
|
limit: 100,
|
|
}
|
|
: "skip"
|
|
)
|
|
|
|
const tickets = useMemo(() => {
|
|
if (!ticketsRaw) return []
|
|
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))
|
|
.sort((a, b) => (b.resolvedAt?.getTime?.() ?? 0) - (a.resolvedAt?.getTime?.() ?? 0))
|
|
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) {
|
|
return (
|
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<CardContent className="flex h-56 flex-col items-center justify-center gap-3 px-5 text-center">
|
|
<div className="inline-flex size-12 items-center justify-center rounded-full border border-slate-200 bg-slate-50">
|
|
<Spinner className="size-5 text-neutral-600" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<CardTitle className="text-lg font-semibold text-neutral-900">Carregando chamados...</CardTitle>
|
|
<p className="text-sm text-neutral-600">
|
|
Estamos buscando seus chamados mais recentes. Isso deve levar apenas alguns instantes.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
if (!viewerId || !tickets.length) {
|
|
return (
|
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<CardHeader className="px-5 py-5">
|
|
<CardTitle className="text-lg font-semibold text-neutral-900">Meus chamados</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-5 pb-6">
|
|
<Empty>
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<span className="inline-block size-3 rounded-full border border-slate-300 bg-[#00e8ff]" />
|
|
</EmptyMedia>
|
|
<EmptyTitle className="text-neutral-900">Nenhum ticket encontrado</EmptyTitle>
|
|
<EmptyDescription className="text-neutral-600">
|
|
Ajuste os filtros ou crie um novo ticket.
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
<div className="mt-4">
|
|
<Button asChild className="rounded-full bg-neutral-900 px-4 text-sm font-semibold text-white hover:bg-neutral-900/90">
|
|
<Link href="/portal/tickets/new">Novo ticket</Link>
|
|
</Button>
|
|
</div>
|
|
</Empty>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-neutral-900">Meus chamados</h2>
|
|
<p className="text-sm text-neutral-600">Acompanhe seus tickets e veja as últimas atualizações.</p>
|
|
</div>
|
|
</div>
|
|
{lastResolvedNoCsat ? (
|
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
|
Como foi seu último atendimento? Avalie o chamado #{lastResolvedNoCsat.reference}.{' '}
|
|
<Link href={`/portal/tickets/${lastResolvedNoCsat.id}#csat`} className="font-semibold underline">
|
|
Avaliar agora
|
|
</Link>
|
|
</div>
|
|
) : null}
|
|
<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>
|
|
)
|
|
}
|