mega-plant/lib/clock-store.ts
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

49 lines
3 KiB
TypeScript

import 'server-only';
import type PocketBase from 'pocketbase';
import { type ClockEmployee, type ClockEvent, type ClockStore } from './clock';
import { acquireEmployeeLock } from './clock-lock';
import { serverAuth } from './pb';
async function optional<T>(read:()=>Promise<T>):Promise<T|null> {
try{return await read();}catch(e){if((e as {status?:number}).status===404)return null;throw e;}
}
export async function clockPB() {
const pb=await serverAuth();
if(!pb.authStore.isSuperuser)throw new Error('Clock database authentication unavailable.');
pb.beforeSend=(url,options)=>({url,options:{...options,signal:AbortSignal.timeout(15000)}});
return pb;
}
export async function clockEmployees(pb:PocketBase):Promise<ClockEmployee[]> {
const [operators,mechanics]=await Promise.all([
pb.collection('operators').getFullList({sort:'name',fields:'id,name,employee_code,asset,expand.asset.code',expand:'asset'}),
pb.collection('mechanics').getFullList({sort:'name',fields:'id,name,employee_code,role'}),
]);
return [...operators.map(e=>({id:`operators:${e.id}`,name:String(e.name||''),employeeNo:String(e.employee_code||''),role:'Operator',asset:String(e.expand?.asset?.code||'')})),
...mechanics.map(e=>({id:`mechanics:${e.id}`,name:String(e.name||''),employeeNo:String(e.employee_code||''),role:String(e.role||'Mechanic'),asset:''}))]
.sort((a,b)=>a.name.localeCompare(b.name));
}
export function clockStore(pb:PocketBase):ClockStore {
return {
employees:()=>clockEmployees(pb),
latest:employee=>optional(()=>pb.collection('clock_events').getFirstListItem<ClockEvent>(pb.filter('employee = {:employee}',{employee}),{sort:'-scanned_at,-id',requestKey:null})),
event:id=>optional(()=>pb.collection('clock_events').getOne<ClockEvent>(id,{requestKey:null})),
lock:employee=>acquireEmployeeLock({
create:employee=>pb.collection('clock_scan_locks').create({employee}),
find:employee=>optional(()=>pb.collection('clock_scan_locks').getFirstListItem<{id:string;created:string}>(pb.filter('employee = {:employee}',{employee}),{requestKey:null})),
remove:id=>pb.collection('clock_scan_locks').delete(id),
},employee),
unlock:async id=>{
try{await pb.collection('clock_scan_locks').delete(id);}
catch(e){if((e as {status:number}).status!==404)console.error('Clock lock release failed; lease will expire.',id);}
},
create:event=>pb.collection('clock_events').create<ClockEvent>(event),
};
}
export async function clockRoster() {
const pb=await clockPB();
const [employees,events]=await Promise.all([clockEmployees(pb),pb.collection('clock_events').getFullList<ClockEvent>({sort:'-scanned_at,-id'})]);
// Actor IDs, GPS and request signatures are server audit details, not roster data.
return {employees,events:events.map(({id,employee,employee_name,employee_no,site,direction,scanned_at,session_minutes,has_location})=>
({id,employee,employee_name,employee_no,site,direction,scanned_at,session_minutes,has_location})),asOf:new Date().toISOString()};
}
export type ClockRoster = Awaited<ReturnType<typeof clockRoster>>;