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 (

Hours report

Plan vs actual based on closed time logs. Longer sessions where an operator paused and resumed count every segment separately.

{/* ─── Summary strip ───────────────────────────────────────── */}
0 ? formatMinutes(report.totalPlannedMinutes) : "—"} hint="Sum of plannedMinutes for ops touched in range" /> 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" } />
{/* ─── By machine ──────────────────────────────────────────── */}

By machine

{report.byMachine.length === 0 ? (

No machine-attached time in this window.

) : ( {report.byMachine.map((row) => ( ))} {report.unassignedMachineMinutes > 0 && ( )}
Machine Ops Planned Actual Variance
{row.name} {row.operations} {row.plannedMinutes == null ? "—" : formatMinutes(row.plannedMinutes)} {formatMinutes(row.actualMinutes)} {row.plannedMinutes == null || row.plannedMinutes === 0 ? "—" : `${Math.round( ((row.actualMinutes - row.plannedMinutes) / row.plannedMinutes) * 100, )}%`}
Ops without a machine assigned {formatMinutes(report.unassignedMachineMinutes)}
)}
{/* ─── By operator ─────────────────────────────────────────── */}

By operator

Plans are per-operation, not per-operator, so only actual time is shown here.

{report.byOperator.length === 0 ? (

No operator time in this window.

) : ( {report.byOperator.map((row) => ( ))}
Operator Ops touched Actual
{row.name} {row.operations} {formatMinutes(row.actualMinutes)}
)}
); } 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 (

{label}

{value}

{hint}

); }