Add live ticket animations and fix sidebar hydration
This commit is contained in:
parent
2a9170f7dd
commit
ddbf019d12
6 changed files with 90 additions and 32 deletions
|
|
@ -46,20 +46,22 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
|||
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">{queue.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm text-neutral-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Pendentes</span>
|
||||
<span className="font-semibold text-neutral-900">{queue.pending}</span>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="flex h-full flex-col justify-between gap-2 rounded-xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-100 px-4 py-3 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Pendentes</p>
|
||||
<p className="text-3xl font-bold tracking-tight text-neutral-900 tabular-nums">{queue.pending}</p>
|
||||
</div>
|
||||
<div className="flex h-full flex-col justify-between gap-2 rounded-xl border border-sky-200 bg-gradient-to-br from-white via-white to-sky-50 px-4 py-3 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-sky-700">Aguardando resposta</p>
|
||||
<p className="text-3xl font-bold tracking-tight text-sky-700 tabular-nums">{queue.waiting}</p>
|
||||
</div>
|
||||
<div className="flex h-full flex-col justify-between gap-2 rounded-xl border border-amber-200 bg-gradient-to-br from-white via-white to-amber-50 px-4 py-3 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-amber-700">Violados</p>
|
||||
<p className="text-3xl font-bold tracking-tight text-amber-700 tabular-nums">{queue.breached}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Aguardando resposta</span>
|
||||
<span className="font-semibold text-neutral-900">{queue.waiting}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Violados</span>
|
||||
<span className="font-semibold text-red-600">{queue.breached}</span>
|
||||
</div>
|
||||
<div className="pt-1.5">
|
||||
<div className="pt-1">
|
||||
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
|
||||
<span className="mt-2 block text-xs text-neutral-500">
|
||||
{breachPercent}% com SLA violado nesta fila
|
||||
|
|
|
|||
|
|
@ -14,13 +14,14 @@ import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-sta
|
|||
|
||||
type TicketsBoardProps = {
|
||||
tickets: Ticket[]
|
||||
enteringIds?: Set<string>
|
||||
}
|
||||
|
||||
function formatUpdated(date: Date) {
|
||||
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
|
||||
}
|
||||
|
||||
export function TicketsBoard({ tickets }: TicketsBoardProps) {
|
||||
export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
||||
if (!tickets.length) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm">
|
||||
|
|
@ -41,11 +42,16 @@ export function TicketsBoard({ tickets }: TicketsBoardProps) {
|
|||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{tickets.map((ticket) => (
|
||||
{tickets.map((ticket) => {
|
||||
const isEntering = enteringIds?.has(ticket.id)
|
||||
return (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="group block h-full rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300"
|
||||
className={cn(
|
||||
"group block h-full rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300",
|
||||
isEntering ? "recent-ticket-enter" : ""
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
|
@ -103,7 +109,8 @@ export function TicketsBoard({ tickets }: TicketsBoardProps) {
|
|||
</div>
|
||||
</dl>
|
||||
</Link>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,9 +95,10 @@ function AssigneeCell({ ticket }: { ticket: Ticket }) {
|
|||
|
||||
export type TicketsTableProps = {
|
||||
tickets?: Ticket[]
|
||||
enteringIds?: Set<string>
|
||||
}
|
||||
|
||||
export function TicketsTable({ tickets }: TicketsTableProps) {
|
||||
export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
|
||||
const safeTickets = tickets ?? []
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const serverOffsetRef = useRef<number>(0)
|
||||
|
|
@ -175,11 +176,15 @@ export function TicketsTable({ tickets }: TicketsTableProps) {
|
|||
<TableBody>
|
||||
{safeTickets.map((ticket) => {
|
||||
const ChannelIcon = channelIcon[ticket.channel] ?? MessageCircle
|
||||
const rowClass = cn(
|
||||
`${tableRowClass} cursor-pointer`,
|
||||
enteringIds?.has(ticket.id) ? "recent-ticket-enter" : undefined,
|
||||
)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
<TableRow
|
||||
key={ticket.id}
|
||||
className={`${tableRowClass} cursor-pointer`}
|
||||
className={rowClass}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={() => router.push(`/tickets/${ticket.id}`)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -165,6 +165,27 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
|||
return working
|
||||
}, [tickets, filters.queue, filters.status, filters.view, filters.company])
|
||||
|
||||
const previousIdsRef = useRef<string[]>([])
|
||||
const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
if (ticketsRaw === undefined) {
|
||||
previousIdsRef.current = []
|
||||
setEnteringIds(new Set())
|
||||
return
|
||||
}
|
||||
const ids = filteredTickets.map((ticket) => ticket.id)
|
||||
const previous = previousIdsRef.current
|
||||
previousIdsRef.current = ids
|
||||
if (!previous.length) return
|
||||
const newIds = ids.filter((id) => !previous.includes(id))
|
||||
if (newIds.length === 0) return
|
||||
const highlight = new Set(newIds)
|
||||
setEnteringIds(highlight)
|
||||
const timeout = window.setTimeout(() => setEnteringIds(new Set()), 600)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [filteredTickets, ticketsRaw])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketsFilters
|
||||
|
|
@ -223,9 +244,9 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
|||
</div>
|
||||
</div>
|
||||
) : viewMode === "board" ? (
|
||||
<TicketsBoard tickets={filteredTickets} />
|
||||
<TicketsBoard tickets={filteredTickets} enteringIds={enteringIds} />
|
||||
) : (
|
||||
<TicketsTable tickets={filteredTickets} />
|
||||
<TicketsTable tickets={filteredTickets} enteringIds={enteringIds} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue