- 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>
119 lines
9 KiB
TypeScript
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>;
|
|
}
|