Add live ticket animations and fix sidebar hydration

This commit is contained in:
codex-bot 2025-10-24 17:24:51 -03:00
parent 2a9170f7dd
commit ddbf019d12
6 changed files with 90 additions and 32 deletions

View file

@ -39,6 +39,13 @@ Este documento consolida o estado atual do ambiente de desenvolvimento, descreve
- **Turbopack** segue como bundler padrão, sem flags experimentais adicionais.
- **Whitelist de hosts**: o release estável 15.5 não aceita `server.allowedHosts` (vide [`invalid-next-config`](https://nextjs.org/docs/messages/invalid-next-config)), portanto bloqueamos domínios exclusivamente via `middleware.ts`.
### Editor rich text (TipTap) — menções de ticket
- Menções (`ticketMention`) agora têm prioridade maior (`priority: 1000`) e um Link seguro (`SafeLinkExtension`) foi introduzido para ignorar `<a data-ticket-mention="true">`. Isso evita que o `Link` do StarterKit capture as âncoras na hidratação, garantindo que as menções continuem como nodes (não como marks) durante a edição.
- O mesmo helper `normalizeTicketMentionHtml` é aplicado ao carregar/atualizar conteúdo no editor, dentro dos fluxos de comentários e no Convex. Esse helper reescreve qualquer HTML legado (`#123•Assunto`) no formato de chip completo (datasets, spans, dot).
- Resultado: o chip mantém layout e comportamento ao editar (Backspace/Delete removem o node inteiro, node view continua ativo) sem exigir reload.
- Se precisar adicionar novos comportamentos, importe `SafeLinkExtension` e mantenha a ordem `[TicketMentionExtension, StarterKit (link:false), SafeLinkExtension, Placeholder]` para que o parser continue estável.
## Comandos de qualidade
- `pnpm lint`: executa ESLint (flat config) sobre os arquivos do projeto.

View file

@ -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 justify-between">
<span>Aguardando resposta</span>
<span className="font-semibold text-neutral-900">{queue.waiting}</span>
<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 items-center justify-between">
<span>Violados</span>
<span className="font-semibold text-red-600">{queue.breached}</span>
<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 className="pt-1.5">
</div>
<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

View file

@ -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>
)
}

View file

@ -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
key={ticket.id}
className={`${tableRowClass} cursor-pointer`}
className={rowClass}
role="link"
tabIndex={0}
onClick={() => router.push(`/tickets/${ticket.id}`)}

View file

@ -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>
)

View file

@ -258,8 +258,24 @@ function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
}: React.ComponentProps<typeof Button> & { fallback?: ReactNode }) {
const { toggleSidebar } = useSidebar()
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(true)
}, [])
if (!hydrated) {
return (
<div
data-sidebar="trigger"
data-slot="sidebar-trigger"
className={cn("size-7 rounded-full border border-slate-200 bg-white", className)}
role="presentation"
/>
)
}
return (
<Button