feat(reports): add date range filters and extend machine reports
This commit is contained in:
parent
82875a2252
commit
5b22065609
11 changed files with 742 additions and 290 deletions
|
|
@ -41,6 +41,21 @@ type HoursItem = {
|
|||
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",
|
||||
|
|
@ -53,19 +68,44 @@ const topClientsChartConfig = {
|
|||
} satisfies ChartConfig
|
||||
|
||||
export function HoursReport() {
|
||||
const [timeRange, setTimeRange] = useState("90d")
|
||||
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 data = useQuery(
|
||||
api.reports.hoursByClient,
|
||||
enabled ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange } : "skip"
|
||||
enabled && groupBy === "company"
|
||||
? {
|
||||
tenantId,
|
||||
viewerId: convexUserId as Id<"users">,
|
||||
range: timeRange === "365d" || timeRange === "all" ? "90d" : timeRange,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
}
|
||||
: "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">),
|
||||
dateFrom,
|
||||
dateTo,
|
||||
}
|
||||
: "skip"
|
||||
) as HoursByMachineResponse | undefined
|
||||
|
||||
const companies = useQuery(
|
||||
api.companies.list,
|
||||
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
|
|
@ -84,7 +124,7 @@ export function HoursReport() {
|
|||
})),
|
||||
]
|
||||
}, [companies])
|
||||
const filtered = useMemo(() => {
|
||||
const filteredCompanies = useMemo(() => {
|
||||
let items = data?.items ?? []
|
||||
if (companyId !== "all") {
|
||||
items = items.filter((it) => String(it.companyId) === companyId)
|
||||
|
|
@ -97,8 +137,24 @@ export function HoursReport() {
|
|||
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(() => {
|
||||
return filtered.reduce(
|
||||
const source = groupBy === "machine" ? filteredMachines : filteredCompanies
|
||||
return source.reduce(
|
||||
(acc, item) => {
|
||||
acc.internal += item.internalMs / 3600000
|
||||
acc.external += item.externalMs / 3600000
|
||||
|
|
@ -107,13 +163,13 @@ export function HoursReport() {
|
|||
},
|
||||
{ internal: 0, external: 0, total: 0 }
|
||||
)
|
||||
}, [filtered])
|
||||
}, [filteredCompanies, filteredMachines, groupBy])
|
||||
|
||||
// No number formatter needed; we use formatHoursCompact for hours
|
||||
|
||||
const filteredWithComputed = useMemo(
|
||||
const filteredCompaniesWithComputed = useMemo(
|
||||
() =>
|
||||
filtered.map((row) => {
|
||||
filteredCompanies.map((row) => {
|
||||
const internal = row.internalMs / 3600000
|
||||
const external = row.externalMs / 3600000
|
||||
const total = row.totalMs / 3600000
|
||||
|
|
@ -129,11 +185,11 @@ export function HoursReport() {
|
|||
usagePercent,
|
||||
}
|
||||
}),
|
||||
[filtered]
|
||||
[filteredCompanies]
|
||||
)
|
||||
const topClientsData = useMemo(
|
||||
() =>
|
||||
filteredWithComputed
|
||||
filteredCompaniesWithComputed
|
||||
.slice()
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 10)
|
||||
|
|
@ -142,7 +198,7 @@ export function HoursReport() {
|
|||
internas: row.internal,
|
||||
externas: row.external,
|
||||
})),
|
||||
[filteredWithComputed]
|
||||
[filteredCompaniesWithComputed]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -161,68 +217,184 @@ export function HoursReport() {
|
|||
companyId={companyId}
|
||||
onCompanyChange={(value) => setCompanyId(value)}
|
||||
companyOptions={companyOptions}
|
||||
timeRange={timeRange as "90d" | "30d" | "7d"}
|
||||
onTimeRangeChange={(value) => setTimeRange(value)}
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={(value) => {
|
||||
setTimeRange(value)
|
||||
setDateFrom(null)
|
||||
setDateTo(null)
|
||||
}}
|
||||
showBillingFilter
|
||||
billingFilter={billingFilter}
|
||||
onBillingFilterChange={(value) => setBillingFilter(value)}
|
||||
exportHref={`/api/reports/hours-by-client.xlsx?range=${timeRange}${
|
||||
companyId !== "all" ? `&companyId=${companyId}` : ""
|
||||
}`}
|
||||
exportHref={`/api/reports/hours-by-client.xlsx?range=${
|
||||
timeRange === "365d" || timeRange === "all" ? "90d" : timeRange
|
||||
}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
|
||||
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onDateRangeChange={({ from, to }) => {
|
||||
setDateFrom(from)
|
||||
setDateTo(to)
|
||||
}}
|
||||
allowExtendedRanges={groupBy === "machine"}
|
||||
extraFilters={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-neutral-500">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>
|
||||
}
|
||||
/>
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Top clientes por horas</CardTitle>
|
||||
<CardDescription>Comparativo empilhado de horas internas x externas (top 10).</CardDescription>
|
||||
<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>
|
||||
{!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>
|
||||
{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>
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
{!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))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<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>Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado.</CardDescription>
|
||||
<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">
|
||||
|
|
@ -238,65 +410,125 @@ export function HoursReport() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{filteredWithComputed.length === 0 ? (
|
||||
{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 empresa encontrada para o filtro selecionado.
|
||||
Nenhuma máquina 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>
|
||||
{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>
|
||||
<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}%` : "—"}
|
||||
<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>
|
||||
{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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue