A scan in up to 07:10 pays from 07:00; a scan out between 16:20 and 16:40 pays to 16:30; a session over five hours loses a 30-minute lunch. A normal day is therefore exactly 9 hours (green on the calendar and matrix); late arrivals, early exits and overtime use the real times. Applied on clock-out, in missed-scan corrections and their preview. The calendar keeps the raw scan times and labels hours "Paid hours". Includes a dry-run-first script to recalculate existing QR entries; it only rewrites rows still equal to their raw scan total. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
119 lines
3.8 KiB
TypeScript
119 lines
3.8 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { pbAdmin } from "@/lib/pb";
|
|
import { dateLogged, workDate } from "@/lib/clock-day";
|
|
import { paidSessionHours } from "@/lib/clock-paid-hours";
|
|
import type { ClockEventRecord, EmployeeRecord, TimesheetEntryRecord } from "@/lib/pb-types";
|
|
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
|
|
import { isWorkerActive } from "@/lib/worker-status";
|
|
|
|
function escapePb(s: string): string {
|
|
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
}
|
|
|
|
function round2(n: number): number {
|
|
return Math.round(n * 100) / 100;
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const user = await requireApiUser("clock");
|
|
if (user instanceof Response) return user;
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
|
|
}
|
|
|
|
const employeeNo = typeof body.employee_no === "string" ? body.employee_no.trim() : "";
|
|
if (!employeeNo) {
|
|
return NextResponse.json({ error: "employee_no is required" }, { status: 400 });
|
|
}
|
|
|
|
const pb = await pbAdmin();
|
|
|
|
let employee: EmployeeRecord;
|
|
try {
|
|
employee = await pb
|
|
.collection("employees")
|
|
.getFirstListItem<EmployeeRecord>(`employee_no="${escapePb(employeeNo)}"`);
|
|
} catch {
|
|
return NextResponse.json({ error: "Unknown badge" }, { status: 404 });
|
|
}
|
|
const denied = requireSiteAccess(user, employee.site);
|
|
if (denied) return denied;
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
let lastEvent: ClockEventRecord | null;
|
|
try {
|
|
lastEvent = await pb
|
|
.collection("clock_events")
|
|
.getFirstListItem<ClockEventRecord>(`employee="${employee.id}"`, { sort: "-scanned_at" });
|
|
} catch {
|
|
lastEvent = null;
|
|
}
|
|
|
|
// The QR request contract stays unchanged for already-installed phones.
|
|
// Only a same-local-day IN can be closed by today's scan.
|
|
if (!lastEvent || lastEvent.direction === "out" ||
|
|
workDate(lastEvent.scanned_at) !== workDate(now)) {
|
|
if (!isWorkerActive(employee)) return NextResponse.json({ error: "Worker is inactive. Reactivate them before clocking in." }, { status: 409 });
|
|
await pb.collection("clock_events").create({
|
|
employee: employee.id,
|
|
site: employee.site,
|
|
direction: "in",
|
|
scanned_at: now,
|
|
});
|
|
return NextResponse.json({ direction: "in", employeeName: employee.name, at: now });
|
|
}
|
|
|
|
// The company bills a standard day, so a scan stores PAID hours, not the raw span.
|
|
const sessionHours = paidSessionHours(lastEvent.scanned_at, now);
|
|
const entryDate = dateLogged(workDate(lastEvent.scanned_at));
|
|
const pbEntryDate = entryDate.replace("T", " ");
|
|
|
|
let entry: TimesheetEntryRecord | null;
|
|
try {
|
|
entry = await pb
|
|
.collection("timesheet_entries")
|
|
.getFirstListItem<TimesheetEntryRecord>(
|
|
`employee="${employee.id}" && date_logged="${pbEntryDate}" && timesheet=""`,
|
|
);
|
|
} catch {
|
|
entry = null;
|
|
}
|
|
|
|
let dailyTotalHours: number;
|
|
let entryId: string;
|
|
if (entry) {
|
|
dailyTotalHours = round2(entry.hours_worked + sessionHours);
|
|
const updated = await pb
|
|
.collection("timesheet_entries")
|
|
.update<TimesheetEntryRecord>(entry.id, { hours_worked: dailyTotalHours });
|
|
entryId = updated.id;
|
|
} else {
|
|
const created = await pb.collection("timesheet_entries").create<TimesheetEntryRecord>({
|
|
employee: employee.id,
|
|
site: employee.site,
|
|
date_logged: entryDate,
|
|
hours_worked: sessionHours,
|
|
});
|
|
entryId = created.id;
|
|
dailyTotalHours = sessionHours;
|
|
}
|
|
|
|
await pb.collection("clock_events").create({
|
|
employee: employee.id,
|
|
site: employee.site,
|
|
direction: "out",
|
|
scanned_at: now,
|
|
timesheet_entry: entryId,
|
|
});
|
|
|
|
return NextResponse.json({
|
|
direction: "out",
|
|
employeeName: employee.name,
|
|
sessionHours,
|
|
dailyTotalHours,
|
|
});
|
|
}
|