sistema-de-chamados/src/components/tickets/tickets-board.tsx

307 lines
11 KiB
TypeScript

"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { formatDistanceStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { LayoutGrid } from "lucide-react"
import { IconBuildingOff, IconCategory, IconUserOff } from "@tabler/icons-react"
import type { Ticket } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
import { cn } from "@/lib/utils"
import { PrioritySelect } from "@/components/tickets/priority-select"
import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
import { deriveServerOffset, toServerTimestamp } from "@/components/tickets/ticket-timer.utils"
type TicketsBoardProps = {
tickets: Ticket[]
enteringIds?: Set<string>
}
const SECOND = 1_000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
function getTimestamp(value: Date | number | string | null | undefined) {
if (value == null) return null
if (typeof value === "number") {
return Number.isFinite(value) ? value : null
}
const parsed = value instanceof Date ? value.getTime() : new Date(value).getTime()
return Number.isFinite(parsed) ? parsed : null
}
function getNextDelay(diff: number) {
if (diff < MINUTE) {
return SECOND
}
if (diff < HOUR) {
const pastMinute = diff % MINUTE
return pastMinute === 0 ? MINUTE : MINUTE - pastMinute
}
if (diff < DAY) {
const pastHour = diff % HOUR
return pastHour === 0 ? HOUR : HOUR - pastHour
}
const pastDay = diff % DAY
return pastDay === 0 ? DAY : DAY - pastDay
}
function formatUpdated(date: Date | number | string, now: number) {
const timestamp = getTimestamp(date)
if (timestamp === null) return "—"
return formatDistanceStrict(timestamp, now, { addSuffix: true, locale: ptBR })
}
function formatQueueLabel(queue?: string | null) {
if (!queue) {
return { label: "Sem fila", title: "Sem fila" }
}
const normalized = queue.trim().toLowerCase()
if (normalized.startsWith("laboratorio")) {
return { label: "Lab", title: queue }
}
return { label: queue, title: queue }
}
function formatDuration(ms?: number | null) {
if (!ms || ms <= 0) return "—"
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
}
if (minutes > 0) {
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
}
return `${seconds}s`
}
export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
const [now, setNow] = useState(() => Date.now())
const serverOffsetRef = useRef<number>(0)
const ticketTimestamps = useMemo(() => {
return tickets
.map((ticket) => getTimestamp(ticket.updatedAt))
.filter((value): value is number => value !== null)
}, [tickets])
useEffect(() => {
if (ticketTimestamps.length === 0) {
return
}
let minDelay = DAY
for (const timestamp of ticketTimestamps) {
const diff = Math.abs(now - timestamp)
const candidate = Math.max(SECOND, getNextDelay(diff))
if (candidate < minDelay) {
minDelay = candidate
}
}
const timeoutId = window.setTimeout(() => setNow(Date.now()), minDelay)
return () => window.clearTimeout(timeoutId)
}, [ticketTimestamps, now])
useEffect(() => {
const candidates = tickets
.map((ticket) =>
typeof ticket.workSummary?.serverNow === "number" ? ticket.workSummary.serverNow : null
)
.filter((value): value is number => value !== null)
if (candidates.length === 0) return
const latestServerNow = candidates[candidates.length - 1]
serverOffsetRef.current = deriveServerOffset({
currentOffset: serverOffsetRef.current,
localNow: Date.now(),
serverNow: latestServerNow,
})
}, [tickets])
const getWorkedMs = (ticket: Ticket) => {
const base = ticket.workSummary?.totalWorkedMs ?? 0
const activeStart = ticket.workSummary?.activeSession?.startedAt
if (activeStart instanceof Date) {
const alignedNow = toServerTimestamp(now, serverOffsetRef.current)
return base + Math.max(0, alignedNow - activeStart.getTime())
}
return base
}
if (!tickets.length) {
return (
<div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm">
<Empty>
<EmptyHeader>
<LayoutGrid className="mx-auto size-9 text-neutral-400" />
</EmptyHeader>
<EmptyContent>
<EmptyTitle>Nenhum ticket encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou crie um novo ticket para visualizar aqui na visão em quadro.
</EmptyDescription>
</EmptyContent>
</Empty>
</div>
)
}
return (
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
{tickets.map((ticket) => {
const isEntering = enteringIds?.has(ticket.id)
const queueDisplay = formatQueueLabel(ticket.queue)
const updatedTimestamp = getTimestamp(ticket.updatedAt)
const updatedAbsoluteLabel = updatedTimestamp
? new Date(updatedTimestamp).toLocaleString("pt-BR", {
dateStyle: "short",
timeStyle: "short",
})
: "—"
return (
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
className={cn(
"group flex h-full flex-col 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 w-full flex-col gap-3">
<div className="flex w-full items-center justify-between gap-4">
<Badge
variant="outline"
className="rounded-full border-slate-200 bg-white px-4 py-2 text-sm font-semibold tracking-tight text-neutral-700"
>
#{ticket.reference}
</Badge>
<span className="text-right text-sm font-semibold text-neutral-900" title={queueDisplay.title}>
{queueDisplay.label}
</span>
</div>
<div className="flex w-full items-center justify-between gap-4">
<span
className={cn(
"inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition",
getTicketStatusChipClass(ticket.status),
)}
>
{getTicketStatusLabel(ticket.status)}
</span>
<div
className="relative z-[1] flex justify-end"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<PrioritySelect
className="min-w-[7rem]"
badgeClassName="h-9 px-4 shadow-sm"
ticketId={ticket.id}
value={ticket.priority}
/>
</div>
</div>
</div>
<div className="mt-4 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center shadow-sm">
<h3 className="line-clamp-2 text-lg font-semibold text-neutral-900">
{ticket.subject || "Sem assunto"}
</h3>
</div>
<div className="mt-4 flex flex-1 flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<dl className="grid grid-cols-1 gap-4 text-sm text-neutral-600 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Empresa
</dt>
<dd className="text-neutral-700">
{ticket.company?.name ?? (
<EmptyIndicator
icon={IconBuildingOff}
label="Nenhuma empresa vinculada"
className="h-7 w-7 border-neutral-200 bg-transparent text-neutral-400"
/>
)}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Responsável
</dt>
<dd className="text-neutral-700">
{ticket.assignee?.name ?? (
<EmptyIndicator
icon={IconUserOff}
label="Sem responsável"
className="h-7 w-7 border-neutral-200 bg-transparent text-neutral-400"
/>
)}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Solicitante
</dt>
<dd className="text-neutral-700">
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
Criado em
</dt>
<dd className="text-neutral-700">
{(() => {
const createdTimestamp = getTimestamp(ticket.createdAt)
return createdTimestamp
? new Date(createdTimestamp).toLocaleDateString("pt-BR")
: "—"
})()}
</dd>
</div>
</dl>
<div className="mt-auto flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-4 text-sm text-neutral-600">
<span className="text-neutral-700">
Categoria:{" "}
{ticket.category?.name ? (
<span className="font-semibold text-neutral-900">{ticket.category.name}</span>
) : (
<EmptyIndicator
icon={IconCategory}
label="Sem categoria"
className="ml-2 h-7 w-7 border-neutral-200 bg-transparent text-neutral-400"
/>
)}
</span>
<span className="text-xs text-neutral-500">
Tempo:{" "}
<span className="font-semibold text-neutral-900">
{formatDuration(getWorkedMs(ticket))}
</span>
{ticket.workSummary?.activeSession ? (
<span className="ml-1 text-[11px] font-medium text-emerald-600">Em andamento</span>
) : null}
</span>
</div>
</div>
</Link>
)
})}
</div>
)
}