- 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>
182 lines
14 KiB
TypeScript
182 lines
14 KiB
TypeScript
'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>;
|
||
}
|