- 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>
145 lines
10 KiB
TypeScript
145 lines
10 KiB
TypeScript
'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>;
|
||
}
|