307 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|