623 lines
26 KiB
TypeScript
623 lines
26 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,
|
|
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<string | null>(null)
|
|
const [dateTo, setDateTo] = useState<string | null>(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<SearchableComboboxOption[]>(() => {
|
|
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 (
|
|
<div className="space-y-6">
|
|
{isAdmin ? (
|
|
<ReportScheduleDrawer
|
|
open={schedulerOpen}
|
|
onOpenChange={setSchedulerOpen}
|
|
defaultReports={["hours"]}
|
|
defaultRange={timeRange}
|
|
defaultCompanyId={companyId === "all" ? null : companyId}
|
|
companyOptions={companyOptions}
|
|
/>
|
|
) : null}
|
|
<div className="flex flex-col gap-4 rounded-3xl border border-border/60 bg-white/95 p-4 shadow-sm">
|
|
<div className="flex flex-wrap gap-3 lg:items-center">
|
|
<div className="flex flex-1 flex-wrap gap-3 lg:flex-nowrap">
|
|
<SearchableCombobox
|
|
value={companyId}
|
|
onValueChange={(value) => 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"
|
|
/>
|
|
<DateRangeButton
|
|
from={dateFrom}
|
|
to={dateTo}
|
|
onChange={({ from, to }) => {
|
|
setDateFrom(from)
|
|
setDateTo(to)
|
|
}}
|
|
className="w-full min-w-[200px] lg:w-auto lg:flex-1"
|
|
align="center"
|
|
/>
|
|
</div>
|
|
<div className="flex w-full justify-start lg:w-auto lg:justify-end lg:ml-auto">
|
|
<ToggleGroup
|
|
type="single"
|
|
value={billingFilter}
|
|
onValueChange={(value) => value && setBillingFilter(value as typeof billingFilter)}
|
|
className="inline-flex rounded-full border border-border/60 bg-white/80 p-1 shadow-sm"
|
|
>
|
|
<ToggleGroupItem
|
|
value="all"
|
|
className="rounded-full px-6 py-2 text-xs font-semibold whitespace-nowrap transition first:rounded-l-full last:rounded-r-full"
|
|
>
|
|
Todos
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem
|
|
value="avulso"
|
|
className="rounded-full px-6 py-2 text-xs font-semibold whitespace-nowrap transition first:rounded-l-full last:rounded-r-full"
|
|
>
|
|
Somente avulsos
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem
|
|
value="contratado"
|
|
className="rounded-full px-6 py-2 text-xs font-semibold whitespace-nowrap transition first:rounded-l-full last:rounded-r-full"
|
|
>
|
|
Somente contratados
|
|
</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<ToggleGroup
|
|
type="single"
|
|
value={timeRange}
|
|
onValueChange={(value) => {
|
|
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]"
|
|
>
|
|
<ToggleGroupItem value="90d" className="min-w-[80px] justify-center px-4">
|
|
90 dias
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem value="30d" className="min-w-[80px] justify-center px-4">
|
|
30 dias
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem value="7d" className="min-w-[80px] justify-center px-4">
|
|
7 dias
|
|
</ToggleGroupItem>
|
|
{groupBy === "machine" ? (
|
|
<>
|
|
<ToggleGroupItem value="365d" className="hidden min-w-[96px] justify-center px-4 lg:inline-flex">
|
|
12 meses
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem value="all" className="hidden min-w-[120px] justify-center px-5 lg:inline-flex">
|
|
Todo histórico
|
|
</ToggleGroupItem>
|
|
</>
|
|
) : null}
|
|
</ToggleGroup>
|
|
<div className="flex flex-1 min-w-[220px] flex-wrap items-center justify-center gap-2 text-xs font-medium text-neutral-500">
|
|
<span>Agrupar por</span>
|
|
<div className="inline-flex rounded-full border border-slate-200 bg-slate-50 p-1 text-xs">
|
|
<button
|
|
type="button"
|
|
className={`px-3 py-1 rounded-full transition ${
|
|
groupBy === "company"
|
|
? "bg-white text-neutral-900 shadow-sm"
|
|
: "text-neutral-500 hover:text-neutral-800"
|
|
}`}
|
|
onClick={() => setGroupBy("company")}
|
|
>
|
|
Empresa
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`px-3 py-1 rounded-full transition ${
|
|
groupBy === "machine"
|
|
? "bg-white text-neutral-900 shadow-sm"
|
|
: "text-neutral-500 hover:text-neutral-800"
|
|
}`}
|
|
onClick={() => setGroupBy("machine")}
|
|
>
|
|
Máquina
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="ml-auto flex flex-wrap items-center justify-end gap-2 lg:flex-nowrap">
|
|
{isAdmin ? (
|
|
<Button variant="outline" size="sm" onClick={() => setSchedulerOpen(true)}>
|
|
Agendar exportação
|
|
</Button>
|
|
) : null}
|
|
<Button asChild size="sm" variant="outline">
|
|
<a href={exportHref}>Exportar XLSX</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle>
|
|
{groupBy === "company" ? "Top clientes por horas" : "Top máquinas por horas"}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{groupBy === "company"
|
|
? "Comparativo empilhado de horas internas x externas (top 10 empresas)."
|
|
: "Comparativo empilhado de horas internas x externas (top 10 máquinas)."}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{groupBy === "company" ? (
|
|
!filteredCompaniesWithComputed || filteredCompaniesWithComputed.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={topClientsChartConfig} className="aspect-auto h-[320px] w-full">
|
|
<BarChart
|
|
data={topClientsData}
|
|
layout="vertical"
|
|
margin={{ top: 16, right: 24, bottom: 16, left: 0 }}
|
|
barCategoryGap={12}
|
|
>
|
|
<CartesianGrid horizontal vertical={false} />
|
|
<XAxis
|
|
type="number"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(value) => formatHoursCompact(Number(value))}
|
|
/>
|
|
<YAxis dataKey="name" type="category" tickLine={false} axisLine={false} width={180} />
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[220px]"
|
|
labelFormatter={(value) => (
|
|
<span className="font-semibold text-foreground">{String(value)}</span>
|
|
)}
|
|
formatter={(value, name) => (
|
|
<>
|
|
<span className="text-muted-foreground">
|
|
{name === "internas" ? "Horas internas" : "Horas externas"}
|
|
</span>
|
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
|
{formatHoursCompact(Number(value))}
|
|
</span>
|
|
</>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<ChartLegend content={<ChartLegendContent />} />
|
|
<Bar dataKey="internas" stackId="hours" fill="var(--color-internas)" radius={[8, 0, 0, 8]} barSize={18} />
|
|
<Bar dataKey="externas" stackId="hours" fill="var(--color-externas)" radius={[0, 8, 8, 0]} barSize={18} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
)
|
|
) : (
|
|
<>
|
|
{!filteredMachines || filteredMachines.length === 0 ? (
|
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
|
|
Nenhuma máquina encontrada para o filtro selecionado.
|
|
</p>
|
|
) : (
|
|
<ChartContainer config={topClientsChartConfig} className="aspect-auto h-[320px] w-full">
|
|
<BarChart
|
|
data={filteredMachines
|
|
.slice()
|
|
.sort((a, b) => 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}
|
|
>
|
|
<CartesianGrid horizontal vertical={false} />
|
|
<XAxis
|
|
type="number"
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(value) => formatHoursCompact(Number(value))}
|
|
/>
|
|
<YAxis dataKey="name" type="category" tickLine={false} axisLine={false} width={200} />
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
className="w-[220px]"
|
|
labelFormatter={(value) => (
|
|
<span className="font-semibold text-foreground">{String(value)}</span>
|
|
)}
|
|
formatter={(value, name) => (
|
|
<>
|
|
<span className="text-muted-foreground">
|
|
{name === "internas" ? "Horas internas" : "Horas externas"}
|
|
</span>
|
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
|
{formatHoursCompact(Number(value))}
|
|
</span>
|
|
</>
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
<ChartLegend content={<ChartLegendContent />} />
|
|
<Bar dataKey="internas" stackId="hours" fill="var(--color-internas)" radius={[8, 0, 0, 8]} barSize={18} />
|
|
<Bar dataKey="externas" stackId="hours" fill="var(--color-externas)" radius={[0, 8, 8, 0]} barSize={18} />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle>Horas</CardTitle>
|
|
<CardDescription>
|
|
{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."}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{[
|
|
{ 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) => (
|
|
<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}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{groupBy === "company" ? (
|
|
!filteredCompaniesWithComputed.length ? (
|
|
<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">
|
|
{filteredCompaniesWithComputed.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">
|
|
{formatHoursCompact(row.internal)}
|
|
</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">
|
|
{formatHoursCompact(row.external)}
|
|
</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">
|
|
{formatHoursCompact(row.total)}
|
|
</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 ? formatHoursCompact(row.contracted) : "—"}
|
|
</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>
|
|
)
|
|
) : !filteredMachines.length ? (
|
|
<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 máquina encontrada para o filtro selecionado.
|
|
</div>
|
|
) : (
|
|
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
|
{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 (
|
|
<div
|
|
key={row.machineId}
|
|
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.machineHostname ?? row.machineId}
|
|
</h3>
|
|
<p className="text-xs text-neutral-500">
|
|
{row.companyName ?? "Sem empresa"}
|
|
</p>
|
|
</div>
|
|
</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">
|
|
{formatHoursCompact(internal)}
|
|
</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">
|
|
{formatHoursCompact(external)}
|
|
</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">
|
|
{formatHoursCompact(total)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|