feat: machine reports with filters and hours
This commit is contained in:
parent
6938bebdbb
commit
82875a2252
2 changed files with 403 additions and 11 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue