mega-plant/components/workforce/AttendanceView.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

182 lines
14 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 { useEffect, useMemo, useRef, useState, useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ChevronLeft, ChevronRight, RefreshCw, ScanLine } from 'lucide-react';
import type { WorkforceData } from '@/lib/workforce-store';
import { attendanceSummary, employeeSlug, periodDays, shiftPeriod, type DayTotal, type Period } from '@/lib/workforce';
import { workDate } from '@/lib/clock-rules';
import { clockDate, clockTime } from '@/lib/clock-display';
import { ScrollFade } from '../ScrollFade';
import { Kpi } from './WorkforceBits';
const PERIODS:{value:Period;label:string}[]=[{value:'day',label:'Day'},{value:'week',label:'Week'},{value:'month',label:'Month'}];
const fmt=(day:string,o:Intl.DateTimeFormatOptions)=>new Intl.DateTimeFormat('en-ZA',{timeZone:'UTC',...o}).format(new Date(`${day}T12:00:00Z`));
/** Table cells use "8:05" — compact enough for 31 columns. A dash is "nothing recorded", never 0:00. */
const hm=(m:number)=>m>0?`${Math.floor(m/60)}:${String(m%60).padStart(2,'0')}`:'—';
const MODE_KEY='mp:attendancePeriod';
type CellState='none'|'done'|'open'|'missed'|'future';
function cellState(d:DayTotal|undefined, day:string, today:string):CellState {
if(day>today)return 'future';
if(!d)return 'none';
return d.open?(day===today?'open':'missed'):'done';
}
/**
* Attendance as a table: one row per employee, one column per day of the chosen
* Day / Week / Month. Hours come from lib/workforce.ts, so a missed clock-out is
* flagged and counts 0 here exactly as it does on the employee's dashboard.
*/
export function AttendanceView({data}:{data:WorkforceData}) {
const router=useRouter();
const [refreshing,startRefresh]=useTransition();
const today=workDate(data.asOf);
const [period,setPeriodState]=useState<Period>('week');
const [anchor,setAnchor]=useState(today);
const [query,setQuery]=useState('');
const [filter,setFilter]=useState('all');
const scroller=useRef<HTMLDivElement>(null);
const [overflows,setOverflows]=useState(false);
useEffect(()=>{try{const p=localStorage.getItem(MODE_KEY);if(p==='day'||p==='week'||p==='month')setPeriodState(p);}catch{}},[]);
function setPeriod(p:Period){setPeriodState(p);try{localStorage.setItem(MODE_KEY,p);}catch{}}
const rows=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 data.employees.map(employee=>{
const summary=attendanceSummary(byEmployee.get(employee.id)??[],data.asOf);
return {employee,summary,byDay:new Map(summary.days.map(d=>[d.day,d]))};
});
},[data]);
const days=periodDays(period,anchor);
const inPeriod=(r:typeof rows[number])=>days.map(day=>r.byDay.get(day)).filter(Boolean) as DayTotal[];
const shown=rows.filter(r=>{
if(!`${r.employee.name} ${r.employee.employeeNo} ${r.employee.machine?.code??''}`.toLowerCase().includes(query.toLowerCase()))return false;
const ds=inPeriod(r);
if(filter==='present')return ds.length>0;
if(filter==='absent')return ds.length===0;
if(filter==='missed')return ds.some(d=>d.open && d.day<today);
return true;
}).sort((a,b)=>inPeriod(b).length-inPeriod(a).length||a.employee.name.localeCompare(b.employee.name));
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();
},[period,shown.length]);
const label=period==='day'?fmt(anchor,{weekday:'long',day:'numeric',month:'long',year:'numeric'})
:period==='week'?`${fmt(days[0],{day:'numeric',month:'short'})} – ${fmt(days[6],{day:'numeric',month:'short',year:'numeric'})}`
:fmt(anchor,{month:'long',year:'numeric'});
// The future has no scans: stop at the period containing today.
const atStart=days.includes(today);
const colTotal=(day:string)=>shown.reduce((n,r)=>n+(r.byDay.get(day)?.minutes??0),0);
const grand=shown.reduce((n,r)=>n+inPeriod(r).reduce((m,d)=>m+d.minutes,0),0);
const nameOf=new Map(data.employees.map(e=>[e.id,e]));
// Sticky cells must be OPAQUE or the scrolled columns bleed through them (AGENTS.md).
const stickyBody='sticky left-0 z-10 bg-surface';
const stickyHead='sticky left-0 z-20 bg-bg';
return <section className="plant-clock-page p-4 sm:p-6 lg:p-8 max-w-[1500px] 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">Attendance</h1>
<p className="text-sm text-text-muted mt-2">Hours at the Plant from the Plant Clock scans. Tap a name for that person’s dashboard.</p></div>
<Link href="/clock" className="plant-clock-button plant-clock-primary"><ScanLine size={18}/>Open employee clock</Link>
</div>
<div className="grid grid-cols-3 gap-2 sm:gap-4 mb-5">
<Kpi label="At work now" value={rows.filter(r=>r.summary.status==='in').length}/>
<Kpi label="Clocked out today" value={rows.filter(r=>r.summary.status==='out').length}/>
<Kpi label="Missed clock-outs" value={rows.reduce((n,r)=>n+r.summary.missed.length,0)}/>
</div>
<div className="plant-clock-panel p-4 mb-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
{/* Pills from lg up; a select below lg (mobile checklist rule 5). */}
<div role="group" aria-label="Period" className="hidden lg:inline-flex rounded-xl border border-border p-1 gap-1">
{PERIODS.map(p=><button key={p.value} aria-pressed={period===p.value} onClick={()=>setPeriod(p.value)}
className={`plant-clock-button !min-h-10 ${period===p.value?'bg-[#30638E] text-white':'text-text-muted hover:bg-bg'}`}>{p.label}</button>)}
</div>
<label className="lg:hidden text-sm"><span className="sr-only">Period</span>
<select className="plant-clock-input !w-auto" value={period} onChange={e=>setPeriod(e.target.value as Period)}>{PERIODS.map(p=><option key={p.value} value={p.value}>{p.label}</option>)}</select></label>
<div className="flex items-center gap-1 ml-auto lg:ml-2">
<button aria-label="Previous period" className="plant-clock-button plant-clock-secondary !px-2" onClick={()=>setAnchor(a=>shiftPeriod(period,a,-1))}><ChevronLeft size={18}/></button>
<button aria-label="Next period" className="plant-clock-button plant-clock-secondary !px-2" disabled={atStart} onClick={()=>setAnchor(a=>shiftPeriod(period,a,1))}><ChevronRight size={18}/></button>
<button className="plant-clock-button plant-clock-secondary" disabled={atStart} onClick={()=>setAnchor(today)}>Today</button>
</div>
<p className="w-full lg:w-auto lg:ml-3 text-base font-semibold">{label}</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<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={filter} onChange={e=>setFilter(e.target.value)}>
<option value="all">Everyone</option><option value="present">Present in this {period}</option><option value="absent">No scans this {period}</option><option value="missed">Missed a clock-out</option></select></label>
<button className="plant-clock-button plant-clock-secondary" disabled={refreshing} onClick={()=>startRefresh(()=>router.refresh())}><RefreshCw size={17} className={refreshing?'animate-spin':''}/>Refresh</button>
</div>
<p className="text-xs text-text-muted flex flex-wrap gap-x-4 gap-y-1">
<span>As of {clockTime(data.asOf)} SAST</span>
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-[#EDAE49] align-middle mr-1"/>No clock-out — 0 hours counted</span>
<span><span className="inline-block w-2.5 h-2.5 rounded-sm bg-[#00798C]/25 align-middle mr-1"/>Still at work</span>
</p>
</div>
<div className="plant-clock-panel relative overflow-hidden">
<div ref={scroller} className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
{period==='day' ? <table className="w-full min-w-[640px] text-sm">
<thead><tr className="text-left text-xs text-text-muted border-b border-border">
<th className={`${stickyHead} py-3 px-4 font-medium min-w-[180px]`}>Employee</th>
<th className="bg-bg py-3 px-3 font-medium">Machine</th><th className="bg-bg py-3 px-3 font-medium">First IN</th>
<th className="bg-bg py-3 px-3 font-medium">Last OUT</th><th className="bg-bg py-3 px-3 font-medium text-right">Hours</th><th className="bg-bg py-3 px-4 font-medium">Status</th></tr></thead>
<tbody className="divide-y divide-border">{shown.map(({employee:e,byDay})=>{
const d=byDay.get(anchor);const st=cellState(d,anchor,today);
return <tr key={e.id}>
<td className={`${stickyBody} py-2 px-4`}><Link href={`/employees/${employeeSlug(e.id)}`} className="flex flex-col justify-center min-h-11 hover:text-[#30638E]"><span className="font-medium break-words">{e.name}</span><span className="text-xs text-text-muted">{e.employeeNo}</span></Link></td>
<td className="py-2 px-3 text-text-muted">{e.machine?.code??'—'}</td>
<td className="py-2 px-3 tabular-nums">{d?clockTime(d.firstIn):'—'}</td>
<td className="py-2 px-3 tabular-nums">{d&&!d.open&&d.lastOut?clockTime(d.lastOut):st==='open'?'still in':st==='missed'?'none':'—'}</td>
<td className="py-2 px-3 tabular-nums text-right font-medium">{hm(d?.minutes??0)}</td>
<td className="py-2 px-4"><DayStatus state={st}/></td></tr>;})}</tbody>
<tfoot><tr className="border-t-2 border-border font-semibold"><td className={`${stickyBody} py-3 px-4`}>Total · {shown.filter(r=>r.byDay.has(anchor)).length} present</td><td colSpan={3}/><td className="py-3 px-3 text-right tabular-nums">{hm(colTotal(anchor))}</td><td/></tr></tfoot>
</table>
: <table className="text-sm border-separate border-spacing-0">
<thead><tr className="text-xs text-text-muted">
<th className={`${stickyHead} py-2 px-4 font-medium text-left min-w-[180px] border-b border-border`}>Employee</th>
{days.map(day=><th key={day} className={`bg-bg p-0 font-medium border-b border-border ${day===today?'text-[#30638E]':''}`}>
<button className={`w-full ${period==='month'?'min-w-11':'min-w-16'} min-h-11 px-1 hover:bg-surface`} title="Open this day" onClick={()=>{setAnchor(day);setPeriod('day');}}>
<span className="block">{fmt(day,{weekday:period==='month'?'narrow':'short'})}</span><span className="block text-text">{fmt(day,{day:'numeric'})}</span></button></th>)}
<th className="bg-bg py-2 px-3 font-medium text-right border-b border-border">Total</th><th className="bg-bg py-2 px-3 font-medium text-right border-b border-border">Days</th></tr></thead>
<tbody>{shown.map(r=>{const ds=inPeriod(r);return <tr key={r.employee.id}>
<td className={`${stickyBody} py-1 px-4 border-b border-border`}><Link href={`/employees/${employeeSlug(r.employee.id)}`} className="flex flex-col justify-center min-h-11 hover:text-[#30638E]"><span className="font-medium break-words">{r.employee.name}</span><span className="text-xs text-text-muted">{r.employee.employeeNo}{r.employee.machine?` · ${r.employee.machine.code}`:''}</span></Link></td>
{days.map(day=>{const d=r.byDay.get(day);const st=cellState(d,day,today);
return <td key={day} title={d?`${clockTime(d.firstIn)} – ${d.lastOut&&!d.open?clockTime(d.lastOut):st==='open'?'now':'no clock-out'}`:undefined}
className={`px-1 py-1 text-center tabular-nums border-b border-border text-xs sm:text-sm ${st==='missed'?'bg-[#EDAE49]/30 font-semibold':st==='open'?'bg-[#00798C]/15':''} ${st==='future'?'text-text-muted/40':''} ${st==='none'?'text-text-muted':''}`}>
{st==='missed'?'!':st==='future'?'':hm(d?.minutes??0)}</td>;})}
<td className="px-3 py-1 text-right tabular-nums font-semibold border-b border-border">{hm(ds.reduce((m,d)=>m+d.minutes,0))}</td>
<td className="px-3 py-1 text-right tabular-nums border-b border-border">{ds.length||'—'}</td></tr>;})}</tbody>
<tfoot><tr className="font-semibold"><td className={`${stickyBody} py-3 px-4`}>Total</td>
{days.map(day=><td key={day} className="px-1 py-3 text-center tabular-nums text-xs sm:text-sm">{day>today?'':hm(colTotal(day))}</td>)}
<td className="px-3 py-3 text-right tabular-nums">{hm(grand)}</td><td/></tr></tfoot>
</table>}
</div>
{overflows && <ScrollFade/>}
{!shown.length && <p className="p-8 text-center text-sm text-text-muted">No employees match.</p>}
</div>
<div className="plant-clock-panel mt-6 p-4 sm:p-6"><div className="flex flex-wrap justify-between items-center gap-2 mb-2"><h2 className="text-lg font-semibold">Recent scans</h2><span className="text-xs text-text-muted">Latest 50 · South African time</span></div>
{!data.events.length && <p className="text-sm text-text-muted">The first scan will appear here.</p>}
<ul className="divide-y divide-border">{data.events.slice(0,50).map(event=><li key={event.id} className="py-3 flex gap-3 items-center">
<span className={`plant-clock-status shrink-0 ${event.direction==='in'?'plant-clock-status-in':''}`}>{event.direction.toUpperCase()}</span>
<span className="min-w-0 flex-1"><span className="block font-medium text-sm break-words">{nameOf.get(event.employee)?.name??event.employee_name}</span><span className="block text-xs text-text-muted">{event.employee_no}</span></span>
<span className="text-sm tabular-nums text-right shrink-0">{clockTime(event.scanned_at)}<span className="block text-xs text-text-muted">{clockDate(event.scanned_at)}</span></span></li>)}</ul>
</div>
</section>;
}
function DayStatus({state}:{state:CellState}) {
if(state==='open')return <span className="plant-clock-status plant-clock-status-in">At work</span>;
if(state==='done')return <span className="plant-clock-status">Clocked out</span>;
if(state==='missed')return <span className="plant-clock-status !bg-[#EDAE49]/30 !text-text">No clock-out</span>;
return <span className="text-xs text-text-muted">{state==='future'?'':'Not in'}</span>;
}