394 lines
18 KiB
TypeScript
394 lines
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>
|
||
|
|
);
|
||
|
|
}
|