'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('week'); const [anchor,setAnchor]=useState(today); const [query,setQuery]=useState(''); const [filter,setFilter]=useState('all'); const scroller=useRef(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(); 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.dayinPeriod(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

MEGA PLANT / WORKFORCE

Attendance

Hours at the Plant from the Plant Clock scans. Tap a name for that person’s dashboard.

Open employee clock
r.summary.status==='in').length}/> r.summary.status==='out').length}/> n+r.summary.missed.length,0)}/>
{/* Pills from lg up; a select below lg (mobile checklist rule 5). */}
{PERIODS.map(p=>)}

{label}

As of {clockTime(data.asOf)} SAST No clock-out — 0 hours counted Still at work

{period==='day' ? {shown.map(({employee:e,byDay})=>{ const d=byDay.get(anchor);const st=cellState(d,anchor,today); return ;})}
Employee MachineFirst IN Last OUTHoursStatus
{e.name}{e.employeeNo} {e.machine?.code??'—'} {d?clockTime(d.firstIn):'—'} {d&&!d.open&&d.lastOut?clockTime(d.lastOut):st==='open'?'still in':st==='missed'?'none':'—'} {hm(d?.minutes??0)}
Total · {shown.filter(r=>r.byDay.has(anchor)).length} present{hm(colTotal(anchor))}
: {days.map(day=>)} {shown.map(r=>{const ds=inPeriod(r);return {days.map(day=>{const d=r.byDay.get(day);const st=cellState(d,day,today); return ;})} ;})} {days.map(day=>)}
Employee TotalDays
{r.employee.name}{r.employee.employeeNo}{r.employee.machine?` · ${r.employee.machine.code}`:''} {st==='missed'?'!':st==='future'?'':hm(d?.minutes??0)}{hm(ds.reduce((m,d)=>m+d.minutes,0))} {ds.length||'—'}
Total{day>today?'':hm(colTotal(day))}{hm(grand)}
}
{overflows && } {!shown.length &&

No employees match.

}

Recent scans

Latest 50 · South African time
{!data.events.length &&

The first scan will appear here.

}
    {data.events.slice(0,50).map(event=>
  • {event.direction.toUpperCase()} {nameOf.get(event.employee)?.name??event.employee_name}{event.employee_no} {clockTime(event.scanned_at)}{clockDate(event.scanned_at)}
  • )}
; } function DayStatus({state}:{state:CellState}) { if(state==='open')return At work; if(state==='done')return Clocked out; if(state==='missed')return No clock-out; return {state==='future'?'':'Not in'}; }