Atualiza portal e admin com bloqueio de máquinas desativadas
This commit is contained in:
parent
e5085962e9
commit
630110bf3a
31 changed files with 1756 additions and 244 deletions
|
|
@ -14,11 +14,7 @@ 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"
|
||||
|
||||
function formatHours(ms: number) {
|
||||
const hours = ms / 3600000
|
||||
return hours.toFixed(2)
|
||||
}
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
type HoursItem = {
|
||||
companyId: string
|
||||
|
|
@ -52,16 +48,58 @@ export function HoursReport() {
|
|||
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>Horas por cliente</CardTitle>
|
||||
<CardDescription>Horas internas e externas registradas por empresa.</CardDescription>
|
||||
<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 cliente..."
|
||||
placeholder="Pesquisar empresa..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="h-9 w-full min-w-56 sm:w-72"
|
||||
|
|
@ -83,56 +121,91 @@ export function HoursReport() {
|
|||
<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>
|
||||
<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="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-2 pr-4">Cliente</th>
|
||||
<th className="py-2 pr-4">Avulso</th>
|
||||
<th className="py-2 pr-4">Horas internas</th>
|
||||
<th className="py-2 pr-4">Horas externas</th>
|
||||
<th className="py-2 pr-4">Total</th>
|
||||
<th className="py-2 pr-4">Contratadas/mês</th>
|
||||
<th className="py-2 pr-4">Uso</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{filtered.map((row) => {
|
||||
const totalH = Number(formatHours(row.totalMs))
|
||||
const contracted = row.contractedHoursPerMonth ?? null
|
||||
const pct = contracted ? Math.round((totalH / contracted) * 100) : null
|
||||
const pctBadgeVariant: "secondary" | "destructive" = pct !== null && pct >= 90 ? "destructive" : "secondary"
|
||||
return (
|
||||
<tr key={row.companyId}>
|
||||
<td className="py-2 pr-4 font-medium text-neutral-900">{row.name}</td>
|
||||
<td className="py-2 pr-4">{row.isAvulso ? "Sim" : "Não"}</td>
|
||||
<td className="py-2 pr-4">{formatHours(row.internalMs)}</td>
|
||||
<td className="py-2 pr-4">{formatHours(row.externalMs)}</td>
|
||||
<td className="py-2 pr-4 font-semibold text-neutral-900">{formatHours(row.totalMs)}</td>
|
||||
<td className="py-2 pr-4">{contracted ?? "—"}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{pct !== null ? (
|
||||
<Badge variant={pctBadgeVariant} className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide">
|
||||
{pct}%
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-neutral-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue