This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user