feat: machine reports with filters and hours

This commit is contained in:
Esdras Renan 2025-11-13 23:45:24 -03:00
parent 6938bebdbb
commit 82875a2252
2 changed files with 403 additions and 11 deletions

View file

@ -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,7 +1426,7 @@ export async function ticketsByMachineAndCategoryHandler(
const key = [
date,
machineId ? String(machineId) : "null",
machineIdValue ? String(machineIdValue) : "null",
machineHostname ?? "",
rawCategoryId ?? "uncategorized",
companyIdValue ? String(companyIdValue) : "null",
@ -1426,7 +1438,7 @@ export async function ticketsByMachineAndCategoryHandler(
} else {
aggregated.set(key, {
date,
machineId,
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<string, Doc<"machines"> | null>()
const companiesById = new Map<string, Doc<"companies"> | null>()
const map = new Map<string, MachineHoursEntry>()
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 }

View file

@ -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<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 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)}
/>
<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-3 md:grid-cols-3">
<div className="space-y-1">
<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"
className="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">
Usuário (solicitante)
</span>
<SearchableCombobox
value={selectedUserId}
onValueChange={(value) => 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"
/>
</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>