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
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue