chore: expand reports coverage and upgrade next

This commit is contained in:
codex-bot 2025-10-31 17:27:51 -03:00
parent 2fb587b01d
commit 8b82284e8c
21 changed files with 2952 additions and 2713 deletions

View file

@ -102,6 +102,12 @@ type MachineTicketSummary = {
assignee: { name: string | null; email: string | null } | null
}
type MachineOpenTicketsSummary = {
totalOpen: number
hasMore: boolean
tickets: MachineTicketSummary[]
}
type DetailLineProps = {
label: string
@ -1454,9 +1460,14 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const machineAlertsHistory = alertsHistory ?? []
const openTickets = useQuery(
machine ? api.machines.listOpenTickets : "skip",
machine ? { machineId: machine.id as Id<"machines">, limit: 8 } : ("skip" as const)
) as MachineTicketSummary[] | undefined
const machineTickets = openTickets ?? []
machine ? { machineId: machine.id as Id<"machines">, limit: 6 } : ("skip" as const)
) as MachineOpenTicketsSummary | undefined
const machineTickets = openTickets?.tickets ?? []
const totalOpenTickets = openTickets?.totalOpen ?? machineTickets.length
const displayLimit = 3
const displayedMachineTickets = machineTickets.slice(0, displayLimit)
const hasAdditionalOpenTickets = totalOpenTickets > displayedMachineTickets.length
const machineTicketsHref = machine ? `/admin/machines/${machine.id}/tickets` : null
const metadata = machine?.inventory ?? null
const metrics = machine?.metrics ?? null
const metricsCapturedAt = useMemo(() => getMetricsTimestamp(metrics), [metrics])
@ -2356,45 +2367,65 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</div>
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="space-y-1">
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por esta máquina</h4>
{machineTickets.length === 0 ? (
<p className="text-xs text-[color:var(--accent-foreground)]/80">Nenhum chamado em aberto registrado diretamente por esta máquina.</p>
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por esta máquina</h4>
{machineTicketsHref ? (
<Link
href={machineTicketsHref}
className="text-xs font-semibold text-accent-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-foreground)] focus-visible:ring-offset-2"
>
Ver todos
</Link>
) : null}
</div>
{totalOpenTickets === 0 ? (
<p className="text-xs text-[color:var(--accent-foreground)]/80">
Nenhum chamado em aberto registrado diretamente por esta máquina.
</p>
) : (
<ul className="space-y-2">
{machineTickets.map((ticket) => {
const priorityMeta = getTicketPriorityMeta(ticket.priority)
return (
<li key={ticket.id}>
<Link
href={`/tickets/${ticket.id}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white px-3 py-2 text-sm shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
</p>
</div>
<div className="flex items-center gap-2">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
{priorityMeta.label}
</Badge>
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
</div>
</Link>
</li>
)
})}
</ul>
<div className="space-y-2">
{hasAdditionalOpenTickets ? (
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--accent-foreground)]/70">
Mostrando últimos {Math.min(displayLimit, totalOpenTickets)} de {totalOpenTickets} chamados
em aberto
</p>
) : null}
<ul className="space-y-2">
{displayedMachineTickets.map((ticket) => {
const priorityMeta = getTicketPriorityMeta(ticket.priority)
return (
<li key={ticket.id}>
<Link
href={`/tickets/${ticket.id}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white px-3 py-2 text-sm shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
</p>
</div>
<div className="flex items-center gap-2">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
{priorityMeta.label}
</Badge>
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
</div>
</Link>
</li>
)
})}
</ul>
</div>
)}
</div>
<div className="self-center justify-self-end">
<div className="flex h-12 min-w-[72px] items-center justify-center rounded-2xl border border-[color:var(--accent)] bg-white px-5 shadow-sm sm:min-w-[88px]">
<span className="text-2xl font-semibold leading-none text-accent-foreground tabular-nums sm:text-3xl">
{machineTickets.length}
{totalOpenTickets}
</span>
</div>
</div>

View file

@ -7,7 +7,19 @@ import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
export function MachineBreadcrumbs({ tenantId: _tenantId, machineId }: { tenantId: string; machineId: string }) {
type BreadcrumbSegment = {
label: string
href?: string | null
}
type MachineBreadcrumbsProps = {
tenantId: string
machineId: string
machineHref?: string | null
extra?: BreadcrumbSegment[]
}
export function MachineBreadcrumbs({ tenantId: _tenantId, machineId, machineHref, extra }: MachineBreadcrumbsProps) {
const { convexUserId } = useAuth()
const queryArgs = machineId && convexUserId
? ({ id: machineId as Id<"machines">, includeMetadata: false } as const)
@ -15,15 +27,36 @@ export function MachineBreadcrumbs({ tenantId: _tenantId, machineId }: { tenantI
const item = useQuery(api.machines.getById, queryArgs)
const hostname = useMemo(() => item?.hostname ?? "Detalhe", [item])
const segments = useMemo(() => {
const trail: BreadcrumbSegment[] = [
{ label: "Máquinas", href: "/admin/machines" },
{ label: hostname, href: machineHref ?? undefined },
]
if (Array.isArray(extra) && extra.length > 0) {
trail.push(...extra.filter((segment): segment is BreadcrumbSegment => Boolean(segment?.label)))
}
return trail
}, [hostname, machineHref, extra])
return (
<nav className="mb-4 text-sm text-neutral-600">
<ol className="flex items-center gap-2">
<li>
<Link href="/admin/machines" className="underline-offset-4 hover:underline">Máquinas</Link>
</li>
<li className="text-neutral-400">/</li>
<li className="text-neutral-800">{hostname}</li>
{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>
)

View file

@ -0,0 +1,439 @@
"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 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"
type MachineTicketHistoryItem = {
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 MachineTicketsHistoryArgs = {
machineId: Id<"machines">
status?: "open" | "resolved"
priority?: string
search?: string
from?: number
to?: number
}
type MachineTicketsHistoryStats = {
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 MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }: { tenantId: string; machineId: 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: MachineTicketsHistoryArgs = {
machineId: machineId 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, machineId, priorityFilter, range.from, range.to, statusFilter])
const { results: tickets, status: paginationStatus, loadMore } = usePaginatedQuery(
api.machines.listTicketsHistory,
queryArgs,
{ initialNumItems: 25 }
)
const stats = useQuery(api.machines.getTicketsHistoryStats, queryArgs) as MachineTicketsHistoryStats | 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 máquina.
</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 assigneeLabel = ticket.assignee?.name ?? ticket.assignee?.email ?? "Sem responsável"
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 text-sm text-neutral-700">
<span>{assigneeLabel}</span>
{ticket.assignee?.email ? (
<span className="text-xs text-neutral-400">{ticket.assignee.email}</span>
) : null}
</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>
)
}

View file

@ -303,7 +303,7 @@ export function CloseTicketDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Encerrar ticket</DialogTitle>
<DialogDescription>

View file

@ -93,7 +93,7 @@ export function SearchableCombobox({
aria-expanded={open}
disabled={disabled}
className={cn(
"flex h-9 w-full items-center justify-between rounded-full border border-input bg-background px-3 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
"flex min-h-[42px] w-full items-center justify-between rounded-full border border-input bg-background px-3 py-2 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
@ -171,4 +171,3 @@ export function SearchableCombobox({
</Popover>
)
}