891 lines
35 KiB
TypeScript
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>
|
|
)
|
|
}
|