mega-plant/components/workforce/EmployeeDashboard.tsx
David df53130861 Add Workforce pages, Plant Clock tab bar and oil & diesel book-out
- Workforce: Employees register, per-employee dashboards, Attendance table
- Plant Clock: scan-only clock, bottom tab bar (Employees / Oil & Diesel / Job cards)
- Oil & Diesel stock: diesel tank (650 L, opening 589 L), level overwrite dialog,
  QR on each card, book-out screen with searchable vehicle picker,
  filterable "Booked out" table (today/week/month/all/range)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:39:18 +02:00

145 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Download, Truck, TriangleAlert } from 'lucide-react';
import type { ClockEvent } from '@/lib/clock';
import type { WorkforceEmployee, WorkforceMachine } from '@/lib/workforce-store';
import { attendanceSummary } from '@/lib/workforce';
import { workDate } from '@/lib/clock-rules';
import { clockDate, clockTime } from '@/lib/clock-display';
import { employeeBadgePayload } from '@/lib/clock-badge';
import { buildBadgesPdf, downloadPdf } from '@/lib/qr-pdf';
import { QrCode } from '../QrCode';
import { Kpi, StatusChip, hours } from './WorkforceBits';
const dayLabel=(day:string)=>new Intl.DateTimeFormat('en-ZA',{timeZone:'UTC',weekday:'short',day:'numeric',month:'short'}).format(new Date(`${day}T12:00:00Z`));
/** A bar is full at a 9-hour day; longer days still read full rather than overflow. */
const FULL_DAY=9*60;
export function EmployeeDashboard({employee,events,machines,sharedWith,asOf}:{
employee:WorkforceEmployee; events:ClockEvent[]; machines:WorkforceMachine[];
/** Other operators on the same machine, by name. */ sharedWith:string[]; asOf:string;
}) {
const router=useRouter();
const s=attendanceSummary(events,asOf);
const today=workDate(asOf);
const month=s.days.filter(d=>d.day.startsWith(today.slice(0,7)));
const [machine,setMachine]=useState(employee.machine?.id??'');
const [saving,startSave]=useTransition();
const [notice,setNotice]=useState('');
const [rate,setRate]=useState(employee.rate?.toFixed(2)??'');
const [rateSaving,startRateSave]=useTransition();
const [rateNotice,setRateNotice]=useState('');
const rateChanged=rate.trim()!==(employee.rate?.toFixed(2)??'');
function saveRate(e:React.FormEvent) {
e.preventDefault();setRateNotice('');
const value=Number(rate.replace(',','.'));
if(!Number.isFinite(value)||value<=0){setRateNotice('Enter an hourly rate above R0.');return;}
startRateSave(async()=>{
try {
const r=await fetch(`/api/operators/${employee.recordId}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({rate:value})});
const body=await r.json();
if(!body.ok)throw new Error(body.error||'Could not save');
setRate(Number(body.operator.rate).toFixed(2));setRateNotice('Rate saved.');router.refresh();
}catch(err){setRateNotice(err instanceof Error?err.message:'Could not save — check your connection.');}
});
}
const [badgeBusy,setBadgeBusy]=useState(false);
function allocate(next:string) {
setMachine(next);setNotice('');
startSave(async()=>{
try {
const r=await fetch(`/api/operators/${employee.recordId}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({assetId:next||null})});
const body=await r.json();
if(!body.ok)throw new Error(body.error||'Could not save');
setNotice(body.sharedWith?`Saved. ${employee.name} now shares ${body.operator.asset.code} with ${body.sharedWith.name}.`:'Saved.');
router.refresh();
}catch(e){setMachine(employee.machine?.id??'');setNotice(e instanceof Error?e.message:'Could not save — check your connection.');}
});
}
async function downloadBadge() {
setBadgeBusy(true);
try{const pdf=await buildBadgesPdf([{name:employee.name,employee_code:employee.employeeNo,role:employee.role}],'Mega Plant — Employee Badges');downloadPdf(pdf,`mega-plant-${employee.employeeNo}.pdf`);}
catch{setNotice('Could not generate the badge PDF. Try again.');}
finally{setBadgeBusy(false);}
}
const onSheet=machines.filter(m=>m.onSheet);const offSheet=machines.filter(m=>!m.onSheet);
return <section className="plant-clock-page p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto">
<Link href="/employees" className="inline-flex min-h-11 items-center gap-1 text-sm text-text-muted hover:text-[#30638E] mb-2"><ArrowLeft size={16}/>All employees</Link>
<div className="flex flex-wrap justify-between items-start gap-4 mb-6">
<div className="min-w-0"><p className="plant-clock-eyebrow">{employee.role.toUpperCase()} · {employee.employeeNo||'NO EMPLOYEE NUMBER'}</p>
<h1 className="text-3xl font-semibold tracking-tight mt-2 break-words">{employee.name}</h1>
<p className="text-sm text-text-muted mt-2">{s.last?`Last scan: ${s.last.direction.toUpperCase()} · ${clockDate(s.last.scanned_at)} ${clockTime(s.last.scanned_at)}`:'Never scanned at the Plant Clock yet.'}</p></div>
<StatusChip status={s.status}/>
</div>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-2 sm:gap-4 mb-5 [&>*:last-child]:col-span-2 lg:[&>*:last-child]:col-span-1">
<Kpi label="Today" value={hours(s.todayMinutes)} hint={s.status==='in'?'Still at work':undefined}/>
<Kpi label="This week" value={hours(s.weekMinutes)} hint="From Monday"/>
<Kpi label="This month" value={hours(s.monthMinutes)}/>
<Kpi label="Days present this month" value={s.daysThisMonth} hint="Includes days with no clock-out"/>
<Kpi label="Missed clock-outs" value={s.missed.length}/>
</div>
{s.missed.length>0 && <div className="plant-clock-warning mb-5 flex gap-2"><TriangleAlert size={18} className="shrink-0 mt-0.5"/><div>
<p className="font-semibold">Never clocked out on {s.missed.length===1?'this day':'these days'} — those hours are not counted.</p>
<p className="text-xs mt-1">{s.missed.map(e=>`${clockDate(e.scanned_at)} (IN ${clockTime(e.scanned_at)})`).join(' · ')}</p></div></div>}
<div className="grid gap-5 lg:grid-cols-[1fr_320px] items-start">
<div className="space-y-5 min-w-0">
<div className="plant-clock-panel p-4 sm:p-5">
<div className="flex items-center gap-2"><Truck size={18} className="text-accent-ink"/><h2 className="text-base font-semibold">Machine & rate</h2></div>
{employee.kind==='operators' ? <div className="grid sm:grid-cols-2 gap-4 mt-4 items-start">
<div className="min-w-0">
<label className="text-sm block">Works on<select className="plant-clock-input mt-2" value={machine} disabled={saving} onChange={e=>allocate(e.target.value)}>
<option value="">No machine</option>
<optgroup label="On the operator sheet">{onSheet.map(m=><option key={m.id} value={m.id}>{m.code} — {m.name}</option>)}</optgroup>
{offSheet.length>0 && <optgroup label="Other plant">{offSheet.map(m=><option key={m.id} value={m.id}>{m.code} — {m.name}</option>)}</optgroup>}
</select></label>
<p className="text-xs text-text-muted mt-2">{saving?'Saving…':notice||(sharedWith.length?`Shares this machine with ${sharedWith.join(', ')}.`:'Saves as soon as you pick a machine.')}</p>
</div>
<form onSubmit={saveRate} className="min-w-0">
<label htmlFor="operator-rate" className="text-sm block">Rate per hour</label>
<div className="flex gap-2 mt-2"><span className="relative flex-1 min-w-0"><span className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted">R</span>
<input id="operator-rate" className="plant-clock-input !pl-7 tabular-nums" inputMode="decimal" value={rate} disabled={rateSaving} onChange={e=>setRate(e.target.value)} placeholder="0.00"/></span>
<button className="plant-clock-button plant-clock-primary shrink-0" disabled={rateSaving||!rateChanged}>{rateSaving?'Saving…':'Save'}</button></div>
<p className="text-xs text-text-muted mt-2">{rateNotice||(employee.rateSetInApp?'Set in the app — a re-seed keeps it.':'From the operator sheet.')}</p>
</form>
</div> : <p className="text-sm text-text-muted mt-3">Mechanics are not allocated to a machine.</p>}
</div>
<div className="plant-clock-panel p-4 sm:p-5">
<div className="flex flex-wrap justify-between items-center gap-2"><h2 className="text-base font-semibold">Daily hours this month</h2><span className="text-xs text-text-muted">Bar full at 9h</span></div>
{!month.length ? <p className="text-sm text-text-muted mt-3">No time recorded this month.</p>
: <ul className="mt-3 space-y-2">{month.map(d=><li key={d.day} className="grid grid-cols-[88px_1fr_64px] sm:grid-cols-[110px_1fr_150px_64px] items-center gap-3 text-sm">
<span className="text-text-muted">{dayLabel(d.day)}</span>
<span className="h-3 rounded-full bg-bg overflow-hidden"><span className={`block h-full rounded-full ${d.open?'bg-[#EDAE49]':'bg-[#00798C]'}`} style={{width:`${Math.min(100,d.minutes/FULL_DAY*100)}%`}}/></span>
<span className="hidden sm:block text-xs text-text-muted tabular-nums">{clockTime(d.firstIn)} – {!d.open&&d.lastOut?clockTime(d.lastOut):d.day===today?'now':'no out'}</span>
<span className="text-right tabular-nums">{hours(d.minutes)}</span></li>)}</ul>}
</div>
<div className="plant-clock-panel p-4 sm:p-5">
<h2 className="text-base font-semibold">Scan history</h2>
{!events.length ? <p className="text-sm text-text-muted mt-3">No scans yet.</p>
: <ul className="divide-y divide-border mt-2">{events.slice(0,30).map(e=><li key={e.id} className="py-3 flex items-center gap-3">
<span className={`plant-clock-status shrink-0 ${e.direction==='in'?'plant-clock-status-in':''}`}>{e.direction.toUpperCase()}</span>
<span className="flex-1 text-sm">{clockDate(e.scanned_at)}</span>
<span className="text-sm tabular-nums">{clockTime(e.scanned_at)}</span></li>)}</ul>}
</div>
</div>
<div className="plant-clock-panel p-4 sm:p-5 text-center">
<h2 className="text-base font-semibold text-left">QR badge</h2>
{employee.employeeNo ? <>
<div className="bg-white rounded-xl border border-border p-3 mt-4 text-[#003D5B] max-w-[260px] mx-auto"><p className="text-xs font-semibold tracking-widest">MEGA PLANT</p>
<QrCode value={employeeBadgePayload(employee)} size={240} label={`QR badge for ${employee.name}, ${employee.employeeNo}`} className="w-full h-auto aspect-square"/>
<p className="font-semibold text-sm break-words">{employee.name}</p><p className="text-sm mt-1">{employee.employeeNo}</p></div>
<button disabled={badgeBusy} onClick={()=>void downloadBadge()} className="plant-clock-button plant-clock-primary w-full mt-4"><Download size={17}/>{badgeBusy?'Preparing PDF…':'Download badge PDF'}</button>
</> : <p className="plant-clock-warning mt-4 text-left">No employee number, so no badge can be printed.</p>}
</div>
</div>
</section>;
}