mega-plant/components/OperatorsView.tsx
David f85b9378a2 Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.

Insurance (R503 330/yr, 20 vehicles)
  A premium can move every month or three, so it is stored as a RATE PERIOD in
  insurance_rates and each month's cost is resolved against the rate in force
  that month. cost_lines insurance rows are derived from those periods. An absent
  rate is null, never 0 — nobody has entered the date, which is not the same as
  being insured for free.

Financing (20 agreements, R428 585/mo)
  Instalment from the sheet; start date, paid-off date and the settled toggle are
  human-entered and preserved across re-seeds. Months-left and outstanding are
  null until a date is entered, never R0 on a live debt. The two paid-off
  agreements (WB02, DT01) show as settled and are excluded from the run-rate.

Purchases + Oil Stock
  Uploads PO PDFs the way /income/invoices does. The parser reads columns by
  x-position, because plain text extraction reorders them, and refuses any PO
  whose lines do not sum to the sub-total. A cost code either names a machine
  ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
  guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
  stock is the sum of movements so the first usage is a subtraction from history.

Operators (19) and Mechanics (2)
  Both are people with an employee number, separately reconcilable by the seed so
  someone added in the app survives. Mechanics get a badge QR; a mechanic with no
  employee number cannot be printed, because the code is what the QR encodes.

Cross-cutting
  - The seed no longer deletes operators, mechanics or oil items: people are added
    in the app, and those rows exist in no file.
  - Re-pointing allocations that PocketBase clears because `assets` is rebuilt
    with new ids each seed (operators, purchase lines).
  - One QR encoder for screen and PDF, so a printed code cannot drift from the one
    on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
  - Every figure is null-safe: unknown is never rendered as zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 01:06:27 +02:00

394 lines
No EOL
18 KiB
TypeScript

