feat: dispositivos e ajustes de csat e relatórios
This commit is contained in:
parent
25d2a9b062
commit
e0ef66555d
86 changed files with 5811 additions and 992 deletions
218
src/components/admin/devices/admin-device-details.client.tsx
Normal file
218
src/components/admin/devices/admin-device-details.client.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import {
|
||||
DeviceDetails,
|
||||
normalizeDeviceItem,
|
||||
type DevicesQueryItem,
|
||||
} from "@/components/admin/devices/admin-devices-overview"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
export function AdminDeviceDetailsClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId?: string }) {
|
||||
const router = useRouter()
|
||||
const params = useParams<{ id?: string | string[] }>()
|
||||
const routeDeviceId = Array.isArray(params?.id) ? params?.id[0] : params?.id
|
||||
const effectiveDeviceId = deviceId ?? routeDeviceId ?? ""
|
||||
|
||||
const canLoadDevice = Boolean(effectiveDeviceId)
|
||||
|
||||
const single = useQuery(
|
||||
api.devices.getById,
|
||||
canLoadDevice
|
||||
? ({ id: effectiveDeviceId as Id<"machines">, includeMetadata: true } as const)
|
||||
: "skip"
|
||||
)
|
||||
|
||||
// Fallback via HTTP in caso de o Convex React demorar/ficar preso em loading
|
||||
const [fallback, setFallback] = useState<Record<string, unknown> | null | undefined>(undefined)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [retryTick, setRetryTick] = useState(0)
|
||||
const shouldLoad = fallback === undefined && Boolean(effectiveDeviceId)
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsHydrated(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLoad) return
|
||||
let cancelled = false
|
||||
|
||||
const probe = async () => {
|
||||
try {
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (convexUrl) {
|
||||
try {
|
||||
const http = new ConvexHttpClient(convexUrl)
|
||||
const data = (await http.query(api.devices.getById, {
|
||||
id: effectiveDeviceId as Id<"machines">,
|
||||
includeMetadata: true,
|
||||
})) as Record<string, unknown> | null
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (data) {
|
||||
setFallback(data)
|
||||
setLoadError(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
setFallback(null)
|
||||
setLoadError(null)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/devices/${effectiveDeviceId}/details`, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
let payload: Record<string, unknown> | null = null
|
||||
try {
|
||||
payload = (await res.json()) as Record<string, unknown> | null
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
setFallback(payload ?? null)
|
||||
setLoadError(null)
|
||||
return
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof payload?.error === "string" && payload.error
|
||||
? payload.error
|
||||
: `Falha ao carregar (HTTP ${res.status})`
|
||||
|
||||
if (res.status === 404) {
|
||||
setFallback(null)
|
||||
setLoadError(null)
|
||||
} else {
|
||||
setLoadError(message)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("[admin-device-details] API fallback fetch failed", err)
|
||||
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("[admin-device-details] Unexpected probe failure", err)
|
||||
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
probe().catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.error("[admin-device-details] Probe promise rejected", err)
|
||||
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [shouldLoad, effectiveDeviceId, retryTick])
|
||||
|
||||
// Timeout de proteção: se depois de X segundos ainda estiver carregando e sem fallback, mostra erro claro
|
||||
useEffect(() => {
|
||||
if (!shouldLoad) return
|
||||
const timeout = setTimeout(() => {
|
||||
setLoadError((error) =>
|
||||
error ?? "Tempo esgotado ao consultar os dados (Convex). Verifique sua conexão e tente novamente."
|
||||
)
|
||||
}, 10_000)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [shouldLoad, effectiveDeviceId, retryTick])
|
||||
|
||||
const device: DevicesQueryItem | null = useMemo(() => {
|
||||
const source = single ?? (fallback === undefined ? undefined : fallback)
|
||||
if (source === undefined || source === null) return source as null
|
||||
return normalizeDeviceItem(source)
|
||||
}, [single, fallback])
|
||||
const isLoading = single === undefined && fallback === undefined && !loadError
|
||||
const isNotFound = (single === null || fallback === null) && !loadError
|
||||
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const onRetry = () => {
|
||||
setLoadError(null)
|
||||
setFallback(undefined)
|
||||
setRetryTick((t) => t + 1)
|
||||
// força revalidação de RSC/convex subscription
|
||||
try {
|
||||
router.refresh()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (loadError && !device) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<p className="text-sm font-medium text-red-600">Falha ao carregar os dados da dispositivo</p>
|
||||
<p className="text-sm text-muted-foreground">{loadError}</p>
|
||||
<div className="pt-2 flex items-center gap-2">
|
||||
<Button size="sm" onClick={onRetry}>Tentar novamente</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (isNotFound) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<p className="text-sm font-medium text-red-600">Dispositivo não encontrada</p>
|
||||
<p className="text-sm text-muted-foreground">Verifique o identificador e tente novamente.</p>
|
||||
<div className="pt-2 flex items-center gap-2">
|
||||
<Button size="sm" onClick={onRetry}>Recarregar</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return <DeviceDetails device={device} />
|
||||
}
|
||||
5522
src/components/admin/devices/admin-devices-overview.tsx
Normal file
5522
src/components/admin/devices/admin-devices-overview.tsx
Normal file
File diff suppressed because it is too large
Load diff
65
src/components/admin/devices/device-breadcrumbs.client.tsx
Normal file
65
src/components/admin/devices/device-breadcrumbs.client.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useMemo } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
||||
type BreadcrumbSegment = {
|
||||
label: string
|
||||
href?: string | null
|
||||
}
|
||||
|
||||
type DeviceBreadcrumbsProps = {
|
||||
tenantId: string
|
||||
deviceId: string
|
||||
deviceHref?: string | null
|
||||
extra?: BreadcrumbSegment[]
|
||||
}
|
||||
|
||||
export function DeviceBreadcrumbs({ tenantId: _tenantId, deviceId, deviceHref, extra }: DeviceBreadcrumbsProps) {
|
||||
const { convexUserId } = useAuth()
|
||||
const canLoadDevice = Boolean(deviceId && convexUserId)
|
||||
const item = useQuery(
|
||||
api.devices.getById,
|
||||
canLoadDevice
|
||||
? ({ id: deviceId as Id<"machines">, includeMetadata: false } as const)
|
||||
: "skip"
|
||||
)
|
||||
const hostname = useMemo(() => item?.hostname ?? "Detalhe", [item])
|
||||
const segments = useMemo(() => {
|
||||
const trail: BreadcrumbSegment[] = [
|
||||
{ label: "Dispositivos", href: "/admin/devices" },
|
||||
{ label: hostname, href: deviceHref ?? undefined },
|
||||
]
|
||||
if (Array.isArray(extra) && extra.length > 0) {
|
||||
trail.push(...extra.filter((segment): segment is BreadcrumbSegment => Boolean(segment?.label)))
|
||||
}
|
||||
return trail
|
||||
}, [hostname, deviceHref, extra])
|
||||
|
||||
return (
|
||||
<nav className="mb-4 text-sm text-neutral-600">
|
||||
<ol className="flex items-center gap-2">
|
||||
{segments.map((segment, index) => {
|
||||
const isLast = index === segments.length - 1
|
||||
const content = segment.href && !isLast ? (
|
||||
<Link href={segment.href} className="underline-offset-4 hover:underline">
|
||||
{segment.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={isLast ? "text-neutral-800" : "text-neutral-600"}>{segment.label}</span>
|
||||
)
|
||||
return (
|
||||
<li key={`${segment.label}-${index}`} className="flex items-center gap-2">
|
||||
{content}
|
||||
{!isLast ? <span className="text-neutral-400">/</span> : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
450
src/components/admin/devices/device-tickets-history.client.tsx
Normal file
450
src/components/admin/devices/device-tickets-history.client.tsx
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePaginatedQuery, useQuery } from "convex/react"
|
||||
import { format, formatDistanceToNowStrict } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconUserOff } from "@tabler/icons-react"
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
|
||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { EmptyIndicator } from "@/components/ui/empty-indicator"
|
||||
|
||||
type DeviceTicketHistoryItem = {
|
||||
id: string
|
||||
reference: number
|
||||
subject: string
|
||||
status: TicketStatus
|
||||
priority: TicketPriority | string
|
||||
updatedAt: number
|
||||
createdAt: number
|
||||
queue: string | null
|
||||
requester: { name: string | null; email: string | null } | null
|
||||
assignee: { name: string | null; email: string | null } | null
|
||||
}
|
||||
|
||||
type DeviceTicketsHistoryArgs = {
|
||||
machineId: Id<"machines">
|
||||
status?: "open" | "resolved"
|
||||
priority?: string
|
||||
search?: string
|
||||
from?: number
|
||||
to?: number
|
||||
}
|
||||
|
||||
type DeviceTicketsHistoryStats = {
|
||||
total: number
|
||||
openCount: number
|
||||
resolvedCount: number
|
||||
}
|
||||
|
||||
type PeriodPreset = "7d" | "30d" | "90d" | "year" | "all" | "custom"
|
||||
|
||||
function startOfDayMs(date: Date) {
|
||||
const copy = new Date(date)
|
||||
copy.setHours(0, 0, 0, 0)
|
||||
return copy.getTime()
|
||||
}
|
||||
|
||||
function endOfDayMs(date: Date) {
|
||||
const copy = new Date(date)
|
||||
copy.setHours(23, 59, 59, 999)
|
||||
return copy.getTime()
|
||||
}
|
||||
|
||||
function parseDateInput(value: string) {
|
||||
if (!value) return null
|
||||
const parsed = new Date(`${value}T00:00:00`)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function computeRange(preset: PeriodPreset, customFrom: string, customTo: string) {
|
||||
if (preset === "all") {
|
||||
return { from: null, to: null }
|
||||
}
|
||||
if (preset === "custom") {
|
||||
const fromDate = parseDateInput(customFrom)
|
||||
const toDate = parseDateInput(customTo)
|
||||
return {
|
||||
from: fromDate ? startOfDayMs(fromDate) : null,
|
||||
to: toDate ? endOfDayMs(toDate) : null,
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const end = endOfDayMs(now)
|
||||
const start = new Date(now)
|
||||
|
||||
switch (preset) {
|
||||
case "7d":
|
||||
start.setDate(start.getDate() - 6)
|
||||
break
|
||||
case "30d":
|
||||
start.setDate(start.getDate() - 29)
|
||||
break
|
||||
case "90d":
|
||||
start.setDate(start.getDate() - 89)
|
||||
break
|
||||
case "year":
|
||||
start.setMonth(0, 1)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return { from: startOfDayMs(start), to: end }
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp?: number | null) {
|
||||
if (!timestamp || timestamp <= 0) return "—"
|
||||
return formatDistanceToNowStrict(timestamp, { addSuffix: true, locale: ptBR })
|
||||
}
|
||||
|
||||
function formatAbsoluteTime(timestamp?: number | null) {
|
||||
if (!timestamp || timestamp <= 0) return "—"
|
||||
return format(timestamp, "dd/MM/yyyy HH:mm", { locale: ptBR })
|
||||
}
|
||||
|
||||
function getPriorityMeta(priority: TicketPriority | string | null | undefined) {
|
||||
const normalized = (priority ?? "MEDIUM").toString().toUpperCase()
|
||||
switch (normalized) {
|
||||
case "LOW":
|
||||
return { label: "Baixa", badgeClass: "bg-emerald-100 text-emerald-700 border border-emerald-200" }
|
||||
case "MEDIUM":
|
||||
return { label: "Média", badgeClass: "bg-sky-100 text-sky-700 border border-sky-200" }
|
||||
case "HIGH":
|
||||
return { label: "Alta", badgeClass: "bg-amber-100 text-amber-700 border border-amber-200" }
|
||||
case "URGENT":
|
||||
return { label: "Urgente", badgeClass: "bg-rose-100 text-rose-700 border border-rose-200" }
|
||||
default:
|
||||
return { label: normalized, badgeClass: "bg-slate-100 text-slate-700 border border-slate-200" }
|
||||
}
|
||||
}
|
||||
|
||||
export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId: string }) {
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
|
||||
const [periodPreset, setPeriodPreset] = useState<PeriodPreset>("90d")
|
||||
const [customFrom, setCustomFrom] = useState<string>("")
|
||||
const [customTo, setCustomTo] = useState<string>("")
|
||||
const [searchValue, setSearchValue] = useState<string>("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState<string>("")
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchValue.trim())
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (periodPreset !== "custom") {
|
||||
setCustomFrom("")
|
||||
setCustomTo("")
|
||||
}
|
||||
}, [periodPreset])
|
||||
|
||||
const range = useMemo(() => computeRange(periodPreset, customFrom, customTo), [periodPreset, customFrom, customTo])
|
||||
|
||||
const queryArgs = useMemo(() => {
|
||||
const args: DeviceTicketsHistoryArgs = {
|
||||
machineId: deviceId as Id<"machines">,
|
||||
}
|
||||
if (statusFilter !== "all") {
|
||||
args.status = statusFilter
|
||||
}
|
||||
if (priorityFilter !== "ALL") {
|
||||
args.priority = priorityFilter
|
||||
}
|
||||
if (debouncedSearch) {
|
||||
args.search = debouncedSearch
|
||||
}
|
||||
if (range.from !== null) {
|
||||
args.from = range.from
|
||||
}
|
||||
if (range.to !== null) {
|
||||
args.to = range.to
|
||||
}
|
||||
return args
|
||||
}, [debouncedSearch, deviceId, priorityFilter, range.from, range.to, statusFilter])
|
||||
|
||||
const { results: tickets, status: paginationStatus, loadMore } = usePaginatedQuery(
|
||||
api.devices.listTicketsHistory,
|
||||
queryArgs,
|
||||
{ initialNumItems: 25 }
|
||||
)
|
||||
|
||||
const stats = useQuery(api.devices.getTicketsHistoryStats, queryArgs) as DeviceTicketsHistoryStats | undefined
|
||||
const totalTickets = stats?.total ?? 0
|
||||
const openTickets = stats?.openCount ?? 0
|
||||
const resolvedTickets = stats?.resolvedCount ?? 0
|
||||
|
||||
const isLoadingFirstPage = paginationStatus === "LoadingFirstPage"
|
||||
const isLoadingMore = paginationStatus === "LoadingMore"
|
||||
const canLoadMore = paginationStatus === "CanLoadMore"
|
||||
|
||||
const resetFilters = () => {
|
||||
setStatusFilter("all")
|
||||
setPriorityFilter("ALL")
|
||||
setPeriodPreset("90d")
|
||||
setCustomFrom("")
|
||||
setCustomTo("")
|
||||
setSearchValue("")
|
||||
setDebouncedSearch("")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-4 shadow-sm">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">Chamados no período</p>
|
||||
<p className="pt-1 text-2xl font-semibold text-neutral-900">
|
||||
{stats ? (
|
||||
totalTickets
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2 text-sm text-neutral-500">
|
||||
<Spinner className="size-4 text-neutral-400" /> Atualizando...
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4 shadow-sm">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-emerald-700">Em aberto</p>
|
||||
<p className="pt-1 text-2xl font-semibold text-emerald-900">{stats ? openTickets : "—"}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 shadow-sm">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-600">Resolvidos</p>
|
||||
<p className="pt-1 text-2xl font-semibold text-neutral-900">{stats ? resolvedTickets : "—"}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm lg:p-6">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder="Buscar por assunto, #ID, solicitante ou responsável"
|
||||
className="sm:max-w-sm"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as typeof statusFilter)}>
|
||||
<SelectTrigger className="sm:w-[160px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os status</SelectItem>
|
||||
<SelectItem value="open">Em aberto</SelectItem>
|
||||
<SelectItem value="resolved">Resolvidos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={priorityFilter} onValueChange={(value) => setPriorityFilter(value)}>
|
||||
<SelectTrigger className="sm:w-[160px]">
|
||||
<SelectValue placeholder="Prioridade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">Todas as prioridades</SelectItem>
|
||||
<SelectItem value="URGENT">Urgente</SelectItem>
|
||||
<SelectItem value="HIGH">Alta</SelectItem>
|
||||
<SelectItem value="MEDIUM">Média</SelectItem>
|
||||
<SelectItem value="LOW">Baixa</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Select value={periodPreset} onValueChange={(value) => setPeriodPreset(value as PeriodPreset)}>
|
||||
<SelectTrigger className="sm:w-[200px]">
|
||||
<SelectValue placeholder="Período" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7d">Últimos 7 dias</SelectItem>
|
||||
<SelectItem value="30d">Últimos 30 dias</SelectItem>
|
||||
<SelectItem value="90d">Últimos 90 dias</SelectItem>
|
||||
<SelectItem value="year">Este ano</SelectItem>
|
||||
<SelectItem value="all">Desde sempre</SelectItem>
|
||||
<SelectItem value="custom">Personalizado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{periodPreset === "custom" ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
type="date"
|
||||
value={customFrom}
|
||||
onChange={(event) => setCustomFrom(event.target.value)}
|
||||
className="sm:w-[160px]"
|
||||
placeholder="Início"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={customTo}
|
||||
onChange={(event) => setCustomTo(event.target.value)}
|
||||
className="sm:w-[160px]"
|
||||
placeholder="Fim"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={resetFilters}>
|
||||
Limpar filtros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-0 shadow-sm">
|
||||
{isLoadingFirstPage ? (
|
||||
<div className="flex items-center justify-center gap-2 py-12">
|
||||
<Spinner className="size-5 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">Carregando chamados...</span>
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<Empty className="m-4 border-dashed">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Nenhum chamado encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste os filtros ou expanda o período para visualizar o histórico de chamados desta dispositivo.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button variant="outline" size="sm" onClick={resetFilters}>
|
||||
Limpar filtros
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-slate-200 bg-slate-50/60">
|
||||
<TableHead className="min-w-[260px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Ticket
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Prioridade
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Última atualização
|
||||
</TableHead>
|
||||
<TableHead className="w-[200px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Responsável
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tickets.map((ticket) => {
|
||||
const priorityMeta = getPriorityMeta(ticket.priority)
|
||||
const requesterLabel = ticket.requester?.name ?? ticket.requester?.email ?? "Solicitante não informado"
|
||||
const updatedLabel = formatRelativeTime(ticket.updatedAt)
|
||||
const updatedAbsolute = formatAbsoluteTime(ticket.updatedAt)
|
||||
return (
|
||||
<TableRow key={ticket.id} className="border-b border-slate-100 hover:bg-slate-50/70">
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="text-sm font-semibold text-neutral-900 underline-offset-4 hover:underline"
|
||||
>
|
||||
#{ticket.reference} · {ticket.subject}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
||||
<span>{requesterLabel}</span>
|
||||
{ticket.queue ? (
|
||||
<span className="inline-flex items-center rounded-full bg-slate-200/70 px-2 py-0.5 text-[11px] font-medium text-neutral-600">
|
||||
{ticket.queue}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-neutral-400">Aberto em {formatAbsoluteTime(ticket.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
|
||||
{priorityMeta.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-col text-xs text-neutral-600">
|
||||
<span className="font-medium text-neutral-800">{updatedLabel}</span>
|
||||
<span className="text-[11px] text-neutral-400">{updatedAbsolute}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-col items-start text-sm text-neutral-700">
|
||||
{ticket.assignee ? (
|
||||
<>
|
||||
<span>{ticket.assignee.name ?? ticket.assignee.email ?? "—"}</span>
|
||||
{ticket.assignee.email ? (
|
||||
<span className="text-xs text-neutral-400">{ticket.assignee.email}</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyIndicator
|
||||
icon={IconUserOff}
|
||||
label="Sem responsável"
|
||||
className="h-7 w-7 border-neutral-200 bg-transparent text-neutral-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 px-4 py-4 text-center">
|
||||
{canLoadMore || isLoadingMore ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => loadMore(25)}
|
||||
disabled={isLoadingMore}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Spinner className="size-4 text-neutral-500" />
|
||||
Carregando...
|
||||
</>
|
||||
) : (
|
||||
"Carregar mais"
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-neutral-500">
|
||||
Todos os chamados filtrados foram exibidos.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue