sistema-de-chamados/src/components/reports/machine-category-report.tsx
2025-11-18 15:54:49 -03:00

891 lines
35 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ReportsFilterToolbar } from "@/components/reports/report-filter-toolbar"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { formatDateDM, formatHoursCompact } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
import { DEVICE_INVENTORY_COLUMN_METADATA, type DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Checkbox } from "@/components/ui/checkbox"
import { Progress } from "@/components/ui/progress"
import { Spinner } from "@/components/ui/spinner"
import { toast } from "sonner"
type MachineCategoryDailyItem = {
date: string
machineId: string | null
machineHostname: string | null
companyId: string | null
companyName: string | null
categoryId: string | null
categoryName: string
total: number
}
type MachineCategoryReportData = {
rangeDays: number
items: MachineCategoryDailyItem[]
}
type MachineHoursItem = {
machineId: string
machineHostname: string | null
companyId: string | null
companyName: string | null
internalMs: number
externalMs: number
totalMs: number
}
type MachineHoursResponse = {
rangeDays: number
items: MachineHoursItem[]
}
type DeviceFieldOption = {
id: Id<"deviceFields">
key: string
label: string
}
type ColumnOption = {
key: string
label: string
group: "base" | "custom"
}
const DEFAULT_DEVICE_COLUMN_KEYS = DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map((meta) => meta.key)
export function MachineCategoryReport() {
const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d" | "365d" | "all">("30d")
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const [dateFrom, setDateFrom] = useState<string | null>(null)
const [dateTo, setDateTo] = useState<string | null>(null)
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const canView = Boolean(isStaff)
const enabled = Boolean(canView && convexUserId)
const dateRangeFilters = useMemo(() => {
const filters: { dateFrom?: string; dateTo?: string } = {}
if (dateFrom) filters.dateFrom = dateFrom
if (dateTo) filters.dateTo = dateTo
return filters
}, [dateFrom, dateTo])
const data = useQuery(
api.reports.ticketsByMachineAndCategory,
enabled
? ({
tenantId,
viewerId: convexUserId as Id<"users">,
range: timeRange,
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
...dateRangeFilters,
} as const)
: "skip"
) as MachineCategoryReportData | undefined
const companies = useQuery(
api.companies.list,
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
const items = useMemo(() => data?.items ?? [], [data])
const machinesRaw = useQuery(
api.devices.listByTenant,
enabled
? ({
tenantId,
includeMetadata: false,
} as const)
: "skip"
) as Array<{ id: string; hostname?: string | null; companyId?: string | null; displayName?: string | null }> | undefined
const users = useQuery(
api.users.listCustomers,
enabled
? ({
tenantId,
viewerId: convexUserId as Id<"users">,
} as const)
: "skip"
) as Array<{ id: Id<"users">; name: string | null; email: string; companyId?: Id<"companies"> | null }> | undefined
const deviceFields = useQuery(
api.deviceFields.listForTenant,
enabled
? ({
tenantId,
viewerId: convexUserId as Id<"users">,
} as const)
: "skip"
) as DeviceFieldOption[] | undefined
const machineOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as máquinas" }]
if (!machinesRaw || machinesRaw.length === 0) return base
const filtered = machinesRaw.filter((machine) => {
if (!machine) return false
if (companyId === "all") return true
return String(machine.companyId ?? "") === String(companyId)
})
const mapped = filtered
.map((machine) => {
const id = String((machine as { id?: string; _id?: string }).id ?? (machine as { _id?: string })._id ?? "")
const hostname =
(machine.hostname ?? machine.displayName ?? "")?.toString().trim() || `Máquina ${id.slice(0, 8)}`
return { value: id, label: hostname }
})
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
return [base[0], ...mapped]
}, [machinesRaw, companyId])
const userOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todos os usuários" }]
if (!users || users.length === 0) return base
const filtered = users.filter((user) => {
if (!user) return false
if (companyId === "all") return true
return String(user.companyId ?? "") === String(companyId)
})
const mapped = filtered
.map((user) => {
const label = (user.name ?? user.email).trim()
return { value: String(user.id), label }
})
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
return [base[0], ...mapped]
}, [users, companyId])
const [selectedMachineId, setSelectedMachineId] = useState<string>("all")
const [selectedUserId, setSelectedUserId] = useState<string>("all")
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState(0)
const [exportError, setExportError] = useState<string | null>(null)
const [selectedColumnKeys, setSelectedColumnKeys] = useState<string[]>(() => [...DEFAULT_DEVICE_COLUMN_KEYS])
const baseColumnOptions = useMemo<ColumnOption[]>(
() =>
DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({
key: meta.key,
label: meta.label,
group: "base" as const,
})),
[]
)
const customColumnOptions = useMemo<ColumnOption[]>(() => {
if (!deviceFields || deviceFields.length === 0) return []
return deviceFields
.map((field) => ({
key: `custom:${field.key}`,
label: field.label,
group: "custom" as const,
}))
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
}, [deviceFields])
const columnOptionsMap = useMemo(() => {
const entries = [...baseColumnOptions, ...customColumnOptions]
return new Map(entries.map((option) => [option.key, option]))
}, [baseColumnOptions, customColumnOptions])
const orderedColumnKeys = useMemo(
() => [...baseColumnOptions.map((option) => option.key), ...customColumnOptions.map((option) => option.key)],
[baseColumnOptions, customColumnOptions]
)
const selectedColumnConfigs = useMemo<DeviceInventoryColumnConfig[]>(() => {
const selection = new Set(selectedColumnKeys)
return orderedColumnKeys
.filter((key) => selection.has(key))
.map((key) => {
const option = columnOptionsMap.get(key)
return {
key,
label: option && option.group === "custom" ? option.label : undefined,
}
})
}, [columnOptionsMap, orderedColumnKeys, selectedColumnKeys])
const selectedColumnCount = selectedColumnConfigs.length
const hours = useQuery(
api.reports.hoursByMachine,
enabled && selectedMachineId !== "all"
? ({
tenantId,
viewerId: convexUserId as Id<"users">,
range: timeRange,
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
machineId: selectedMachineId !== "all" ? (selectedMachineId as Id<"machines">) : undefined,
userId: selectedUserId !== "all" ? (selectedUserId as Id<"users">) : undefined,
...dateRangeFilters,
} as const)
: "skip"
) as MachineHoursResponse | undefined
const totals = useMemo(
() =>
items.reduce(
(acc, item) => {
acc.totalTickets += item.total
acc.machines.add(item.machineId ?? item.machineHostname ?? "sem-maquina")
acc.categories.add(item.categoryName)
return acc
},
{
totalTickets: 0,
machines: new Set<string>(),
categories: new Set<string>(),
}
),
[items]
)
const dailySeries = useMemo(
() => {
const map = new Map<string, number>()
for (const item of items) {
const current = map.get(item.date) ?? 0
map.set(item.date, current + item.total)
}
return Array.from(map.entries())
.map(([date, total]) => ({ date, total }))
.sort((a, b) => a.date.localeCompare(b.date))
},
[items]
)
const tableRows = useMemo(
() =>
[...items].sort((a, b) => {
if (a.date !== b.date) return b.date.localeCompare(a.date)
const machineA = (a.machineHostname ?? "").toLowerCase()
const machineB = (b.machineHostname ?? "").toLowerCase()
if (machineA !== machineB) return machineA.localeCompare(machineB)
return a.categoryName.localeCompare(b.categoryName, "pt-BR")
}),
[items]
)
const exportableMachineIds = useMemo(() => {
const set = new Set<string>()
for (const item of items) {
if (item.machineId) {
set.add(String(item.machineId))
}
}
return Array.from(set)
}, [items])
const handleToggleColumn = useCallback((key: string, checked: boolean) => {
setSelectedColumnKeys((prev) => {
if (checked) {
if (prev.includes(key)) return prev
return [...prev, key]
}
return prev.filter((column) => column !== key)
})
}, [])
const handleResetColumns = useCallback(() => {
setSelectedColumnKeys([...DEFAULT_DEVICE_COLUMN_KEYS])
}, [])
const handleSelectAllColumns = useCallback(() => {
setSelectedColumnKeys([...orderedColumnKeys])
}, [orderedColumnKeys])
const handleOpenExportDialog = useCallback(() => {
if (exportableMachineIds.length === 0) {
toast.warning("Nenhuma máquina com identificador válido para exportar neste período.")
return
}
setExportError(null)
setIsExportDialogOpen(true)
}, [exportableMachineIds.length])
const handleExportDialogOpenChange = useCallback(
(open: boolean) => {
if (!open && isExporting) return
setIsExportDialogOpen(open)
if (!open) {
setExportProgress(0)
setExportError(null)
}
},
[isExporting]
)
const buildExportSearchParams = useCallback(() => {
const params = new URLSearchParams()
params.set("range", timeRange)
if (companyId !== "all") params.set("companyId", companyId)
if (selectedMachineId !== "all") params.set("machineId", selectedMachineId)
if (selectedUserId !== "all") params.set("userId", selectedUserId)
if (dateFrom) params.set("dateFrom", dateFrom)
if (dateTo) params.set("dateTo", dateTo)
return params
}, [companyId, dateFrom, dateTo, selectedMachineId, selectedUserId, timeRange])
const handleConfirmExport = useCallback(async () => {
if (exportableMachineIds.length === 0) {
setExportError("Não há máquinas disponíveis para exportar.")
return
}
if (selectedColumnConfigs.length === 0) {
setExportError("Selecione ao menos uma coluna para exportar.")
return
}
const params = buildExportSearchParams()
params.set("columns", JSON.stringify(selectedColumnConfigs))
const qs = params.toString()
const url = `/api/reports/machine-category.xlsx${qs ? `?${qs}` : ""}`
setIsExporting(true)
setExportError(null)
setExportProgress(5)
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Export failed with status ${response.status}`)
}
const contentLengthHeader = response.headers.get("Content-Length")
const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : Number.NaN
const hasLength = Number.isFinite(totalBytes) && totalBytes > 0
const disposition = response.headers.get("Content-Disposition")
const filenameMatch = disposition?.match(/filename="?([^";]+)"?/i)
const filename = filenameMatch?.[1] ?? `machine-category-${new Date().toISOString().slice(0, 10)}.xlsx`
let blob: Blob
if (!response.body || typeof response.body.getReader !== "function") {
blob = await response.blob()
setExportProgress(100)
} else {
const reader = response.body.getReader()
const chunks: ArrayBuffer[] = []
let received = 0
while (true) {
const { value, done } = await reader.read()
if (done) break
if (value) {
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)
chunks.push(chunk)
received += value.length
if (hasLength) {
const percent = Math.min(99, Math.round((received / totalBytes) * 100))
setExportProgress(percent)
} else {
setExportProgress((prev) => (prev >= 95 ? prev : prev + 5))
}
}
}
blob = new Blob(chunks, { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
setExportProgress(100)
}
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = downloadUrl
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(downloadUrl)
toast.success("Planilha gerada com sucesso.")
setIsExporting(false)
setIsExportDialogOpen(false)
} catch (error) {
console.error("Failed to export machine category worksheet", error)
setExportProgress(0)
setIsExporting(false)
setExportError("Não foi possível gerar a planilha. Tente novamente.")
}
}, [buildExportSearchParams, exportableMachineIds.length, selectedColumnConfigs])
if (!canView) {
return (
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Máquinas x categorias</CardTitle>
<CardDescription className="text-neutral-600">
Este relatório está disponível apenas para a equipe interna.
</CardDescription>
</CardHeader>
</Card>
)
}
if (!data) {
return (
<div className="space-y-6">
<Skeleton className="h-16 rounded-2xl" />
<Skeleton className="h-64 rounded-2xl" />
<Skeleton className="h-80 rounded-2xl" />
</div>
)
}
return (
<div className="space-y-6">
<ReportsFilterToolbar
companyId={companyId}
onCompanyChange={(value) => setCompanyId(value)}
companyOptions={companyOptions}
timeRange={timeRange}
onTimeRangeChange={(value) => {
setTimeRange(value)
setDateFrom(null)
setDateTo(null)
}}
dateFrom={dateFrom}
dateTo={dateTo}
onDateRangeChange={({ from, to }) => {
setDateFrom(from)
setDateTo(to)
}}
allowExtendedRanges
onExportClick={handleOpenExportDialog}
isExporting={isExporting}
/>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-sm font-semibold text-neutral-900">
Filtro detalhado por máquina e usuário
</CardTitle>
<CardDescription className="text-neutral-600">
Refine os dados para uma máquina específica e, opcionalmente, para um colaborador da empresa selecionada.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-3">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Máquina
</span>
<SearchableCombobox
value={selectedMachineId}
onValueChange={(value) => setSelectedMachineId(value ?? "all")}
options={machineOptions}
placeholder="Todas as máquinas"
triggerClassName="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800"
/>
</div>
<div className="space-y-3">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Usuário (solicitante)
</span>
<SearchableCombobox
value={selectedUserId}
onValueChange={(value) => setSelectedUserId(value ?? "all")}
options={userOptions}
placeholder="Todos os usuários"
triggerClassName="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800"
/>
</div>
<div className="space-y-1">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Contexto
</span>
<p className="text-xs text-neutral-500">
Combine os filtros acima com o período selecionado para analisar o histórico daquela máquina.
</p>
</div>
</div>
</CardContent>
</Card>
{selectedMachineId !== "all" ? (
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">
Histórico da máquina selecionada
</CardTitle>
<CardDescription className="text-neutral-600">
Distribuição de categorias e horas trabalhadas na máquina filtrada, dentro do período selecionado.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
{(() => {
const machineItems = items.filter(
(item) =>
item.machineId === selectedMachineId ||
(selectedMachineId === "all" && item.machineId !== null),
)
const totalTickets = machineItems.reduce((acc, item) => acc + item.total, 0)
const hoursItem = (hours?.items ?? []).find(
(entry) => entry.machineId === selectedMachineId,
)
const internalHours = hoursItem ? hoursItem.internalMs / 3_600_000 : 0
const externalHours = hoursItem ? hoursItem.externalMs / 3_600_000 : 0
const totalHours = hoursItem ? hoursItem.totalMs / 3_600_000 : 0
const companyLabel =
hoursItem?.companyName ??
machineItems[0]?.companyName ??
(companyId === "all" ? "Várias empresas" : null)
return (
<>
<div className="space-y-1 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Chamados da máquina
</p>
<p className="text-2xl font-semibold text-neutral-900">{totalTickets}</p>
</div>
<div className="space-y-1 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Horas totais
</p>
<p className="text-xl font-semibold text-neutral-900">
{totalHours > 0 ? formatHoursCompact(totalHours) : "0h"}
</p>
<p className="text-xs text-neutral-500">
Internas: {formatHoursCompact(internalHours)} · Externas:{" "}
{formatHoursCompact(externalHours)}
</p>
</div>
<div className="space-y-1 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Empresa
</p>
<p className="text-sm font-semibold text-neutral-900">
{companyLabel ?? "Sem empresa"}
</p>
</div>
</>
)
})()}
</div>
{(() => {
const perCategory = new Map<string, { categoryName: string; total: number }>()
for (const item of items) {
if (item.machineId !== selectedMachineId) continue
if (selectedUserId !== "all" && selectedUserId) {
// user filter já é aplicado na query de backend; não precisamos revalidar aqui
}
const key = item.categoryName || "Sem categoria"
const current = perCategory.get(key) ?? { categoryName: key, total: 0 }
current.total += item.total
perCategory.set(key, current)
}
const rows = Array.from(perCategory.values()).sort((a, b) => b.total - a.total)
if (rows.length === 0) {
return (
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
Nenhuma categoria encontrada para a combinação de filtros atual.
</p>
)
}
return (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-4 py-3 text-left">Categoria</th>
<th className="px-4 py-3 text-right">Chamados</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.categoryName} className="border-t border-slate-100">
<td className="px-4 py-2 text-neutral-800">{row.categoryName}</td>
<td className="px-4 py-2 text-right font-semibold text-neutral-900">
{row.total}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
})()}
</CardContent>
</Card>
) : null}
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Chamados analisados
</CardTitle>
<CardDescription className="text-neutral-600">
Total de tickets com máquina vinculada no período.
</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{totals.totalTickets}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Máquinas únicas
</CardTitle>
<CardDescription className="text-neutral-600">
Quantidade de dispositivos diferentes com chamados no período.
</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{totals.machines.size}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Categorias
</CardTitle>
<CardDescription className="text-neutral-600">
Categorias distintas associadas aos tickets dessas máquinas.
</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{totals.categories.size}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">
Volume diário por máquina (total)
</CardTitle>
<CardDescription className="text-neutral-600">
Quantidade de chamados com máquina vinculada, somando todas as categorias, por dia.
</CardDescription>
</CardHeader>
<CardContent>
{dailySeries.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Nenhum ticket com máquina vinculada foi encontrado para o período selecionado.
</p>
) : (
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
<BarChart data={dailySeries} margin={{ top: 8, left: 20, right: 20, bottom: 32 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={24}
tickFormatter={(value) => formatDateDM(new Date(String(value)))}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[180px]"
labelFormatter={(value) => formatDateDM(new Date(String(value)))}
/>
}
/>
<Bar dataKey="total" fill="var(--chart-1)" radius={[4, 4, 0, 0]} name="Chamados" />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">
Detalhamento diário por máquina e categoria
</CardTitle>
<CardDescription className="text-neutral-600">
Cada linha representa o total de chamados abertos em uma data específica, agrupados por
máquina e categoria.
</CardDescription>
</CardHeader>
<CardContent>
{tableRows.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Nenhum ticket com máquina vinculada foi encontrado para o período selecionado.
</p>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-4 py-3 text-left">Data</th>
<th className="px-4 py-3 text-left">Máquina</th>
<th className="px-4 py-3 text-left">Empresa</th>
<th className="px-4 py-3 text-left">Categoria</th>
<th className="px-4 py-3 text-right">Chamados</th>
</tr>
</thead>
<tbody>
{tableRows.map((row, index) => {
const machineLabel =
row.machineHostname && row.machineHostname.trim().length > 0
? row.machineHostname
: "Sem hostname"
const companyLabel = row.companyName ?? "Sem empresa"
return (
<tr key={`${row.date}-${machineLabel}-${row.categoryName}-${index}`} className="border-t border-slate-100">
<td className="px-4 py-2 text-neutral-800">
{formatDateDM(new Date(`${row.date}T00:00:00Z`))}
</td>
<td className="px-4 py-2 text-neutral-800">{machineLabel}</td>
<td className="px-4 py-2 text-neutral-700">{companyLabel}</td>
<td className="px-4 py-2 text-neutral-700">{row.categoryName}</td>
<td className="px-4 py-2 text-right font-semibold text-neutral-900">{row.total}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<Dialog open={isExportDialogOpen} onOpenChange={handleExportDialogOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Exportar XLSX Máquinas x categorias</DialogTitle>
<DialogDescription>
Personalize quais campos das máquinas aparecerão na planilha complementar de inventário. Os filtros aplicados neste relatório serão respeitados.
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-3 text-sm text-neutral-700">
Máquinas encontradas:{" "}
<span className="font-semibold text-neutral-900">{exportableMachineIds.length}</span>
{selectedMachineId !== "all" ? (
<span className="ml-1 text-xs text-neutral-500">(apenas a máquina filtrada será exportada)</span>
) : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-neutral-900">Colunas selecionadas: {selectedColumnCount}</p>
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="sm" onClick={handleSelectAllColumns} disabled={isExporting} className="gap-2">
Selecionar todas
</Button>
<Button type="button" variant="ghost" size="sm" onClick={handleResetColumns} disabled={isExporting} className="gap-2">
Restaurar padrão
</Button>
</div>
</div>
<div className="space-y-4 max-h-[420px] overflow-y-auto pr-1">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Colunas padrão</p>
<div className="grid gap-3 sm:grid-cols-2">
{baseColumnOptions.map((column) => {
const checked = selectedColumnKeys.includes(column.key)
return (
<label key={column.key} className="flex items-center gap-2 text-sm text-neutral-800">
<Checkbox
checked={checked}
onCheckedChange={(value) => handleToggleColumn(column.key, value === true || value === "indeterminate")}
disabled={isExporting}
/>
<span>{column.label}</span>
</label>
)
})}
</div>
</div>
{customColumnOptions.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Campos personalizados</p>
<div className="grid gap-3 sm:grid-cols-2">
{customColumnOptions.map((column) => {
const checked = selectedColumnKeys.includes(column.key)
return (
<label key={column.key} className="flex items-center gap-2 text-sm text-neutral-800">
<Checkbox
checked={checked}
onCheckedChange={(value) => handleToggleColumn(column.key, value === true || value === "indeterminate")}
disabled={isExporting}
/>
<span>{column.label}</span>
</label>
)
})}
</div>
</div>
) : null}
</div>
{isExporting ? (
<div className="space-y-2 rounded-xl border border-slate-200 bg-white/80 px-4 py-3">
<div className="flex items-center justify-between text-sm font-medium text-neutral-700">
<span>Gerando planilha...</span>
<span>{Math.min(100, Math.max(0, Math.round(exportProgress)))}%</span>
</div>
<Progress value={exportProgress} className="h-2" />
</div>
) : null}
{exportError ? <p className="text-sm text-destructive">{exportError}</p> : null}
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => handleExportDialogOpenChange(false)} disabled={isExporting}>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirmExport}
disabled={isExporting || selectedColumnCount === 0 || exportableMachineIds.length === 0}
className="gap-2"
>
{isExporting ? (
<>
<Spinner className="mr-2 size-4" />
Exportando...
</>
) : (
"Exportar XLSX"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}