98 lines
4.6 KiB
TypeScript
98 lines
4.6 KiB
TypeScript
|
|
import type { ClockEvent } from './clock';
|
||
|
|
import { missedClockIns, workDate } from './clock-rules';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Workforce = everyone on the Plant roster (operators + mechanics), with the time
|
||
|
|
* the Plant Clock recorded for them. Pure functions only, so the Employees page,
|
||
|
|
* the Attendance page and each employee's dashboard read time the same way.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/** "operators:abc…" ↔ "operators-abc…" — a URL segment cannot carry the colon cleanly. */
|
||
|
|
export const employeeSlug = (id:string) => id.replace(':','-');
|
||
|
|
export function employeeIdFromSlug(slug:string):string|null {
|
||
|
|
const m=/^(operators|mechanics)-([a-z0-9]{15})$/.exec(slug);
|
||
|
|
return m?`${m[1]}:${m[2]}`:null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type Session = { in:ClockEvent; out:ClockEvent|null; minutes:number; day:string };
|
||
|
|
export type DayTotal = { day:string; minutes:number; firstIn:string; lastOut:string|null; open:boolean };
|
||
|
|
export type AttendanceSummary = {
|
||
|
|
status:'in'|'out'|'away'; last:ClockEvent|null;
|
||
|
|
todayMinutes:number; weekMinutes:number; monthMinutes:number; daysThisMonth:number;
|
||
|
|
missed:ClockEvent[]; days:DayTotal[]; sessions:Session[];
|
||
|
|
};
|
||
|
|
|
||
|
|
const byTime=(a:ClockEvent,b:ClockEvent)=>Date.parse(a.scanned_at)-Date.parse(b.scanned_at)||a.id.localeCompare(b.id);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Pair each IN with the same-day OUT that closed it. An IN still open TODAY counts
|
||
|
|
* up to `now` (they are at work); an IN left open on an earlier day counts as 0 —
|
||
|
|
* it is a missing clock-out, and inventing hours for it would overstate the time.
|
||
|
|
*/
|
||
|
|
export function sessionsOf(events:ClockEvent[], now:string):Session[] {
|
||
|
|
const out:Session[]=[];let open:ClockEvent|null=null;
|
||
|
|
const close=(s:ClockEvent,e:ClockEvent|null)=>{
|
||
|
|
const day=workDate(s.scanned_at);
|
||
|
|
const minutes=e?Math.max(0,Math.floor((Date.parse(e.scanned_at)-Date.parse(s.scanned_at))/60_000))
|
||
|
|
:day===workDate(now)?Math.max(0,Math.floor((Date.parse(now)-Date.parse(s.scanned_at))/60_000)):0;
|
||
|
|
out.push({in:s,out:e,minutes,day});
|
||
|
|
};
|
||
|
|
for(const e of [...events].sort(byTime)) {
|
||
|
|
if(e.direction==='in'){if(open)close(open,null);open=e;}
|
||
|
|
else if(open && workDate(open.scanned_at)===workDate(e.scanned_at)){close(open,e);open=null;}
|
||
|
|
}
|
||
|
|
if(open)close(open,null);
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Monday of the Africa/Johannesburg week containing `day` (YYYY-MM-DD). */
|
||
|
|
export function weekStart(day:string):string {
|
||
|
|
const d=new Date(`${day}T12:00:00Z`);const dow=(d.getUTCDay()+6)%7;
|
||
|
|
d.setUTCDate(d.getUTCDate()-dow);return d.toISOString().slice(0,10);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function attendanceSummary(events:ClockEvent[], now:string):AttendanceSummary {
|
||
|
|
const sorted=[...events].sort(byTime);
|
||
|
|
const last=sorted.at(-1)??null;
|
||
|
|
const today=workDate(now);const month=today.slice(0,7);const week=weekStart(today);
|
||
|
|
const sessions=sessionsOf(sorted,now);
|
||
|
|
const dayMap=new Map<string,DayTotal>();
|
||
|
|
for(const s of sessions) {
|
||
|
|
const d=dayMap.get(s.day)??{day:s.day,minutes:0,firstIn:s.in.scanned_at,lastOut:null,open:false};
|
||
|
|
d.minutes+=s.minutes;
|
||
|
|
if(s.out)d.lastOut=s.out.scanned_at;
|
||
|
|
d.open=!s.out;
|
||
|
|
dayMap.set(s.day,d);
|
||
|
|
}
|
||
|
|
const days=[...dayMap.values()].sort((a,b)=>b.day.localeCompare(a.day));
|
||
|
|
const sum=(f:(d:DayTotal)=>boolean)=>days.filter(f).reduce((n,d)=>n+d.minutes,0);
|
||
|
|
return {
|
||
|
|
status:last && workDate(last.scanned_at)===today ? last.direction : 'away',
|
||
|
|
last,
|
||
|
|
todayMinutes:sum(d=>d.day===today),
|
||
|
|
weekMinutes:sum(d=>d.day>=week && d.day<=today),
|
||
|
|
monthMinutes:sum(d=>d.day.startsWith(month)),
|
||
|
|
daysThisMonth:days.filter(d=>d.day.startsWith(month)).length,
|
||
|
|
missed:missedClockIns(sorted,now),
|
||
|
|
days,sessions,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export type Period = 'day'|'week'|'month';
|
||
|
|
const addDays=(day:string,n:number)=>{const d=new Date(`${day}T12:00:00Z`);d.setUTCDate(d.getUTCDate()+n);return d.toISOString().slice(0,10);};
|
||
|
|
/** Every calendar day (YYYY-MM-DD) in the period containing `anchor`, oldest first. */
|
||
|
|
export function periodDays(period:Period, anchor:string):string[] {
|
||
|
|
if(period==='day')return [anchor];
|
||
|
|
if(period==='week'){const start=weekStart(anchor);return Array.from({length:7},(_,i)=>addDays(start,i));}
|
||
|
|
const [y,m]=anchor.split('-').map(Number);
|
||
|
|
const count=new Date(Date.UTC(y,m,0)).getUTCDate();
|
||
|
|
return Array.from({length:count},(_,i)=>`${anchor.slice(0,7)}-${String(i+1).padStart(2,'0')}`);
|
||
|
|
}
|
||
|
|
/** The same position one period earlier (-1) or later (+1). A month step lands on the 1st. */
|
||
|
|
export function shiftPeriod(period:Period, anchor:string, step:number):string {
|
||
|
|
if(period==='day')return addDays(anchor,step);
|
||
|
|
if(period==='week')return addDays(weekStart(anchor),7*step);
|
||
|
|
const [y,m]=anchor.split('-').map(Number);
|
||
|
|
const d=new Date(Date.UTC(y,m-1+step,1));return d.toISOString().slice(0,10);
|
||
|
|
}
|