229 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|