"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(null) const [dateTo, setDateTo] = useState(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(() => { 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(() => { 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(() => { 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("all") const [selectedUserId, setSelectedUserId] = useState("all") const [isExportDialogOpen, setIsExportDialogOpen] = useState(false) const [isExporting, setIsExporting] = useState(false) const [exportProgress, setExportProgress] = useState(0) const [exportError, setExportError] = useState(null) const [selectedColumnKeys, setSelectedColumnKeys] = useState(() => [...DEFAULT_DEVICE_COLUMN_KEYS]) const baseColumnOptions = useMemo( () => DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({ key: meta.key, label: meta.label, group: "base" as const, })), [] ) const customColumnOptions = useMemo(() => { 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(() => { 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(), categories: new Set(), } ), [items] ) const dailySeries = useMemo( () => { const map = new Map() 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() 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 ( Máquinas x categorias Este relatório está disponível apenas para a equipe interna. ) } if (!data) { return (
) } return (
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} /> Filtro detalhado por máquina e usuário Refine os dados para uma máquina específica e, opcionalmente, para um colaborador da empresa selecionada.
Máquina 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" />
Usuário (solicitante) 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" />
Contexto

Combine os filtros acima com o período selecionado para analisar o histórico daquela máquina.

{selectedMachineId !== "all" ? ( Histórico da máquina selecionada Distribuição de categorias e horas trabalhadas na máquina filtrada, dentro do período selecionado.
{(() => { 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 ( <>

Chamados da máquina

{totalTickets}

Horas totais

{totalHours > 0 ? formatHoursCompact(totalHours) : "0h"}

Internas: {formatHoursCompact(internalHours)} · Externas:{" "} {formatHoursCompact(externalHours)}

Empresa

{companyLabel ?? "Sem empresa"}

) })()}
{(() => { const perCategory = new Map() 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 (

Nenhuma categoria encontrada para a combinação de filtros atual.

) } return (
{rows.map((row) => ( ))}
Categoria Chamados
{row.categoryName} {row.total}
) })()}
) : null}
Chamados analisados Total de tickets com máquina vinculada no período. {totals.totalTickets} Máquinas únicas Quantidade de dispositivos diferentes com chamados no período. {totals.machines.size} Categorias Categorias distintas associadas aos tickets dessas máquinas. {totals.categories.size}
Volume diário por máquina (total) Quantidade de chamados com máquina vinculada, somando todas as categorias, por dia. {dailySeries.length === 0 ? (

Nenhum ticket com máquina vinculada foi encontrado para o período selecionado.

) : ( formatDateDM(new Date(String(value)))} /> formatDateDM(new Date(String(value)))} /> } /> )}
Detalhamento diário por máquina e categoria Cada linha representa o total de chamados abertos em uma data específica, agrupados por máquina e categoria. {tableRows.length === 0 ? (

Nenhum ticket com máquina vinculada foi encontrado para o período selecionado.

) : (
{tableRows.map((row, index) => { const machineLabel = row.machineHostname && row.machineHostname.trim().length > 0 ? row.machineHostname : "Sem hostname" const companyLabel = row.companyName ?? "Sem empresa" return ( ) })}
Data Máquina Empresa Categoria Chamados
{formatDateDM(new Date(`${row.date}T00:00:00Z`))} {machineLabel} {companyLabel} {row.categoryName} {row.total}
)}
Exportar XLSX — Máquinas x categorias Personalize quais campos das máquinas aparecerão na planilha complementar de inventário. Os filtros aplicados neste relatório serão respeitados.
Máquinas encontradas:{" "} {exportableMachineIds.length} {selectedMachineId !== "all" ? ( (apenas a máquina filtrada será exportada) ) : null}

Colunas selecionadas: {selectedColumnCount}

Colunas padrão

{baseColumnOptions.map((column) => { const checked = selectedColumnKeys.includes(column.key) return ( ) })}
{customColumnOptions.length > 0 ? (

Campos personalizados

{customColumnOptions.map((column) => { const checked = selectedColumnKeys.includes(column.key) return ( ) })}
) : null}
{isExporting ? (
Gerando planilha... {Math.min(100, Math.max(0, Math.round(exportProgress)))}%
) : null} {exportError ?

{exportError}

: null}
) }