mega-plant/components/workforce/EmployeesView.tsx
David c269cd00b4 Add Job Cards (vehicle-QR start/finish) and retire Operators/Mechanics pages
- Plant Clock "Job cards" tab: scan a vehicle QR to start a job, scan again to finish
  with odometer (or no meter), kind of work and a note
- /running/job-cards table with period, work, vehicle and mechanic filters
- Remove /running/operators and /running/mechanics; mechanic badge PDF moves to Employees

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:54:59 +02:00

119 lines
9 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ChevronRight, Download, Truck, UserPlus, X } from 'lucide-react';
import type { WorkforceData } from '@/lib/workforce-store';
import { attendanceSummary, employeeSlug } from '@/lib/workforce';
import { buildBadgesPdf, downloadPdf } from '@/lib/qr-pdf';
import { Kpi, StatusChip, rateText } from './WorkforceBits';
/**
* The people register. Every operator and mechanic, with the machine an operator runs
* and their rate — the information that used to live only on /running/operators.
*/
export function EmployeesView({data}:{data:WorkforceData}) {
const router=useRouter();
const [query,setQuery]=useState('');
const [role,setRole]=useState('all');
const [adding,setAdding]=useState(false);
const [kind,setKind]=useState<'operators'|'mechanics'>('operators');
const [busy,setBusy]=useState(false);
const [error,setError]=useState('');
const [building,setBuilding]=useState(false);
const mechanics=data.employees.filter(e=>e.kind==='mechanics' && e.employeeNo.trim());
async function downloadBadges() {
setBuilding(true);
try{downloadPdf(await buildBadgesPdf(mechanics.map(m=>({name:m.name,employee_code:m.employeeNo,role:m.role}))),'mega-plant-mechanic-badges.pdf');}
catch{alert('Could not build the PDF. Please try again.');}
finally{setBuilding(false);}
}
const status=useMemo(()=>{
const byEmployee=new Map<string,typeof data.events>();
for(const e of data.events){const l=byEmployee.get(e.employee)??[];l.push(e);byEmployee.set(e.employee,l);}
return new Map(data.employees.map(e=>[e.id,attendanceSummary(byEmployee.get(e.id)??[],data.asOf).status]));
},[data]);
const staffed=new Set(data.employees.map(e=>e.machine?.id).filter(Boolean));
// Same scope as the old operator register: machines on the operator sheet.
const emptyMachines=data.machines.filter(m=>m.onSheet && !staffed.has(m.id));
const operators=data.employees.filter(e=>e.kind==='operators');
const filtered=data.employees.filter(e=>`${e.name} ${e.employeeNo} ${e.role} ${e.machine?.code??''} ${e.machine?.name??''}`.toLowerCase().includes(query.toLowerCase())
&& (role==='all' || (role==='operators'?e.kind==='operators':role==='mechanics'?e.kind==='mechanics':e.kind==='operators'&&!e.machine)));
async function add(ev:React.FormEvent<HTMLFormElement>) {
ev.preventDefault();const fd=new FormData(ev.currentTarget);setBusy(true);setError('');
try {
const body=kind==='operators'
?{name:fd.get('name'),employeeCode:fd.get('employeeCode'),rate:fd.get('rate'),assetId:fd.get('assetId')||''}
:{name:fd.get('name'),employeeCode:fd.get('employeeCode'),role:fd.get('role')||'Mechanic'};
const r=await fetch(`/api/${kind}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const out=await r.json();
if(!out.ok)throw new Error(out.error||'Could not add the employee.');
setAdding(false);router.refresh();
}catch(e){setError(e instanceof Error?e.message:'Could not save — check your connection.');}
finally{setBusy(false);}
}
return <section className="plant-clock-page p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto">
<div className="flex flex-wrap justify-between items-start gap-4 mb-6">
<div><p className="plant-clock-eyebrow">MEGA PLANT / WORKFORCE</p><h1 className="text-3xl font-semibold tracking-tight mt-2">Employees</h1>
<p className="text-sm text-text-muted mt-2">Everyone on the Plant roster, the machine they run and their rate. Open someone for their dashboard.</p></div>
<div className="flex flex-wrap gap-2">
{mechanics.length>0 && <button className="plant-clock-button plant-clock-secondary" disabled={building} onClick={downloadBadges}><Download size={18}/>{building?'Building PDF…':'Download mechanic badges'}</button>}
{!adding && <button className="plant-clock-button plant-clock-primary" onClick={()=>{setError('');setAdding(true);}}><UserPlus size={18}/>Add employee</button>}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-4 mb-5">
<Kpi label="Employees" value={data.employees.length}/>
<Kpi label="At work now" value={[...status.values()].filter(s=>s==='in').length}/>
<Kpi label="Operators on a machine" value={`${operators.filter(o=>o.machine).length} / ${operators.length}`}/>
<Kpi label="Machines with no operator" value={emptyMachines.length}/>
</div>
{adding && <form onSubmit={add} className="plant-clock-panel p-4 sm:p-5 mb-5">
<div className="flex justify-between items-center gap-3 mb-4"><h2 className="text-lg font-semibold">Add an employee</h2>
<button type="button" aria-label="Close" className="plant-clock-button plant-clock-secondary" onClick={()=>setAdding(false)}><X size={16}/></button></div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 items-end">
<label className="text-sm">Type<select className="plant-clock-input mt-2" value={kind} onChange={e=>setKind(e.target.value as typeof kind)}><option value="operators">Operator</option><option value="mechanics">Mechanic</option></select></label>
<label className="text-sm">Name<input name="name" required className="plant-clock-input mt-2" placeholder="e.g. SIPHO NKOSI"/></label>
<label className="text-sm">Employee number<input name="employeeCode" required className="plant-clock-input mt-2" placeholder="e.g. MCW201" autoCapitalize="characters"/></label>
{kind==='operators' ? <>
<label className="text-sm">Rate per hour (R)<input name="rate" required type="number" min="0" step="0.01" inputMode="decimal" className="plant-clock-input mt-2"/></label>
<label className="text-sm sm:col-span-2">Machine<select name="assetId" className="plant-clock-input mt-2" defaultValue=""><option value="">No machine yet</option>
{data.machines.filter(m=>m.onSheet).map(m=><option key={m.id} value={m.id}>{m.code} — {m.name}</option>)}</select></label>
</> : <label className="text-sm">Role<input name="role" className="plant-clock-input mt-2" placeholder="Mechanic"/></label>}
<button disabled={busy} className="plant-clock-button plant-clock-primary">{busy?'Saving…':'Add employee'}</button>
</div>
<p className="text-xs text-text-muted mt-3">The employee number is what their QR badge encodes, so it must be unique.</p>
{error && <p role="alert" className="plant-clock-warning mt-3">{error}</p>}
</form>}
<div className="plant-clock-panel p-4 flex flex-wrap items-end gap-3 mb-5">
<label className="flex-1 min-w-0 basis-48 text-sm">Find an employee<input className="plant-clock-input mt-2" placeholder="Name, number or machine" value={query} onChange={e=>setQuery(e.target.value)}/></label>
<label className="text-sm flex-1 min-w-0 basis-40">Show<select className="plant-clock-input mt-2" value={role} onChange={e=>setRole(e.target.value)}>
<option value="all">Everyone</option><option value="operators">Operators</option><option value="mechanics">Mechanics</option><option value="nomachine">Operators with no machine</option></select></label>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filtered.map(e=><Link key={e.id} href={`/employees/${employeeSlug(e.id)}`} className="plant-clock-panel p-4 sm:p-5 min-w-0 block hover:border-[#30638E] transition-colors">
<div className="flex justify-between items-start gap-3"><span className="text-xs font-medium text-text-muted break-all">{e.employeeNo||'No employee number'}</span><StatusChip status={status.get(e.id)??'away'}/></div>
<h2 className="text-lg font-semibold mt-3 break-words">{e.name}</h2>
<p className="text-sm text-text-muted mt-0.5">{e.role}{e.kind==='operators'?` · ${rateText(e.rate)}`:''}</p>
<div className="border-t border-border mt-3 pt-3 flex items-center gap-2 text-sm">
<Truck size={16} className="shrink-0 text-text-muted"/>
<span className="min-w-0 flex-1 truncate">{e.machine?<><strong>{e.machine.code}</strong> · {e.machine.name}</>:e.kind==='operators'?<span className="rounded px-1.5 py-0.5 bg-[#EDAE49]/20 text-[#003D5B] dark:text-white">No machine allocated</span>:<span className="text-text-muted">Not on a machine</span>}</span>
<ChevronRight size={18} className="shrink-0 text-text-muted"/>
</div>
</Link>)}
</div>
{!filtered.length && <div className="plant-clock-panel p-8 text-center text-sm text-text-muted">No employees match.</div>}
{emptyMachines.length>0 && <div className="plant-clock-panel mt-6 p-4 sm:p-5">
<h2 className="text-base font-semibold">Machines with no operator</h2>
<p className="text-xs text-text-muted mt-1">On the operator sheet but nobody allocated. Open an operator to put them on one.</p>
<ul className="flex flex-wrap gap-2 mt-3">{emptyMachines.map(m=><li key={m.id} className="plant-clock-status"><strong className="mr-1">{m.code}</strong>{m.name}</li>)}</ul>
</div>}
</section>;
}