Files
jason e0dfac2d48
Build and Push Docker Image / build (push) Successful in 1m4s
step 9 and cleanup
2026-04-22 09:27:01 -05:00

229 lines
9.1 KiB
TypeScript

import Link from "next/link";
import { getHoursReport, formatMinutes } from "@/lib/reports";
export const dynamic = "force-dynamic";
/**
* Plan-vs-actual hours report.
*
* Two tables: by machine, by operator. Durations come from closed TimeLog
* rows in the selected window (default: last 30 days). "Plan" is the sum
* of Operation.plannedMinutes for ops that had ANY TimeLog in the window
* — scoped the same way as actual so the two columns are apples-to-apples.
* Operator plans don't exist in the schema (an op has no planned operator),
* so the operator table only shows actuals.
*/
type RangeKey = "7d" | "30d" | "90d" | "all";
function rangeStart(key: RangeKey): Date {
const now = new Date();
if (key === "all") return new Date(0);
const days = key === "7d" ? 7 : key === "30d" ? 30 : 90;
return new Date(now.getTime() - days * 86_400_000);
}
function isRange(v: unknown): v is RangeKey {
return v === "7d" || v === "30d" || v === "90d" || v === "all";
}
export default async function ReportsPage({
searchParams,
}: {
searchParams: Promise<{ range?: string }>;
}) {
const { range: rawRange } = await searchParams;
const range: RangeKey = isRange(rawRange) ? rawRange : "30d";
const since = rangeStart(range);
const report = await getHoursReport(since);
const planVarianceClass = (actual: number, planned: number | null) => {
if (planned == null) return "text-slate-500";
if (planned === 0) return "text-slate-500";
const ratio = actual / planned;
if (ratio > 1.15) return "text-red-600 font-medium";
if (ratio < 0.85) return "text-emerald-600 font-medium";
return "text-slate-700";
};
return (
<div className="mx-auto max-w-7xl px-4 py-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Hours report</h1>
<p className="text-slate-500 mt-1 text-sm">
Plan vs actual based on closed time logs. Longer sessions where an operator
paused and resumed count every segment separately.
</p>
</div>
<nav className="flex items-center gap-1 text-sm rounded-md border border-slate-200 bg-white p-1">
{(["7d", "30d", "90d", "all"] as const).map((k) => (
<Link
key={k}
href={`/admin/reports?range=${k}`}
className={`px-3 py-1 rounded ${
k === range
? "bg-slate-900 text-white"
: "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
}`}
>
{k === "all" ? "All time" : `Last ${k}`}
</Link>
))}
</nav>
</div>
{/* ─── Summary strip ───────────────────────────────────────── */}
<div className="grid gap-4 sm:grid-cols-3 mb-8">
<SummaryCard
label="Total actual"
value={formatMinutes(report.totalActualMinutes)}
hint="Summed across every closed TimeLog"
/>
<SummaryCard
label="Total planned"
value={report.totalPlannedMinutes > 0 ? formatMinutes(report.totalPlannedMinutes) : "—"}
hint="Sum of plannedMinutes for ops touched in range"
/>
<SummaryCard
label="Variance"
value={
report.totalPlannedMinutes > 0
? `${Math.round(
((report.totalActualMinutes - report.totalPlannedMinutes) /
report.totalPlannedMinutes) *
100,
)}%`
: "—"
}
hint="Positive means actual > plan"
tone={
report.totalPlannedMinutes === 0
? "neutral"
: report.totalActualMinutes > report.totalPlannedMinutes * 1.15
? "bad"
: report.totalActualMinutes < report.totalPlannedMinutes * 0.85
? "good"
: "neutral"
}
/>
</div>
{/* ─── By machine ──────────────────────────────────────────── */}
<section className="mb-8">
<h2 className="text-lg font-semibold mb-3">By machine</h2>
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
{report.byMachine.length === 0 ? (
<p className="px-4 py-10 text-center text-slate-500 text-sm">
No machine-attached time in this window.
</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
<tr>
<th className="px-4 py-2 font-medium">Machine</th>
<th className="px-4 py-2 font-medium">Ops</th>
<th className="px-4 py-2 font-medium">Planned</th>
<th className="px-4 py-2 font-medium">Actual</th>
<th className="px-4 py-2 font-medium">Variance</th>
</tr>
</thead>
<tbody>
{report.byMachine.map((row) => (
<tr key={row.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2">{row.name}</td>
<td className="px-4 py-2 text-slate-600">{row.operations}</td>
<td className="px-4 py-2 text-slate-700">
{row.plannedMinutes == null ? "—" : formatMinutes(row.plannedMinutes)}
</td>
<td
className={`px-4 py-2 ${planVarianceClass(
row.actualMinutes,
row.plannedMinutes,
)}`}
>
{formatMinutes(row.actualMinutes)}
</td>
<td className="px-4 py-2 text-slate-600">
{row.plannedMinutes == null || row.plannedMinutes === 0
? "—"
: `${Math.round(
((row.actualMinutes - row.plannedMinutes) / row.plannedMinutes) * 100,
)}%`}
</td>
</tr>
))}
{report.unassignedMachineMinutes > 0 && (
<tr className="border-t border-slate-200 bg-slate-50 text-xs text-slate-500">
<td className="px-4 py-2 italic">Ops without a machine assigned</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2">{formatMinutes(report.unassignedMachineMinutes)}</td>
<td className="px-4 py-2"></td>
</tr>
)}
</tbody>
</table>
)}
</div>
</section>
{/* ─── By operator ─────────────────────────────────────────── */}
<section>
<h2 className="text-lg font-semibold mb-3">By operator</h2>
<p className="text-xs text-slate-500 mb-3">
Plans are per-operation, not per-operator, so only actual time is shown here.
</p>
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
{report.byOperator.length === 0 ? (
<p className="px-4 py-10 text-center text-slate-500 text-sm">
No operator time in this window.
</p>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
<tr>
<th className="px-4 py-2 font-medium">Operator</th>
<th className="px-4 py-2 font-medium">Ops touched</th>
<th className="px-4 py-2 font-medium">Actual</th>
</tr>
</thead>
<tbody>
{report.byOperator.map((row) => (
<tr key={row.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2">{row.name}</td>
<td className="px-4 py-2 text-slate-600">{row.operations}</td>
<td className="px-4 py-2 text-slate-700">{formatMinutes(row.actualMinutes)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
</div>
);
}
function SummaryCard({
label,
value,
hint,
tone = "neutral",
}: {
label: string;
value: string;
hint: string;
tone?: "neutral" | "good" | "bad";
}) {
const valueClass =
tone === "bad" ? "text-red-600" : tone === "good" ? "text-emerald-600" : "text-slate-900";
return (
<div className="rounded-xl bg-white border border-slate-200 p-5">
<p className="text-xs text-slate-500">{label}</p>
<p className={`text-2xl font-semibold tracking-tight mt-1 ${valueClass}`}>{value}</p>
<p className="text-xs text-slate-500 mt-1">{hint}</p>
</div>
);
}