243 lines
11 KiB
TypeScript
243 lines
11 KiB
TypeScript
"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, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
|
import { Progress } from "@/components/ui/progress"
|
|
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
|
|
|
type HoursItem = {
|
|
companyId: string
|
|
name: string
|
|
isAvulso: boolean
|
|
internalMs: number
|
|
externalMs: number
|
|
totalMs: number
|
|
contractedHoursPerMonth?: number | null
|
|
}
|
|
|
|
export function HoursReport() {
|
|
const [timeRange, setTimeRange] = useState("90d")
|
|
const [query, setQuery] = useState("")
|
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
|
const { session, convexUserId } = useAuth()
|
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
|
|
|
const data = useQuery(
|
|
api.reports.hoursByClient,
|
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange } : "skip"
|
|
) as { rangeDays: number; items: HoursItem[] } | undefined
|
|
|
|
const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
|
|
const filtered = useMemo(() => {
|
|
const items = data?.items ?? []
|
|
const q = query.trim().toLowerCase()
|
|
let list = items
|
|
if (companyId !== "all") list = list.filter((it) => String(it.companyId) === companyId)
|
|
if (q) list = list.filter((it) => it.name.toLowerCase().includes(q))
|
|
return list
|
|
}, [data?.items, query, companyId])
|
|
|
|
const totals = useMemo(() => {
|
|
return filtered.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 }
|
|
)
|
|
}, [filtered])
|
|
|
|
const numberFormatter = useMemo(
|
|
() =>
|
|
new Intl.NumberFormat("pt-BR", {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}),
|
|
[]
|
|
)
|
|
|
|
const filteredWithComputed = useMemo(
|
|
() =>
|
|
filtered.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,
|
|
}
|
|
}),
|
|
[filtered]
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle>Top clientes por horas</CardTitle>
|
|
<CardDescription>Comparativo empilhado de horas internas x externas (top 10).</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!filteredWithComputed || filteredWithComputed.length === 0 ? (
|
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem dados para o período.</p>
|
|
) : (
|
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
|
<BarChart
|
|
data={filteredWithComputed
|
|
.slice()
|
|
.sort((a, b) => b.total - a.total)
|
|
.slice(0, 10)
|
|
.map((r) => ({ name: r.name, internas: r.internal, externas: r.external }))}
|
|
>
|
|
<CartesianGrid vertical={false} />
|
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} interval={0} angle={-30} height={60} />
|
|
<Bar dataKey="internas" stackId="a" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
|
|
<Bar dataKey="externas" stackId="a" fill="var(--chart-2)" radius={[4, 4, 0, 0]} />
|
|
<ChartTooltip content={<ChartTooltipContent className="w-[200px]" />} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle>Horas</CardTitle>
|
|
<CardDescription>Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado.</CardDescription>
|
|
<CardAction>
|
|
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end">
|
|
<Input
|
|
placeholder="Pesquisar empresa..."
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
className="h-9 w-full min-w-56 sm:w-72"
|
|
/>
|
|
<Select value={companyId} onValueChange={setCompanyId}>
|
|
<SelectTrigger className="w-full min-w-56 sm:w-64">
|
|
<SelectValue placeholder="Todas as empresas" />
|
|
</SelectTrigger>
|
|
<SelectContent className="rounded-xl">
|
|
<SelectItem value="all">Todas as empresas</SelectItem>
|
|
{(companies ?? []).map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<ToggleGroup type="single" value={timeRange} onValueChange={setTimeRange} variant="outline" className="hidden md:flex">
|
|
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
|
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
|
|
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
<Button asChild size="sm" variant="outline">
|
|
<a
|
|
href={`/api/reports/hours-by-client.csv?range=${timeRange}${query ? `&q=${encodeURIComponent(query)}` : ""}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
|
|
download
|
|
>
|
|
Exportar CSV
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</CardAction>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{[
|
|
{ key: "internal", label: "Horas internas", value: numberFormatter.format(totals.internal) },
|
|
{ key: "external", label: "Horas externas", value: numberFormatter.format(totals.external) },
|
|
{ key: "total", label: "Total acumulado", value: numberFormatter.format(totals.total) },
|
|
].map((item) => (
|
|
<div key={item.key} className="rounded-xl border border-slate-200 bg-slate-50/80 p-4 shadow-sm">
|
|
<p className="text-xs uppercase tracking-wide text-neutral-500">{item.label}</p>
|
|
<p className="mt-2 text-2xl font-semibold text-neutral-900">{item.value} h</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{filteredWithComputed.length === 0 ? (
|
|
<div className="mt-6 rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-8 text-center text-sm text-muted-foreground">
|
|
Nenhuma empresa encontrada para o filtro selecionado.
|
|
</div>
|
|
) : (
|
|
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
|
{filteredWithComputed.map((row) => (
|
|
<div
|
|
key={row.companyId}
|
|
className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-slate-300"
|
|
>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-neutral-900">{row.name}</h3>
|
|
<p className="text-xs text-neutral-500">ID {row.companyId}</p>
|
|
</div>
|
|
<Badge variant={row.isAvulso ? "secondary" : "outline"} className="rounded-full px-3 py-1 text-xs font-medium">
|
|
{row.isAvulso ? "Cliente avulso" : "Recorrente"}
|
|
</Badge>
|
|
</div>
|
|
<div className="mt-4 grid gap-3 rounded-xl border border-slate-100 bg-slate-50/70 p-4 text-sm text-neutral-700">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs uppercase text-neutral-500">Horas internas</span>
|
|
<span className="font-semibold text-neutral-900">{numberFormatter.format(row.internal)} h</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs uppercase text-neutral-500">Horas externas</span>
|
|
<span className="font-semibold text-neutral-900">{numberFormatter.format(row.external)} h</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs uppercase text-neutral-500">Total</span>
|
|
<span className="font-semibold text-neutral-900">{numberFormatter.format(row.total)} h</span>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 space-y-2">
|
|
<div className="flex items-center justify-between text-xs text-neutral-500">
|
|
<span>Contratadas/mês</span>
|
|
<span className="font-medium text-neutral-800">
|
|
{row.contracted ? `${numberFormatter.format(row.contracted)} h` : "—"}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between text-xs text-neutral-500">
|
|
<span>Uso</span>
|
|
<span className="font-semibold text-neutral-800">
|
|
{row.usagePercent !== null ? `${row.usagePercent}%` : "—"}
|
|
</span>
|
|
</div>
|
|
{row.usagePercent !== null ? (
|
|
<Progress value={row.usagePercent} className="h-2" />
|
|
) : (
|
|
<div className="rounded-full border border-dashed border-slate-200 py-1 text-center text-[11px] text-neutral-500">
|
|
Defina horas contratadas para acompanhar o uso
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|