From 82875a2252350e8c1276f7f078f8f14189eb5912 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 13 Nov 2025 23:45:24 -0300 Subject: [PATCH] feat: machine reports with filters and hours --- convex/reports.ts | 164 +++++++++++- .../reports/machine-category-report.tsx | 250 +++++++++++++++++- 2 files changed, 403 insertions(+), 11 deletions(-) diff --git a/convex/reports.ts b/convex/reports.ts index 47e9381..562c1c8 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -1335,7 +1335,16 @@ export async function ticketsByMachineAndCategoryHandler( viewerId, range, companyId, - }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } + machineId, + userId, + }: { + tenantId: string + viewerId: Id<"users"> + range?: string + companyId?: Id<"companies"> + machineId?: Id<"machines"> + userId?: Id<"users"> + } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 @@ -1372,8 +1381,11 @@ export async function ticketsByMachineAndCategoryHandler( const hasMachine = Boolean(ticket.machineId) || Boolean(ticket.machineSnapshot) if (!hasMachine) continue + if (machineId && ticket.machineId !== machineId) continue + if (userId && ticket.requesterId !== userId) continue + const date = formatDateKey(createdAt) - const machineId = (ticket.machineId ?? null) as Id<"machines"> | null + const machineIdValue = (ticket.machineId ?? null) as Id<"machines"> | null const machineSnapshot = (ticket.machineSnapshot ?? null) as | { hostname?: string | null @@ -1414,19 +1426,19 @@ export async function ticketsByMachineAndCategoryHandler( const key = [ date, - machineId ? String(machineId) : "null", + machineIdValue ? String(machineIdValue) : "null", machineHostname ?? "", rawCategoryId ?? "uncategorized", companyIdValue ? String(companyIdValue) : "null", ].join("|") - const existing = aggregated.get(key) - if (existing) { - existing.total += 1 - } else { - aggregated.set(key, { - date, - machineId, + const existing = aggregated.get(key) + if (existing) { + existing.total += 1 + } else { + aggregated.set(key, { + date, + machineId: machineIdValue, machineHostname, companyId: companyIdValue, companyName, @@ -1457,10 +1469,142 @@ export const ticketsByMachineAndCategory = query({ viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")), + machineId: v.optional(v.id("machines")), + userId: v.optional(v.id("users")), }, handler: ticketsByMachineAndCategoryHandler, }) +type MachineHoursEntry = { + machineId: Id<"machines"> + machineHostname: string | null + companyId: Id<"companies"> | null + companyName: string | null + internalMs: number + externalMs: number + totalMs: number +} + +export async function hoursByMachineHandler( + ctx: QueryCtx, + { + tenantId, + viewerId, + range, + companyId, + machineId, + userId, + }: { + tenantId: string + viewerId: Id<"users"> + range?: string + companyId?: Id<"companies"> + machineId?: Id<"machines"> + userId?: Id<"users"> + } +) { + const viewer = await requireStaff(ctx, viewerId, tenantId) + const tickets = await fetchScopedTickets(ctx, tenantId, viewer) + + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 + const end = new Date() + end.setUTCHours(0, 0, 0, 0) + const endMs = end.getTime() + ONE_DAY_MS + const startMs = endMs - days * ONE_DAY_MS + + const machinesById = new Map | null>() + const companiesById = new Map | null>() + + const map = new Map() + + for (const t of tickets) { + if (t.updatedAt < startMs || t.updatedAt >= endMs) continue + if (companyId && t.companyId && t.companyId !== companyId) continue + if (machineId && t.machineId !== machineId) continue + if (userId && t.requesterId !== userId) continue + + const machineIdValue = (t.machineId ?? null) as Id<"machines"> | null + if (!machineIdValue) continue + + const key = String(machineIdValue) + + let acc = map.get(key) + if (!acc) { + let machineDoc = machinesById.get(key) + if (machineDoc === undefined) { + machineDoc = (await ctx.db.get(machineIdValue)) as Doc<"machines"> | null + machinesById.set(key, machineDoc ?? null) + } + + const snapshot = (t.machineSnapshot ?? null) as { hostname?: string | null } | null + const machineHostname = + typeof machineDoc?.hostname === "string" && machineDoc.hostname.trim().length > 0 + ? machineDoc.hostname.trim() + : snapshot?.hostname && snapshot.hostname.trim().length > 0 + ? snapshot.hostname.trim() + : null + + const companyIdValue = (t.companyId ?? + (machineDoc?.companyId as Id<"companies"> | undefined) ?? + null) as Id<"companies"> | null + + let companyName: string | null = null + if (companyIdValue) { + const companyKey = String(companyIdValue) + let companyDoc = companiesById.get(companyKey) + if (companyDoc === undefined) { + companyDoc = (await ctx.db.get(companyIdValue)) as Doc<"companies"> | null + companiesById.set(companyKey, companyDoc ?? null) + } + if (companyDoc?.name && companyDoc.name.trim().length > 0) { + companyName = companyDoc.name.trim() + } + } + + acc = { + machineId: machineIdValue, + machineHostname, + companyId: companyIdValue, + companyName, + internalMs: 0, + externalMs: 0, + totalMs: 0, + } + map.set(key, acc) + } + + const internal = (t.internalWorkedMs ?? 0) as number + const external = (t.externalWorkedMs ?? 0) as number + acc.internalMs += internal + acc.externalMs += external + acc.totalMs += internal + external + } + + const items = Array.from(map.values()).sort((a, b) => { + if (b.totalMs !== a.totalMs) return b.totalMs - a.totalMs + const hostA = (a.machineHostname ?? "").toLowerCase() + const hostB = (b.machineHostname ?? "").toLowerCase() + return hostA.localeCompare(hostB) + }) + + return { + rangeDays: days, + items, + } +} + +export const hoursByMachine = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + range: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + machineId: v.optional(v.id("machines")), + userId: v.optional(v.id("users")), + }, + handler: hoursByMachineHandler, +}) + export async function hoursByClientHandler( ctx: QueryCtx, { tenantId, viewerId, range }: { tenantId: string; viewerId: Id<"users">; range?: string } diff --git a/src/components/reports/machine-category-report.tsx b/src/components/reports/machine-category-report.tsx index c72c973..387f55e 100644 --- a/src/components/reports/machine-category-report.tsx +++ b/src/components/reports/machine-category-report.tsx @@ -13,7 +13,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com 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 } from "@/lib/utils" +import { formatDateDM, formatHoursCompact } from "@/lib/utils" import { Skeleton } from "@/components/ui/skeleton" type MachineCategoryDailyItem = { @@ -32,6 +32,21 @@ type MachineCategoryReportData = { 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[] +} + export function MachineCategoryReport() { const [timeRange, setTimeRange] = useState("30d") const [companyId, setCompanyId] = usePersistentCompanyFilter("all") @@ -75,6 +90,79 @@ export function MachineCategoryReport() { 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 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 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, + } as const) + : "skip" + ) as MachineHoursResponse | undefined + const totals = useMemo( () => items.reduce( @@ -152,6 +240,166 @@ export function MachineCategoryReport() { onTimeRangeChange={(value) => setTimeRange(value)} /> + + + + 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" + className="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" + className="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) => ( + + + + + ))} + +
CategoriaChamados
{row.categoryName} + {row.total} +
+
+ ) + })()} +
+
+ ) : null} +