"use client" import { useState, useMemo } from "react" import { useQuery } from "convex/react" 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 { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { usePersistentCompanyFilter } from "@/lib/use-company-filter" import { Progress } from "@/components/ui/progress" import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart" import type { ChartConfig } from "@/components/ui/chart" import { formatHoursCompact } from "@/lib/utils" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" import { Button } from "@/components/ui/button" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { DateRangeButton } from "@/components/date-range-button" import { ReportScheduleDrawer } from "@/components/reports/report-schedule-drawer" type HoursItem = { companyId: string name: string isAvulso: boolean internalMs: number externalMs: number totalMs: number contractedHoursPerMonth?: number | null } type MachineHoursItem = { machineId: string machineHostname: string | null companyId: string | null companyName: string | null internalMs: number externalMs: number totalMs: number } type HoursByMachineResponse = { rangeDays: number items: MachineHoursItem[] } const topClientsChartConfig = { internas: { label: "Horas internas", color: "var(--chart-1)", }, externas: { label: "Horas externas", color: "var(--chart-2)", }, } satisfies ChartConfig export function HoursReport() { const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d" | "365d" | "all">("90d") const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const [billingFilter, setBillingFilter] = useState<"all" | "avulso" | "contratado">("all") const [schedulerOpen, setSchedulerOpen] = useState(false) const [groupBy, setGroupBy] = useState<"company" | "machine">("company") const [dateFrom, setDateFrom] = useState(null) const [dateTo, setDateTo] = useState(null) const { session, convexUserId, isStaff, isAdmin } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const enabled = Boolean(isStaff && 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.hoursByClient, enabled && groupBy === "company" ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange === "365d" || timeRange === "all" ? "90d" : timeRange, ...dateRangeFilters, } : "skip" ) as { rangeDays: number; items: HoursItem[] } | undefined const machinesData = useQuery( api.reports.hoursByMachine, enabled && groupBy === "machine" ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">), ...dateRangeFilters, } : "skip" ) as HoursByMachineResponse | 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 filteredCompanies = useMemo(() => { let items = data?.items ?? [] if (companyId !== "all") { items = items.filter((it) => String(it.companyId) === companyId) } if (billingFilter === "avulso") { items = items.filter((it) => it.isAvulso) } else if (billingFilter === "contratado") { items = items.filter((it) => !it.isAvulso) } return items }, [data?.items, companyId, billingFilter]) const filteredMachines = useMemo(() => { let items = machinesData?.items ?? [] if (companyId !== "all") { items = items.filter((it) => String(it.companyId) === companyId) } if (billingFilter === "avulso") { // Para futuro: quando tivermos flag de avulso por máquina; por enquanto, não filtra return items } if (billingFilter === "contratado") { return items } return items }, [machinesData?.items, companyId, billingFilter]) const totals = useMemo(() => { const source = groupBy === "machine" ? filteredMachines : filteredCompanies return source.reduce( (acc, item) => { acc.internal += item.internalMs / 3600000 acc.external += item.externalMs / 3600000 acc.total += item.totalMs / 3600000 return acc }, { internal: 0, external: 0, total: 0 } ) }, [filteredCompanies, filteredMachines, groupBy]) // No number formatter needed; we use formatHoursCompact for hours const filteredCompaniesWithComputed = useMemo( () => filteredCompanies.map((row) => { const internal = row.internalMs / 3600000 const external = row.externalMs / 3600000 const total = row.totalMs / 3600000 const contracted = row.contractedHoursPerMonth ?? null const usagePercent = contracted && contracted > 0 ? Math.min(100, Math.round((total / contracted) * 100)) : null return { ...row, internal, external, total, contracted, usagePercent, } }), [filteredCompanies] ) const topClientsData = useMemo( () => filteredCompaniesWithComputed .slice() .sort((a, b) => b.total - a.total) .slice(0, 10) .map((row) => ({ name: row.name, internas: row.internal, externas: row.external, })), [filteredCompaniesWithComputed] ) const exportHref = useMemo(() => { const params = new URLSearchParams() const effectiveRange = timeRange === "365d" || timeRange === "all" ? "90d" : timeRange params.set("range", effectiveRange) if (companyId !== "all") params.set("companyId", companyId) if (dateFrom) params.set("dateFrom", dateFrom) if (dateTo) params.set("dateTo", dateTo) return `/api/reports/hours-by-client.xlsx?${params.toString()}` }, [companyId, dateFrom, dateTo, timeRange]) return (
{isAdmin ? ( ) : null}
setCompanyId(value ?? "all")} options={companyOptions} placeholder="Todas as empresas" triggerClassName="h-10 w-full rounded-2xl border border-border/60 bg-white px-3 text-sm font-semibold text-neutral-800 lg:w-64" align="center" /> { setDateFrom(from) setDateTo(to) }} className="w-full min-w-[200px] lg:w-auto lg:flex-1" align="center" />
value && setBillingFilter(value as typeof billingFilter)} className="inline-flex rounded-full border border-border/60 bg-white/80 p-1 shadow-sm" > Todos Somente avulsos Somente contratados
{ if (!value) return setTimeRange(value as typeof timeRange) setDateFrom(null) setDateTo(null) }} variant="outline" size="lg" className="flex flex-wrap rounded-2xl border border-border/60 lg:flex-nowrap lg:min-w-[320px]" > 90 dias 30 dias 7 dias {groupBy === "machine" ? ( <> 12 meses Todo histórico ) : null}
Agrupar por
{isAdmin ? ( ) : null}
{groupBy === "company" ? "Top clientes por horas" : "Top máquinas por horas"} {groupBy === "company" ? "Comparativo empilhado de horas internas x externas (top 10 empresas)." : "Comparativo empilhado de horas internas x externas (top 10 máquinas)."} {groupBy === "company" ? ( !filteredCompaniesWithComputed || filteredCompaniesWithComputed.length === 0 ? (

Sem dados para o período.

) : ( formatHoursCompact(Number(value))} /> ( {String(value)} )} formatter={(value, name) => ( <> {name === "internas" ? "Horas internas" : "Horas externas"} {formatHoursCompact(Number(value))} )} /> } /> } /> ) ) : ( <> {!filteredMachines || filteredMachines.length === 0 ? (

Nenhuma máquina encontrada para o filtro selecionado.

) : ( b.totalMs - a.totalMs) .slice(0, 10) .map((row) => ({ name: row.machineHostname ?? row.machineId, internas: row.internalMs / 3600000, externas: row.externalMs / 3600000, }))} layout="vertical" margin={{ top: 16, right: 24, bottom: 16, left: 0 }} barCategoryGap={12} > formatHoursCompact(Number(value))} /> ( {String(value)} )} formatter={(value, name) => ( <> {name === "internas" ? "Horas internas" : "Horas externas"} {formatHoursCompact(Number(value))} )} /> } /> } /> )} )}
Horas {groupBy === "company" ? "Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado." : "Visualize o esforço interno e externo por máquina dentro dos filtros selecionados."}
{[ { key: "internal", label: "Horas internas", value: formatHoursCompact(totals.internal) }, { key: "external", label: "Horas externas", value: formatHoursCompact(totals.external) }, { key: "total", label: "Total acumulado", value: formatHoursCompact(totals.total) }, ].map((item) => (

{item.label}

{item.value}

))}
{groupBy === "company" ? ( !filteredCompaniesWithComputed.length ? (
Nenhuma empresa encontrada para o filtro selecionado.
) : (
{filteredCompaniesWithComputed.map((row) => (

{row.name}

ID {row.companyId}

{row.isAvulso ? "Cliente avulso" : "Recorrente"}
Horas internas {formatHoursCompact(row.internal)}
Horas externas {formatHoursCompact(row.external)}
Total {formatHoursCompact(row.total)}
Contratadas/mês {row.contracted ? formatHoursCompact(row.contracted) : "—"}
Uso {row.usagePercent !== null ? `${row.usagePercent}%` : "—"}
{row.usagePercent !== null ? ( ) : (
Defina horas contratadas para acompanhar o uso
)}
))}
) ) : !filteredMachines.length ? (
Nenhuma máquina encontrada para o filtro selecionado.
) : (
{filteredMachines.map((row) => { const internal = row.internalMs / 3_600_000 const external = row.externalMs / 3_600_000 const total = row.totalMs / 3_600_000 return (

{row.machineHostname ?? row.machineId}

{row.companyName ?? "Sem empresa"}

Horas internas {formatHoursCompact(internal)}
Horas externas {formatHoursCompact(external)}
Total {formatHoursCompact(total)}
) })}
)}
) }