"use client";
import { useState, useRef, useEffect, useTransition } from "react";
import { useRouter } from "next/navigation";
import { UserRound, UserPlus, X, Check, TriangleAlert, Loader2 } from "lucide-react";
import { ScrollFade } from "@/components/ScrollFade";
import type { OperatorRow } from "@/lib/rows";
import { ZAR2 } from "@/lib/utils";
const ACCENT = "#7A9E06"; // the profit-line green — operators are people, not machines
export function OperatorsView({ rows }: { rows: OperatorRow[] }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [savedId, setSavedId] = useState<string | null>(null);
const [assigningId, setAssigningId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [addingBusy, setAddingBusy] = useState(false);
const scroller = useRef<HTMLDivElement>(null);
const [overflows, setOverflows] = useState(false);
useEffect(() => {
const el = scroller.current;
if (!el) return;
const check = () => setOverflows(el.scrollWidth > el.clientWidth + 1);
check();
const ro = new ResizeObserver(check);
ro.observe(el);
return () => ro.disconnect();
}, [rows.length]);
async function allocate(operatorId: string, machineId: string | null) {
startTransition(async () => {
try {
const res = await fetch(`/api/operators/${operatorId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId: machineId }),
});
const body = await res.json();
if (body.ok) {
setSavedId(operatorId);
setTimeout(() => setSavedId((s) => (s === operatorId ? null : s)), 1600);
if (body.sharedWith) {
alert(
`Heads up — ${body.operator.name} now shares ${body.operator.asset.code} with ${body.sharedWith.name}.`,
);
}
router.refresh();
} else {
alert(body.error || "Could not save");
}
} catch {
alert("Could not save — check your connection");
}
});
}
async function addOperator(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
setAddingBusy(true);
try {
const res = await fetch("/api/operators", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: fd.get("name"),
employeeCode: fd.get("employeeCode"),
rate: fd.get("rate"),
assetId: fd.get("assetId") || "",
}),
});
const body = await res.json();
if (body.ok) {
setAdding(false);
router.refresh();
} else {
alert(body.error || "Could not add the operator");
}
} catch {
alert("Could not save — check your connection");
} finally {
setAddingBusy(false);
}
}
const machineRows = rows.filter((r) => !r.isUnassignedOperator);
const unassignedRows = rows.filter((r) => r.isUnassignedOperator);
const assigningRow = assigningId ? rows.find((r) => r.operatorId === assigningId) ?? null : null;
// EVERY machine is offered, not just the staffed ones. Filtering to staffed rows
// here meant the picker could not offer an EMPTY machine — so you could move an
// operator around but never staff one, which is the opposite of the point.
const options = machineRows;
return (
<div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap mb-5">
<span className="px-2.5 py-1 rounded-md font-medium bg-[#7A9E06]/10 text-[#7A9E06]">
{machineRows.length} machines
</span>
<span className="px-2.5 py-1 rounded-md font-medium bg-gray-100 text-gray-500 dark:bg-white/5 dark:text-gray-400">
{machineRows.filter((r) => !r.operatorId).length} with no operator
</span>
{unassignedRows.length > 0 && (
<span className="px-2.5 py-1 rounded-md font-medium bg-[#EDAE49]/10 text-[#EDAE49]">
{unassignedRows.length} operator{unassignedRows.length !== 1 && "s"} unallocated
</span>
)}
</div>
{!adding && (
<button
onClick={() => setAdding(true)}
className="mb-4 min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 border transition"
style={{ borderColor: `${ACCENT}55`, color: ACCENT }}
>
<UserPlus className="w-4 h-4" /> Add operator
</button>
)}
{adding && (
<section className="mb-4 rounded-2xl border p-4" style={{ borderColor: `${ACCENT}55`, background: `${ACCENT}0d` }}>
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-sm font-medium text-[#003D5B] dark:text-white">Add an operator</span>
<button
onClick={() => setAdding(false)}
aria-label="Close the add-operator panel"
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-500 hover:bg-white dark:hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={addOperator} className="flex flex-col sm:flex-row sm:items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[150px]">
<label htmlFor="op-name" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Name</label>
<input id="op-name" name="name" required placeholder="e.g. SIPHO NKOSI"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200" />
</div>
<div className="flex-1 min-w-[130px]">
<label htmlFor="op-code" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Employee number</label>
<input id="op-code" name="employeeCode" required placeholder="MCW###"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 font-mono" />
</div>
<div className="flex-1 min-w-[110px]">
<label htmlFor="op-rate" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Rate / hour</label>
<input id="op-rate" name="rate" type="number" step="0.01" min="0" required placeholder="68.15"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 tabular-nums" />
</div>
<div className="flex-1 min-w-[150px]">
<label htmlFor="op-asset" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Machine (optional)</label>
<select id="op-asset" name="assetId" defaultValue=""
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200">
<option value="">— leave unallocated —</option>
{rows.filter((r) => !r.isUnassignedOperator && r.machineId).map((r) => (
<option key={r.machineId} value={r.machineId ?? ""}>{r.machineCode} · {r.machineName}</option>
))}
</select>
</div>
<button
type="submit" disabled={addingBusy}
className="min-h-[44px] px-4 rounded-lg text-xs font-semibold text-white transition disabled:opacity-60 inline-flex items-center gap-2"
style={{ background: ACCENT }}
>
{addingBusy ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
Add
</button>
</form>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
Added here, this person is <span className="font-medium">not</span> on the operator sheet — they
will be kept when the seed runs again.
</p>
</section>
)}
{/* Outside the table on purpose: inside a colSpan row it inherits the table's
full width and puts the controls ~600px off-screen on a 375px phone. */}
{assigningRow && (
<section className="mb-4 rounded-2xl border border-[#7A9E06]/30 bg-[#7A9E06]/[0.06] p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium text-[#003D5B] dark:text-white truncate">
{assigningRow.operatorName}
</span>
<span className="font-mono text-xs text-gray-500 dark:text-gray-400">
{assigningRow.employeeCode}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 truncate hidden sm:inline">
· {ZAR2(assigningRow.rate ?? 0)} / hour
</span>
</div>
<button
onClick={() => setAssigningId(null)}
aria-label="Close the allocation panel"
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-500 dark:text-gray-400 hover:bg-white dark:hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
</div>
<AssignPicker
row={assigningRow}
options={options}
pending={pending}
onAllocate={(machineId) => {
allocate(assigningRow.operatorId!, machineId);
setAssigningId(null);
}}
/>
</section>
)}
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
<div className="relative">
<div ref={scroller} className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-y border-gray-100 dark:border-border bg-gray-50 dark:bg-surface-muted">
<th className="text-left py-3 px-6 font-medium sticky left-0 z-10 bg-gray-50 dark:bg-surface-muted">Code</th>
<th className="text-left py-3 px-3 font-medium">Machine</th>
<th className="text-left py-3 px-3 font-medium">Operator</th>
<th className="text-left py-3 px-3 font-medium whitespace-nowrap">Employee code</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">
Rate
<span className="block normal-case tracking-normal text-[12px] text-gray-400 dark:text-text-soft">per hour</span>
</th>
<th className="text-right py-3 px-6 font-medium whitespace-nowrap">
<span className="sr-only">Allocate an operator</span>Allocate
</th>
</tr>
</thead>
<tbody>
{machineRows.map((r) => (
<tr key={r.machineId} className="border-b border-gray-50 dark:border-border hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
<td className="py-2.5 px-6 sticky left-0 z-10 bg-white dark:bg-surface">
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold">
{r.machineCode}
</span>
</td>
<td className="py-2.5 px-3 font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
{r.machineName}
</td>
<td className="py-2.5 px-3 whitespace-nowrap">
{r.operatorId ? (
<span className="flex items-center gap-2 text-gray-700 dark:text-gray-300">
<UserRound className="w-3 h-3 shrink-0" style={{ color: ACCENT }} />
{r.operatorName}
</span>
) : (
<Blank />
)}
</td>
<td className="py-2.5 px-3 whitespace-nowrap text-gray-500 dark:text-gray-400">
{r.employeeCode || <Blank />}
</td>
<td className="py-2.5 px-3 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: ACCENT }}>
{r.rate !== null ? ZAR2(r.rate) : <Blank />}
</td>
<td className="py-2 px-6 text-right">
{r.operatorId ? (
<RowButton
busy={savedId === r.operatorId}
label={assigningId === r.operatorId ? "Close" : "Move"}
onClick={() => setAssigningId(assigningId === r.operatorId ? null : r.operatorId)}
/>
) : (
/* No operator to move from this row. You staff an empty
machine by allocating one of the unallocated operators at
the bottom of the table — saying "pick someone" here would
point at the machine column, which holds no names. */
<span className="text-[12px] text-gray-300 dark:text-text-soft">—</span>
)}
</td>
</tr>
))}
{unassignedRows.length > 0 && (
<tr>
<td colSpan={6} className="py-2 px-6 text-xs uppercase tracking-wider text-gray-400 dark:text-text-soft bg-gray-50 dark:bg-surface-muted">
Operators with no machine
</td>
</tr>
)}
{unassignedRows.map((r) => (
<tr key={r.operatorId} className="border-b border-gray-50 dark:border-border bg-[#EDAE49]/[0.04] hover:bg-[#EDAE49]/[0.08]">
<td className="py-2.5 px-6 sticky left-0 z-10 bg-[#FDF9F2] dark:bg-[#292934]">
<Blank />
</td>
<td className="py-2.5 px-3 whitespace-nowrap">
<Blank />
</td>
<td className="py-2.5 px-3 whitespace-nowrap">
<span className="flex items-center gap-2 font-medium text-[#003D5B] dark:text-white">
<UserRound className="w-3 h-3 shrink-0" style={{ color: ACCENT }} />
{r.operatorName}
</span>
</td>
<td className="py-2.5 px-3 whitespace-nowrap text-gray-500 dark:text-gray-400">
{r.employeeCode}
</td>
<td className="py-2.5 px-3 text-right tabular-nums font-semibold whitespace-nowrap" style={{ color: ACCENT }}>
{r.rate !== null ? ZAR2(r.rate) : <Blank />}
</td>
<td className="py-2 px-6 text-right">
<RowButton
busy={savedId === r.operatorId}
label={assigningId === r.operatorId ? "Close" : "Allocate"}
onClick={() => setAssigningId(assigningId === r.operatorId ? null : r.operatorId)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{overflows && <ScrollFade />}
</div>
</div>
</div>
);
}
/** Blank means "nobody here yet" — never a zero and never a dash that reads as zero. */
function Blank() {
return <span className="text-gray-300 dark:text-text-soft italic font-normal text-[12px]">none</span>;
}
function RowButton({ label, onClick, busy }: { label: string; onClick: () => void; busy: boolean }) {
return (
<button
onClick={onClick}
className="min-h-[44px] min-w-[88px] px-3 rounded-lg text-xs font-medium transition border inline-flex items-center justify-center gap-1.5 bg-[#7A9E06]/5 text-[#7A9E06] border-[#7A9E06]/25 hover:bg-[#7A9E06]/10"
>
{busy ? (
<>
<Check className="w-3.5 h-3.5" /> Saved
</>
) : (
label
)}
</button>
);
}
function AssignPicker({
row, options, pending, onAllocate,
}: {
row: OperatorRow;
options: OperatorRow[];
pending: boolean;
onAllocate: (machineId: string | null) => void;
}) {
const [value, setValue] = useState(row.machineId ?? "");
return (
<div>
<label htmlFor="assign-machine" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">
Allocate to machine
</label>
<select
id="assign-machine"
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={pending}
className="min-h-[44px] w-full sm:w-[340px] px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200"
>
<option value="">— not allocated to a machine —</option>
{options
.filter((o) => !o.isUnassignedOperator && o.machineId)
.map((o) => (
<option key={o.machineId} value={o.machineId ?? ""}>
{o.machineCode} · {o.machineName}
</option>
))}
</select>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => onAllocate(value || null)}
disabled={pending || value === (row.machineId ?? "")}
className="min-h-[44px] px-5 rounded-lg text-xs font-semibold text-white transition disabled:opacity-40"
style={{ background: ACCENT }}
>
Save allocation
</button>
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-start gap-1.5">
<TriangleAlert className="w-3.5 h-3.5 mt-0.5 shrink-0 text-[#EDAE49]" />
Two people on one machine is allowed — you will be told if you create one.
</span>
</div>
</div>
);
}