From 2bf749e4e5dd4ec6d55842610127c550a660bd87 Mon Sep 17 00:00:00 2001 From: Kristhian Cadiz Date: Mon, 6 Jul 2026 17:07:32 +0800 Subject: [PATCH 1/5] Extend clock-in window to cover approved overtime Floor-employee clock-in was gated strictly to the assigned shift, so an employee granted overtime could not clock in past their scheduled end and was auto-clocked-out at that end. Widen the allowed window to the envelope of shift + granted OT, and drive the shift auto-expiry off that widened end. A rest day with granted OT now opens the OT window instead of blocking. (Payroll already pays OT only inside the authorized window; unchanged here.) Also fix OT storage for overnight shifts: OT entered in next-day wall-clock (e.g. 06:00-08:00 after a 22:00-06:00 shift) was anchored to the shift's calendar date and stored a day early. Roll it forward when the shift is overnight and the OT parses before the shift start; pure day shifts keep any pre-shift OT intact. Add an integration test covering the schedule/OT clock-in gate: too-early, post-shift-no-OT, post-shift-with-OT (allowed, expiry follows OT end), and rest-day with/without OT. Co-Authored-By: Claude Opus 4.8 --- src/scheduling/schedules.controller.ts | 12 ++- src/time-tracking/time-tracking.service.ts | 54 +++++++--- test/clockin-schedule-ot.spec.ts | 117 +++++++++++++++++++++ 3 files changed, 166 insertions(+), 17 deletions(-) create mode 100644 test/clockin-schedule-ot.spec.ts diff --git a/src/scheduling/schedules.controller.ts b/src/scheduling/schedules.controller.ts index a4f1dbd..83cc61c 100644 --- a/src/scheduling/schedules.controller.ts +++ b/src/scheduling/schedules.controller.ts @@ -392,7 +392,8 @@ export class SchedulesController { let end = manilaDateTime(ds, endTime!); if (isNaN(start.getTime()) || isNaN(end.getTime())) throw new BadRequestException('Invalid start or end time.'); - if (end <= start) end = new Date(end.getTime() + 86_400_000); // overnight + const overnightShift = end <= start; + if (overnightShift) end = new Date(end.getTime() + 86_400_000); // crosses midnight // Optional overtime window (individual only). Null when not set, so // re-applying without OT clears any previous OT on that day. @@ -404,6 +405,15 @@ export class SchedulesController { if (isNaN(otStart.getTime()) || isNaN(otEnd.getTime())) throw new BadRequestException('Invalid overtime time.'); if (otEnd <= otStart) otEnd = new Date(otEnd.getTime() + 86_400_000); + // OT normally runs right after the shift. On an OVERNIGHT shift the + // WFM enters OT in next-day wall-clock (e.g. 06:00–08:00 after a + // 22:00–06:00 shift), which parses a day early because it's anchored + // to the shift's calendar date — roll it forward so it sits after + // the shift end. Pure day shifts keep any pre-shift OT intact. + if (overnightShift && otStart < start) { + otStart = new Date(otStart.getTime() + 86_400_000); + otEnd = new Date(otEnd.getTime() + 86_400_000); + } } newRows.push({ employeeId: eid, workDate, isRestDay: false, scheduledStart: start, scheduledEnd: end, otStart, otEnd, isNightShift: !!body.isNightShift }); diff --git a/src/time-tracking/time-tracking.service.ts b/src/time-tracking/time-tracking.service.ts index add7552..8611ab4 100644 --- a/src/time-tracking/time-tracking.service.ts +++ b/src/time-tracking/time-tracking.service.ts @@ -158,9 +158,30 @@ export class TimeTrackingService { const sched = await this.prisma.schedule.findFirst({ where: { employeeId, workDate: scheduleDate }, }); + + // The allowed clock-in window is the assigned shift, WIDENED to include any + // granted overtime window — so an OT'd employee can log in past their normal + // end (or before their normal start, if OT sits ahead of the shift). OT is a + // single contiguous window per day, so the min/max envelope is the union. + // The grant itself (otStart/otEnd being set) is the authorisation; the + // banner acknowledgement is a UX dismissal, not a precondition for working. + const hasOt = !!(sched?.otStart && sched?.otEnd); + const windowStart = !sched + ? null + : hasOt && sched.scheduledStart + ? new Date(Math.min(sched.scheduledStart.getTime(), sched.otStart!.getTime())) + : sched.scheduledStart ?? sched.otStart; + const windowEnd = !sched + ? null + : hasOt && sched.scheduledEnd + ? new Date(Math.max(sched.scheduledEnd.getTime(), sched.otEnd!.getTime())) + : sched.scheduledEnd ?? sched.otEnd; + if (sched) { - // A scheduled rest day means no shift at all today. - if (sched.isRestDay) { + // A scheduled rest day means no shift at all today — unless WFM granted + // overtime for the rest day (an explicit OT call on a day off), which + // opens the OT window enforced below. + if (sched.isRestDay && !hasOt) { await this.recordViolation( employeeId, ViolationType.OUT_OF_SCHEDULE_LOGIN, @@ -170,38 +191,39 @@ export class TimeTrackingService { 'Today is a scheduled rest day — you cannot clock in.', ); } - // Before the scheduled start time → block (the explicit requirement). - if (sched.scheduledStart && new Date() < sched.scheduledStart) { - const startLabel = manilaTimeLabel(sched.scheduledStart); + // Before the window opens → block (the explicit requirement). + if (windowStart && new Date() < windowStart) { + const startLabel = manilaTimeLabel(windowStart); await this.recordViolation( employeeId, ViolationType.OUT_OF_SCHEDULE_LOGIN, - `Attempted login before scheduled start ${sched.scheduledStart.toISOString()}`, + `Attempted login before window start ${windowStart.toISOString()}`, ); throw new ForbiddenException( - `Too early to clock in. Your shift starts at ${startLabel}.`, + `Too early to clock in. Your ${hasOt ? 'shift window (incl. overtime) opens' : 'shift starts'} at ${startLabel}.`, ); } - // After the scheduled end time → the shift is over for today. - if (sched.scheduledEnd && new Date() > sched.scheduledEnd) { - const endLabel = manilaTimeLabel(sched.scheduledEnd); + // After the window closes → the shift (plus any granted OT) is over. + if (windowEnd && new Date() > windowEnd) { + const endLabel = manilaTimeLabel(windowEnd); await this.recordViolation( employeeId, ViolationType.OUT_OF_SCHEDULE_LOGIN, - `Attempted login after scheduled end ${sched.scheduledEnd.toISOString()}`, + `Attempted login after window end ${windowEnd.toISOString()}`, ); throw new ForbiddenException( - `Your scheduled shift has already ended for today (ended at ${endLabel}).`, + `Your ${hasOt ? 'shift (including overtime)' : 'scheduled shift'} has already ended for today (ended at ${endLabel}).`, ); } } const now = new Date(); - // Follow the assigned schedule's end time when one exists; otherwise fall - // back to the org's default shift-length policy from the moment of clock-in. + // Auto-expiry follows the assigned window END — the scheduled end, widened + // by any granted OT — when a schedule exists; otherwise fall back to the + // org's default shift-length policy from the moment of clock-in. const shiftEndsAt = - sched && sched.scheduledEnd - ? sched.scheduledEnd + sched && windowEnd + ? windowEnd : new Date(now.getTime() + policy.shiftHours * 3600_000); let entry; diff --git a/test/clockin-schedule-ot.spec.ts b/test/clockin-schedule-ot.spec.ts new file mode 100644 index 0000000..f9a3c34 --- /dev/null +++ b/test/clockin-schedule-ot.spec.ts @@ -0,0 +1,117 @@ +import { PrismaClient } from '@prisma/client'; +import { TimeTrackingService } from '../src/time-tracking/time-tracking.service'; +import { manilaWorkDate } from '../src/common/timezone'; +import { cleanupOrg } from './helpers'; + +const prisma = new PrismaClient(); + +// clockIn() only calls scheduleShiftExpiry() on the enforcement service and the +// three publisher methods — stubbing both keeps this test free of Redis and the +// WebSocket gateway while exercising the real DB-backed schedule/OT gate. +const enforcement = { scheduleShiftExpiry: async () => {} } as any; +const events = { toEmployee() {}, toApprovers() {}, toActivity() {} } as any; +const svc = new TimeTrackingService(prisma as any, enforcement, events); + +const HOUR = 3600_000; +const at = (offsetMs: number) => new Date(Date.now() + offsetMs); + +// ── Integration against the live DB: schedule-gated clock-in, widened by OT ── +describe('clockIn — schedule-gated login with overtime', () => { + let orgId: string; + let empId: string; + + beforeAll(async () => { + const org = await prisma.organization.create({ + data: { name: 'TEST-OT-' + Date.now(), timezone: 'Asia/Manila' }, + }); + orgId = org.id; + // getPolicy() falls back to the org's default policy (no team set). + await prisma.shiftPolicy.create({ data: { orgId, name: 'T', shiftHours: 8 } }); + const emp = await prisma.employee.create({ + data: { orgId, employeeCode: 'OT-1', fullName: 'OT Tester', hireDate: new Date(), hourlyRate: 100 }, + }); + empId = emp.id; + }); + + // Each test starts clean: no open/closed entries, schedules, or violations. + afterEach(async () => { + const tes = await prisma.timeEntry.findMany({ where: { employeeId: empId }, select: { id: true } }); + const teIds = tes.map((t) => t.id); + await prisma.activitySession.deleteMany({ where: { timeEntryId: { in: teIds } } }); + await prisma.timeEntry.deleteMany({ where: { employeeId: empId } }); + await prisma.schedule.deleteMany({ where: { employeeId: empId } }); + await prisma.complianceViolation.deleteMany({ where: { employeeId: empId } }); + }); + + afterAll(async () => { + await cleanupOrg(prisma, orgId); + await prisma.$disconnect(); + }); + + const setSchedule = ( + data: Partial<{ + scheduledStart: Date | null; + scheduledEnd: Date | null; + otStart: Date | null; + otEnd: Date | null; + isRestDay: boolean; + }>, + ) => + prisma.schedule.create({ + data: { + employeeId: empId, + workDate: manilaWorkDate(), + scheduledStart: null, + scheduledEnd: null, + otStart: null, + otEnd: null, + isRestDay: false, + isNightShift: false, + ...data, + }, + }); + + const clockIn = () => svc.clockIn(empId, 'Productivity', { userId: 'test' }); + const violationType = async () => + (await prisma.complianceViolation.findFirst({ where: { employeeId: empId } }))?.type; + + it('blocks a clock-in before the scheduled start (and logs a violation)', async () => { + await setSchedule({ scheduledStart: at(2 * HOUR), scheduledEnd: at(10 * HOUR) }); + await expect(clockIn()).rejects.toThrow(/Too early/); + expect(await violationType()).toBe('OUT_OF_SCHEDULE_LOGIN'); + }); + + it('blocks a clock-in after the scheduled end when no OT is granted', async () => { + await setSchedule({ scheduledStart: at(-10 * HOUR), scheduledEnd: at(-2 * HOUR) }); + await expect(clockIn()).rejects.toThrow(/already ended/); + expect(await violationType()).toBe('OUT_OF_SCHEDULE_LOGIN'); + }); + + it('ALLOWS a clock-in past the scheduled end when OT covers now, and auto-expiry follows the OT end', async () => { + const otEnd = at(2 * HOUR); + await setSchedule({ + scheduledStart: at(-10 * HOUR), + scheduledEnd: at(-2 * HOUR), + otStart: at(-2 * HOUR), + otEnd, + }); + const state = await clockIn(); + expect(state.onShift).toBe(true); + const te = await prisma.timeEntry.findFirst({ where: { employeeId: empId, status: 'OPEN' } }); + expect(te).toBeTruthy(); + // shiftEndsAt is the WIDENED window end (OT end), not the scheduled end. + expect(Math.abs(te!.shiftEndsAt.getTime() - otEnd.getTime())).toBeLessThan(1000); + // A legitimate OT login records no violation. + expect(await violationType()).toBeUndefined(); + }); + + it('blocks a rest-day clock-in, but ALLOWS it when OT is granted for the rest day', async () => { + await setSchedule({ isRestDay: true }); + await expect(clockIn()).rejects.toThrow(/rest day/); + + await prisma.schedule.deleteMany({ where: { employeeId: empId } }); + await setSchedule({ isRestDay: true, otStart: at(-1 * HOUR), otEnd: at(1 * HOUR) }); + const state = await clockIn(); + expect(state.onShift).toBe(true); + }); +}); From 14956448902999d395bec40280c4dc50afaacf1c Mon Sep 17 00:00:00 2001 From: Kristhian Cadiz Date: Mon, 6 Jul 2026 17:08:04 +0800 Subject: [PATCH 2/5] Add employee overtime visibility: confirm-to-dismiss + My schedule view Dismissing the "you've been given overtime" banner was a single tap with no undo, and there was no employee-facing way to look the OT up again (the schedule view is WFM/Admin-only). An accidental tap hid the reminder for good. - Guard the banner's "Got it" with a confirmation dialog that reassures the OT is still scheduled and payable, and points to My schedule. - Add a read-only GET /me/schedule endpoint (self-scoped) returning the employee's upcoming shifts, rest days, and granted OT windows. - Add a "My schedule" tab that renders those rows and highlights any granted overtime, so it stays visible even after the banner is dismissed. Co-Authored-By: Claude Opus 4.8 --- public/app.js | 42 ++++++++++++++++++++++++++++- public/index.html | 9 +++++++ src/profile/profile.controller.ts | 44 +++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/public/app.js b/public/app.js index abf034c..76115e7 100644 --- a/public/app.js +++ b/public/app.js @@ -176,9 +176,10 @@ function canLeaveReview(roles){return roles.includes("TEAM_LEAD")||roles.include function buildTabs(roles){ const tabs=document.getElementById("tabs"); tabs.innerHTML=""; const lead=isLead(roles); - const allTabs=["clock","leave","schedule","console","oversight","people","teams","payroll","payslips"]; + const allTabs=["clock","myschedule","leave","schedule","console","oversight","people","teams","payroll","payslips"]; const defs=[]; if(user.employeeId) defs.push(["clock","My clock"]); + if(user.employeeId) defs.push(["myschedule","My schedule"]); if(user.employeeId && !lead && !isScheduler(roles) && !isManager(roles) && !isPayroll(roles)) defs.push(["leave","My leave"]); if(user.employeeId) defs.push(["payslips","My payslips"]); if(isScheduler(roles)) defs.push(["schedule","Scheduling"]); @@ -195,6 +196,7 @@ function buildTabs(roles){ stopOversightPoll(); document.querySelectorAll(".tab").forEach(t=>t.classList.remove("on")); b.classList.add("on"); allTabs.forEach(id=>document.getElementById("tab-"+id).classList.toggle("hide",k!==id)); + if(k==="myschedule") loadMySchedule(); if(k==="leave") loadMyLeave(); if(k==="schedule") loadSchedulePanel(); if(k==="oversight") startOversightPoll(); @@ -1232,10 +1234,48 @@ async function loadOvertime(){ }); } async function ackOvertime(id){ + // Guard against an accidental tap: dismissing only hides the reminder, but + // make the user confirm and tell them where to find the OT afterwards. + const ok=await confirmDialog({title:"Dismiss overtime reminder?", + message:"This only hides the reminder. Your overtime is still scheduled and you'll still be paid for it — you can always see it under “My schedule”.", + confirmText:"Dismiss",cancelText:"Keep it",kind:"warn"}); + if(!ok) return; try{ await api(`/me/overtime/${id}/ack`,{method:"POST"}); }catch(e){ toast(e.message,"err"); return; } loadOvertime(); } +// ── My schedule (read-only): upcoming shifts + granted overtime ── +async function loadMySchedule(){ + const box=document.getElementById("mysched-list"); + if(!box) return; + box.innerHTML='
Loading…
'; + let rows; + try{ rows=await api("/me/schedule"); } + catch(e){ box.innerHTML='
Could not load your schedule.
'; return; } + if(!rows.length){ box.innerHTML='
No upcoming shifts scheduled.
'; return; } + box.innerHTML=""; + rows.forEach(r=>{ + const card=document.createElement("div"); + card.style.cssText="border:1px solid var(--line);border-radius:12px;padding:12px 14px;margin-bottom:10px;background:var(--panel)"; + const shift=r.isRestDay + ? 'Rest day — no shift' + : `${fmtTime(r.scheduledStart)} – ${fmtTime(r.scheduledEnd)}`+(r.isNightShift?' · night shift':''); + let html=`
`+ + `
${fmtDay(r.workDate)}
`+ + `
${shift}
`; + if(r.hasOvertime){ + html+=`
`+ + `
⏱ Overtime
`+ + `
${fmtTime(r.otStart)} – ${fmtTime(r.otEnd)}
`+ + `
${r.otAcknowledged?"Acknowledged":"New — not yet acknowledged"}
`+ + `
`; + } + html+=`
`; + card.innerHTML=html; + box.appendChild(card); + }); +} + // ── My payslips (every member) ── async function loadMyPayslips(){ const box=document.getElementById("my-payslip-list"); diff --git a/public/index.html b/public/index.html index 8798fa7..58f31ad 100644 --- a/public/index.html +++ b/public/index.html @@ -93,6 +93,15 @@

Verify it's you

+ +
+
+

My schedule

+
Your upcoming shifts. Any overtime you've been granted is highlighted here — and it stays here even after you dismiss the reminder.
+
+
+
+
diff --git a/src/profile/profile.controller.ts b/src/profile/profile.controller.ts index d8b11d7..ebaef2d 100644 --- a/src/profile/profile.controller.ts +++ b/src/profile/profile.controller.ts @@ -122,6 +122,50 @@ export class ProfileController { return { ok: true }; } + // ───────────────────────── MY SCHEDULE ─────────────────────────────── + // Read-only view of the signed-in employee's own upcoming shifts, so they + // can always look up their hours and overtime window — even after they've + // dismissed the overtime banner. Self-scoped; no role check. + + @Get('schedule') + async mySchedule(@Req() req: AuthedReq) { + if (!req.user.employeeId) return []; + const since = new Date(); + since.setDate(since.getDate() - 1); // include yesterday so today's shift still shows + const rows = await this.prisma.schedule.findMany({ + where: { + employeeId: req.user.employeeId, + workDate: { gte: this.utcMidnight(since) }, + }, + orderBy: { workDate: 'asc' }, + take: 60, // a reasonable look-ahead horizon + select: { + id: true, + workDate: true, + isRestDay: true, + scheduledStart: true, + scheduledEnd: true, + otStart: true, + otEnd: true, + isNightShift: true, + otAcknowledgedAt: true, + }, + }); + // Surface acknowledgement as a boolean; keep the raw timestamp internal. + return rows.map((r) => ({ + id: r.id, + workDate: r.workDate, + isRestDay: r.isRestDay, + scheduledStart: r.scheduledStart, + scheduledEnd: r.scheduledEnd, + otStart: r.otStart, + otEnd: r.otEnd, + isNightShift: r.isNightShift, + hasOvertime: !!(r.otStart && r.otEnd), + otAcknowledged: !!r.otAcknowledgedAt, + })); + } + private utcMidnight(d: Date) { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); } From 52ddac1b40f4d410549648ca12465e7b98cb5518 Mon Sep 17 00:00:00 2001 From: Kristhian Cadiz Date: Mon, 6 Jul 2026 22:50:39 +0800 Subject: [PATCH 3/5] Overtime: separate per-date grants + rest-day/night classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework overtime into a first-class, separately-managed feature: - Grant OT per employee for a specific date as a start time + N hours, from a dedicated Overtime panel. Removed the OT fields from the shift builder, and shift edits no longer touch a day's OT (upsert leaves it alone). New endpoints POST/GET/DELETE /schedules/overtime. - Classify each grant from two dimensions of the scheduled date/time — rest day (taken from the schedule, not "no shift") and night hours (22:00-06:00) — into OT, NDOT, RDOT, or RDNDOT, splitting windows that straddle the boundary. Fixes prior behavior that labeled any no-shift OT as rest-day OT. - Pay: rest-day OT multiplier (rdOtMultiplier, migration) and the night differential stack additively; payroll itemizes the four categories as distinct payslip lines. Totals are unchanged. - Surface the classification in the WFM overtime panel, My Schedule, and the employee overtime banner. Shared classifier in src/common/overtime.ts (night logic shared with payroll so labels and pay always agree). Tests: classifier units, an RDNDOT payroll case, and OT-grant/rest-day cases (30 total, all green). Co-Authored-By: Claude Opus 4.8 --- .../migration.sql | 5 + prisma/schema.prisma | 1 + public/app.js | 67 +++++-- public/index.html | 26 +-- src/common/overtime.ts | 66 +++++++ src/payroll/payroll.controller.ts | 116 +++++++----- src/profile/profile.controller.ts | 9 +- src/scheduling/schedules.controller.ts | 168 +++++++++++++----- test/overtime-classify.spec.ts | 44 +++++ test/overtime-grant.spec.ts | 88 +++++++++ test/payroll-logic.spec.ts | 45 +++++ 11 files changed, 528 insertions(+), 107 deletions(-) create mode 100644 prisma/migrations/20260706120000_add_rd_ot_multiplier/migration.sql create mode 100644 src/common/overtime.ts create mode 100644 test/overtime-classify.spec.ts create mode 100644 test/overtime-grant.spec.ts diff --git a/prisma/migrations/20260706120000_add_rd_ot_multiplier/migration.sql b/prisma/migrations/20260706120000_add_rd_ot_multiplier/migration.sql new file mode 100644 index 0000000..2b50568 --- /dev/null +++ b/prisma/migrations/20260706120000_add_rd_ot_multiplier/migration.sql @@ -0,0 +1,5 @@ +-- Rest-day overtime pay multiplier. +-- Additive column, NOT NULL with a default (PH rest-day OT factor 130% × 130%), +-- so every existing shift policy keeps a sensible rate and payroll for +-- rest-day overtime is paid distinctly from ordinary overtime. +ALTER TABLE "shift_policies" ADD COLUMN "rdOtMultiplier" DOUBLE PRECISION NOT NULL DEFAULT 1.69; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 81ac412..4fcce5d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -270,6 +270,7 @@ model ShiftPolicy { // Payroll rules otMultiplier Float @default(1.5) // overtime pay multiplier (hours/day over shiftHours) + rdOtMultiplier Float @default(1.69) // rest-day overtime multiplier (PH: 130% × 130%); configurable per org nightDiffPercent Int @default(10) // % premium on hours worked at night (22:00-06:00) createdAt DateTime @default(now()) diff --git a/public/app.js b/public/app.js index 76115e7..4e3754a 100644 --- a/public/app.js +++ b/public/app.js @@ -599,19 +599,70 @@ async function loadSchedulePanel(){ scTargets=await api("/schedules/targets").catch(()=>({employees:[],teams:[]})); populateScTargets(); populateCopyTargets(); + populateOtEmp(); const fd=document.getElementById("sc-filter-date"); if(!fd.value) fd.value=dateStr(new Date()); + const od=document.getElementById("ot-date"); + if(!od.value) od.value=dateStr(new Date()); renderWeekdayPattern(); renderRestDays(); loadScheduleList(); } + +// ── Overtime management (WFM): grant per date + hours, separate from shifts ── +function populateOtEmp(){ + const sel=document.getElementById("ot-emp"); sel.innerHTML=""; + const items=scTargets.employees||[]; + if(!items.length){ const o=document.createElement("option"); o.value=""; o.textContent="(none available)"; sel.appendChild(o); document.getElementById("ot-list").innerHTML=""; return; } + items.forEach(e=>{ const o=document.createElement("option"); o.value=e.id; o.textContent=`${e.employeeCode} — ${e.fullName}`; sel.appendChild(o); }); + loadOtGrants(); +} +async function loadOtGrants(){ + const box=document.getElementById("ot-list"); + const eid=document.getElementById("ot-emp").value; + if(!eid){ box.innerHTML=""; return; } + box.innerHTML='
Loading…
'; + let rows; + try{ rows=await api("/schedules/overtime?employeeId="+encodeURIComponent(eid)); } + catch(e){ box.innerHTML='
Could not load overtime.
'; return; } + if(!rows.length){ box.innerHTML='
No upcoming overtime.
'; return; } + box.innerHTML=""; + rows.forEach(r=>{ + const row=document.createElement("div"); + row.style.cssText="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-bottom:1px solid var(--line)"; + row.innerHTML=`
${fmtDay(r.workDate)} · ${r.hours}h${r.classification?` · ${r.classification}`:''}
`+ + `
${fmtTime(r.otStart)} – ${fmtTime(r.otEnd)}${r.acknowledged?' · acknowledged':''}
`; + const del=document.createElement("button"); del.className="btn"; del.textContent="Remove"; + del.style.cssText="padding:5px 12px;font-size:12px;white-space:nowrap"; + del.onclick=async()=>{ del.disabled=true; try{ await api("/schedules/overtime/"+r.id,{method:"DELETE"}); toast("Overtime removed","ok"); loadOtGrants(); }catch(e){ toast(e.message,"err"); del.disabled=false; } }; + row.appendChild(del); box.appendChild(row); + }); +} +document.getElementById("ot-emp").onchange=loadOtGrants; +document.getElementById("ot-grant").onclick=async()=>{ + const btn=document.getElementById("ot-grant"); + const employeeId=document.getElementById("ot-emp").value; + const date=document.getElementById("ot-date").value; + const startTime=document.getElementById("ot-start").value; + const hours=parseFloat(document.getElementById("ot-hours").value); + if(!employeeId){ toast("Pick an employee.","err"); return; } + if(!date){ toast("Pick a date.","err"); return; } + if(!startTime){ toast("Set a start time.","err"); return; } + if(!(hours>0)){ toast("Enter the number of hours.","err"); return; } + btn.disabled=true; btn.textContent="Granting…"; + try{ + const r=await api("/schedules/overtime",{method:"POST",body:{employeeId,date,startTime,hours}}); + toast(`Overtime granted · ${hours}h${r.classification?` · ${r.classification}`:""}`,"ok"); + loadOtGrants(); + }catch(e){ toast(e.message,"err"); } + finally{ btn.disabled=false; btn.textContent="Grant overtime"; } +}; let scScope="employee", scMultiSel=new Set(); function setScScope(s){ scScope=s; document.getElementById("sc-scope-emp").classList.toggle("on",s==="employee"); document.getElementById("sc-scope-multi").classList.toggle("on",s==="multi"); document.getElementById("sc-scope-team").classList.toggle("on",s==="team"); - document.getElementById("sc-ot-section").classList.toggle("hide",s!=="employee"); // overtime is individual-only document.getElementById("sc-target").classList.toggle("hide",s==="multi"); document.getElementById("sc-multi-wrap").classList.toggle("hide",s!=="multi"); if(s==="multi") populateScMulti(); else populateScTargets(); @@ -639,7 +690,6 @@ function populateScMulti(){ document.getElementById("sc-scope-emp").onclick=()=>setScScope("employee"); document.getElementById("sc-scope-multi").onclick=()=>setScScope("multi"); document.getElementById("sc-scope-team").onclick=()=>setScScope("team"); -document.getElementById("sc-ot-on").onchange=e=>document.getElementById("sc-ot-fields").classList.toggle("hide",!e.target.checked); function rangeDates(){ const s=document.getElementById("sc-start-date").value, e=document.getElementById("sc-end-date").value; @@ -732,11 +782,6 @@ document.getElementById("sc-apply").onclick=async()=>{ if(scScope==="team") body.teamId=targetId; else if(scScope==="multi") body.employeeIds=[...scMultiSel]; else body.employeeId=targetId; - if(scScope==="employee" && document.getElementById("sc-ot-on").checked){ - const otS=document.getElementById("sc-ot-start").value, otE=document.getElementById("sc-ot-end").value; - if(!otS||!otE){toast("Set both overtime times, or switch overtime off.","err");return;} - body.otStartTime=otS; body.otEndTime=otE; - } btn.disabled=true; btn.textContent="Applying…"; try{ const r=await postWithCompliance("/schedules/apply",body); @@ -1226,7 +1271,7 @@ async function loadOvertime(){ const card=document.createElement("div"); card.style.cssText="border:1px solid var(--amber);background:#16130a;border-radius:12px;padding:14px 16px;margin-bottom:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap"; card.innerHTML=`
⏱ WFM has provided you with overtime`+ - `
${fmtDay(g.workDate)} · ${fmtTime(g.otStart)}–${fmtTime(g.otEnd)}
`; + `
${fmtDay(g.workDate)} · ${fmtTime(g.otStart)}–${fmtTime(g.otEnd)}${g.classification?` · ${g.classification}`:''}
`; const btn=document.createElement("button"); btn.className="btn warn"; btn.textContent="Got it"; btn.style.cssText="padding:6px 14px;font-size:13px;white-space:nowrap"; btn.onclick=()=>ackOvertime(g.id); @@ -1259,13 +1304,15 @@ async function loadMySchedule(){ card.style.cssText="border:1px solid var(--line);border-radius:12px;padding:12px 14px;margin-bottom:10px;background:var(--panel)"; const shift=r.isRestDay ? 'Rest day — no shift' - : `${fmtTime(r.scheduledStart)} – ${fmtTime(r.scheduledEnd)}`+(r.isNightShift?' · night shift':''); + : r.scheduledStart + ? `${fmtTime(r.scheduledStart)} – ${fmtTime(r.scheduledEnd)}`+(r.isNightShift?' · night shift':'') + : 'Overtime only — no regular shift'; let html=`
`+ `
${fmtDay(r.workDate)}
`+ `
${shift}
`; if(r.hasOvertime){ html+=`
`+ - `
⏱ Overtime
`+ + `
⏱ ${r.otClass||'Overtime'}
`+ `
${fmtTime(r.otStart)} – ${fmtTime(r.otEnd)}
`+ `
${r.otAcknowledged?"Acknowledged":"New — not yet acknowledged"}
`+ `
`; diff --git a/public/index.html b/public/index.html index 58f31ad..c73cc6d 100644 --- a/public/index.html +++ b/public/index.html @@ -174,20 +174,24 @@

Build schedule

-
- -
- - -
-
-
+
+

Overtime

+
Grant overtime for a specific date — managed separately from shifts. Pick the person, the exact date, a start time, and the number of hours.
+ +
+ + + +
+
Classified automatically from the date & time — OT, NDOT (night 22:00–06:00), RDOT (rest day), or RDNDOT (rest day + night). Mark the date as a rest day in the schedule for RD rates.
+ +
Upcoming overtime
+
+
+

Copy a week

Duplicate a week's schedule — shifts, rest days, and overtime — to another week.
diff --git a/src/common/overtime.ts b/src/common/overtime.ts new file mode 100644 index 0000000..a9ea650 --- /dev/null +++ b/src/common/overtime.ts @@ -0,0 +1,66 @@ +// Overtime classification. +// +// A granted OT window is split into the four labor categories from two +// independent dimensions of the scheduled date/time: +// • rest day? → rest-day premium +// • night hours 22:00–06:00 → night differential +// yielding OT, NDOT (night), RDOT (rest day), and RDNDOT (rest day + night). +// +// The night window is computed in the server's local time — identical to the +// payroll night-differential calc — so classification labels and pay always +// agree. (Both share the latent assumption that the server runs Manila time.) + +const HOUR = 3_600_000; +const NIGHT_START = 22; // 10:00 PM +const NIGHT_END = 6; // 6:00 AM + +/** Hours of [a, b) that fall inside the nightly 22:00–06:00 window (local). */ +export function nightOverlapHours(a: Date, b: Date): number { + let total = 0; + const day = new Date(a); + day.setHours(0, 0, 0, 0); + day.setDate(day.getDate() - 1); // a night window can start the previous evening + let guard = 0; + while (day.getTime() < b.getTime() && guard++ < 400) { + const winStart = new Date(day); winStart.setHours(NIGHT_START, 0, 0, 0); + const winEnd = new Date(day); winEnd.setHours(NIGHT_END, 0, 0, 0); + winEnd.setDate(winEnd.getDate() + 1); // wraps past midnight + const ov = Math.min(b.getTime(), winEnd.getTime()) - Math.max(a.getTime(), winStart.getTime()); + if (ov > 0) total += ov / HOUR; + day.setDate(day.getDate() + 1); + } + return total; +} + +export interface OtBreakdown { + ot: number; // ordinary overtime (working day, daytime) + ndot: number; // night-differential OT (working day, night) + rdot: number; // rest-day OT (rest day, daytime) + rdndot: number; // rest-day night-differential OT (rest day, night) +} + +export const EMPTY_OT: OtBreakdown = { ot: 0, ndot: 0, rdot: 0, rdndot: 0 }; + +/** Split a granted OT window into the four categories, in hours. */ +export function classifyOvertime(start: Date | null, end: Date | null, isRestDay: boolean): OtBreakdown { + if (!start || !end || end.getTime() <= start.getTime()) return { ...EMPTY_OT }; + const total = (end.getTime() - start.getTime()) / HOUR; + const night = Math.min(total, nightOverlapHours(start, end)); + const day = Math.max(0, total - night); + return isRestDay + ? { ...EMPTY_OT, rdot: day, rdndot: night } + : { ...EMPTY_OT, ot: day, ndot: night }; +} + +const CODES: [keyof OtBreakdown, string][] = [ + ['ot', 'OT'], ['ndot', 'NDOT'], ['rdot', 'RDOT'], ['rdndot', 'RDNDOT'], +]; +const r1 = (n: number) => Math.round(n * 10) / 10; + +/** Compact human label: single category → "RDOT"; mixed → "OT 2h · NDOT 1h". */ +export function otClassLabel(b: OtBreakdown): string { + const parts = CODES.filter(([k]) => b[k] > 0.0001); + if (!parts.length) return ''; + if (parts.length === 1) return parts[0][1]; + return parts.map(([k, code]) => `${code} ${r1(b[k])}h`).join(' · '); +} diff --git a/src/payroll/payroll.controller.ts b/src/payroll/payroll.controller.ts index 296de46..265db1e 100644 --- a/src/payroll/payroll.controller.ts +++ b/src/payroll/payroll.controller.ts @@ -25,13 +25,12 @@ import { PayslipStatus, PayslipLineCategory, } from '@prisma/client'; +import { classifyOvertime, nightOverlapHours } from '../common/overtime'; interface AuthedReq { user: { userId: string; employeeId: string; roles: Role[] }; } -const NIGHT_START = 22; // 10:00 PM -const NIGHT_END = 6; // 6:00 AM const HOUR = 3_600_000; interface EarnRow { @@ -41,11 +40,19 @@ interface EarnRow { teamId: string | null; rate: number; regularHours: number; - overtimeHours: number; - nightHours: number; + overtimeHours: number; // OT + NDOT (non-rest overtime) + otHours: number; + ndotHours: number; + rdOvertimeHours: number; // RDOT + RDNDOT (rest-day overtime) + rdotHours: number; + rdndotHours: number; + nightHours: number; // all worked night hours (regular + OT) regularPay: number; - overtimePay: number; - nightPay: number; + otPay: number; + ndotPay: number; + rdotPay: number; + rdndotPay: number; + nightPay: number; // differential on REGULAR (non-OT) night hours only gross: number; } @@ -127,6 +134,7 @@ export class PayrollController { ): Promise { const policy = await this.prisma.shiftPolicy.findFirst({ where: { orgId }, orderBy: { createdAt: 'asc' } }); const otMult = policy?.otMultiplier ?? 1.5; + const rdOtMult = policy?.rdOtMultiplier ?? 1.69; const nightPct = policy?.nightDiffPercent ?? 10; const employees = await this.prisma.employee.findMany({ @@ -152,13 +160,18 @@ export class PayrollController { otStart: { not: null, lt: periodEndExclusive }, otEnd: { gt: periodStart }, }, - select: { employeeId: true, otStart: true, otEnd: true }, + select: { employeeId: true, otStart: true, otEnd: true, isRestDay: true }, }); + // Two window lists per employee: OT on a rest day (paid at the RD-OT + // premium) vs OT on a working day (regular OT multiplier). A day is either + // a rest day or a working day, so the two lists never overlap. const otByEmp = new Map(); + const rdOtByEmp = new Map(); for (const w of otRows) { if (!w.otStart || !w.otEnd) continue; - if (!otByEmp.has(w.employeeId)) otByEmp.set(w.employeeId, []); - otByEmp.get(w.employeeId)!.push({ start: w.otStart.getTime(), end: w.otEnd.getTime() }); + const bucket = w.isRestDay ? rdOtByEmp : otByEmp; + if (!bucket.has(w.employeeId)) bucket.set(w.employeeId, []); + bucket.get(w.employeeId)!.push({ start: w.otStart.getTime(), end: w.otEnd.getTime() }); } const now = new Date(); @@ -178,29 +191,51 @@ export class PayrollController { byEmp.get(eid)!.push({ startedAt: segStart, endedAt: segEnd }); } + // Intersect a segment with a list of OT windows → the overlapping + // sub-intervals, so each can be classified into day/night hours. + const intersect = (seg: { startedAt: Date; endedAt: Date }, wins: { start: number; end: number }[]) => { + const out: { start: number; end: number }[] = []; + for (const w of wins) { + const start = Math.max(seg.startedAt.getTime(), w.start); + const end = Math.min(seg.endedAt.getTime(), w.end); + if (end > start) out.push({ start, end }); + } + return out; + }; + return employees.map((e) => { const segs = byEmp.get(e.id) ?? []; const wins = otByEmp.get(e.id) ?? []; - let regular = 0; - let overtime = 0; - let nightHours = 0; + const rdWins = rdOtByEmp.get(e.id) ?? []; + let regular = 0, ot = 0, ndot = 0, rdot = 0, rdndot = 0, nightAll = 0; for (const seg of segs) { const segMs = seg.endedAt.getTime() - seg.startedAt.getTime(); - // Worked time inside an authorized OT window is overtime; the rest regular. let otMs = 0; - for (const w of wins) { - const ov = Math.min(seg.endedAt.getTime(), w.end) - Math.max(seg.startedAt.getTime(), w.start); - if (ov > 0) otMs += ov; + // Rest-day OT windows take precedence; the two sets are disjoint anyway. + for (const iv of intersect(seg, rdWins)) { + const c = classifyOvertime(new Date(iv.start), new Date(iv.end), true); + rdot += c.rdot; rdndot += c.rdndot; otMs += iv.end - iv.start; + } + for (const iv of intersect(seg, wins)) { + const c = classifyOvertime(new Date(iv.start), new Date(iv.end), false); + ot += c.ot; ndot += c.ndot; otMs += iv.end - iv.start; } otMs = Math.min(otMs, segMs); // guard against overlapping windows - overtime += otMs / HOUR; regular += (segMs - otMs) / HOUR; - nightHours += this.nightHours(seg.startedAt, seg.endedAt); + nightAll += nightOverlapHours(seg.startedAt, seg.endedAt); } + // Night differential is a +% add-on. For OT night hours it's folded into + // the NDOT/RDNDOT rates, so the standalone night line covers only the + // REGULAR (non-OT) night hours. + const regularNight = Math.max(0, nightAll - ndot - rdndot); const rate = e.hourlyRate ?? 0; + const nd = nightPct / 100; const regularPay = regular * rate; - const overtimePay = overtime * rate * otMult; - const nightPay = nightHours * rate * (nightPct / 100); + const otPay = ot * rate * otMult; + const ndotPay = ndot * rate * (otMult + nd); + const rdotPay = rdot * rate * rdOtMult; + const rdndotPay = rdndot * rate * (rdOtMult + nd); + const nightPay = regularNight * rate * nd; return { employeeId: e.id, employeeCode: e.employeeCode, @@ -208,12 +243,20 @@ export class PayrollController { teamId: e.teamId, rate, regularHours: round(regular), - overtimeHours: round(overtime), - nightHours: round(nightHours), + overtimeHours: round(ot + ndot), + otHours: round(ot), + ndotHours: round(ndot), + rdOvertimeHours: round(rdot + rdndot), + rdotHours: round(rdot), + rdndotHours: round(rdndot), + nightHours: round(nightAll), regularPay: round(regularPay), - overtimePay: round(overtimePay), + otPay: round(otPay), + ndotPay: round(ndotPay), + rdotPay: round(rdotPay), + rdndotPay: round(rdndotPay), nightPay: round(nightPay), - gross: round(regularPay + overtimePay + nightPay), + gross: round(regularPay + otPay + ndotPay + rdotPay + rdndotPay + nightPay), }; }); } @@ -231,6 +274,9 @@ export class PayrollController { rate: r.rate, regularHours: r.regularHours, overtimeHours: r.overtimeHours, + ndotHours: r.ndotHours, + rdOvertimeHours: r.rdOvertimeHours, + rdndotHours: r.rdndotHours, nightHours: r.nightHours, gross: r.gross, })); @@ -424,7 +470,10 @@ export class PayrollController { const lines: { category: PayslipLineCategory; label: string; amount: number; origin: string; componentId?: string }[] = [ { category: 'EARNING', label: 'Regular pay', amount: e.regularPay, origin: 'AUTO' }, ]; - if (e.overtimePay > 0) lines.push({ category: 'EARNING', label: 'Overtime pay', amount: e.overtimePay, origin: 'AUTO' }); + if (e.otPay > 0) lines.push({ category: 'EARNING', label: `Overtime pay (OT, ${e.otHours}h)`, amount: e.otPay, origin: 'AUTO' }); + if (e.ndotPay > 0) lines.push({ category: 'EARNING', label: `Night-diff OT pay (NDOT, ${e.ndotHours}h)`, amount: e.ndotPay, origin: 'AUTO' }); + if (e.rdotPay > 0) lines.push({ category: 'EARNING', label: `Rest day OT pay (RDOT, ${e.rdotHours}h)`, amount: e.rdotPay, origin: 'AUTO' }); + if (e.rdndotPay > 0) lines.push({ category: 'EARNING', label: `Rest day night-diff OT pay (RDNDOT, ${e.rdndotHours}h)`, amount: e.rdndotPay, origin: 'AUTO' }); if (e.nightPay > 0) lines.push({ category: 'EARNING', label: 'Night differential', amount: e.nightPay, origin: 'AUTO' }); // ALLOWANCE / DEDUCTION lines from applicable components. @@ -641,21 +690,4 @@ export class PayrollController { }); } - /** Hours of [a,b] that fall inside the nightly 22:00–06:00 window (local). */ - private nightHours(a: Date, b: Date): number { - let total = 0; - const day = new Date(a); - day.setHours(0, 0, 0, 0); - day.setDate(day.getDate() - 1); // a night window can start the previous evening - let guard = 0; - while (day.getTime() < b.getTime() && guard++ < 400) { - const winStart = new Date(day); winStart.setHours(NIGHT_START, 0, 0, 0); - const winEnd = new Date(day); winEnd.setHours(NIGHT_END, 0, 0, 0); - winEnd.setDate(winEnd.getDate() + 1); // wraps past midnight - const ov = Math.min(b.getTime(), winEnd.getTime()) - Math.max(a.getTime(), winStart.getTime()); - if (ov > 0) total += ov / HOUR; - day.setDate(day.getDate() + 1); - } - return total; - } } diff --git a/src/profile/profile.controller.ts b/src/profile/profile.controller.ts index ebaef2d..94b5797 100644 --- a/src/profile/profile.controller.ts +++ b/src/profile/profile.controller.ts @@ -12,6 +12,7 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { PayslipStatus } from '@prisma/client'; +import { classifyOvertime, otClassLabel } from '../common/overtime'; interface AuthedReq { user: { userId: string; employeeId: string; roles: string[] }; @@ -105,9 +106,12 @@ export class ProfileController { workDate: { gte: this.utcMidnight(since) }, }, orderBy: { workDate: 'asc' }, - select: { id: true, workDate: true, otStart: true, otEnd: true, isNightShift: true }, + select: { id: true, workDate: true, otStart: true, otEnd: true, isNightShift: true, isRestDay: true }, }); - return rows; + return rows.map((r) => ({ + ...r, + classification: otClassLabel(classifyOvertime(r.otStart, r.otEnd, r.isRestDay)), + })); } @Post('overtime/:scheduleId/ack') @@ -162,6 +166,7 @@ export class ProfileController { otEnd: r.otEnd, isNightShift: r.isNightShift, hasOvertime: !!(r.otStart && r.otEnd), + otClass: otClassLabel(classifyOvertime(r.otStart, r.otEnd, r.isRestDay)), otAcknowledged: !!r.otAcknowledgedAt, })); } diff --git a/src/scheduling/schedules.controller.ts b/src/scheduling/schedules.controller.ts index 83cc61c..429c3a4 100644 --- a/src/scheduling/schedules.controller.ts +++ b/src/scheduling/schedules.controller.ts @@ -18,6 +18,7 @@ import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/roles.decorator'; import { Role, LeaveStatus } from '@prisma/client'; import { manilaDateTime } from '../common/timezone'; +import { classifyOvertime, otClassLabel } from '../common/overtime'; interface AuthedReq { user: { userId: string; employeeId: string; roles: Role[] }; @@ -33,8 +34,6 @@ interface ApplyBody { endTime?: string; // HH:mm isNightShift?: boolean; restDays?: string[]; // YYYY-MM-DD dates within the range - otStartTime?: string; // HH:mm — overtime window (individual only) - otEndTime?: string; force?: boolean; // bypass compliance warnings after the WFM confirms } @@ -69,24 +68,23 @@ export class SchedulesController { return emp.orgId; } - /** Turn pending schedule rows into upsert operations (one per employee/day). */ + /** Turn pending schedule rows into upsert operations (one per employee/day). + * Overtime is managed separately (see the OVERTIME GRANTS endpoints), so a + * shift apply/edit NEVER touches the OT window on an existing row — it only + * writes shift/rest-day fields. On create it seeds OT from the row (used by + * copy-week, which deliberately duplicates OT into fresh days). */ private upsertOps(rows: ScheduleRow[]) { return rows.map((r) => { - const data = { + const shiftData = { isRestDay: r.isRestDay, scheduledStart: r.scheduledStart, scheduledEnd: r.scheduledEnd, - otStart: r.otStart, - otEnd: r.otEnd, isNightShift: r.isNightShift, - // Re-applying clears any prior acknowledgment so a fresh OT grant - // re-surfaces the "you've been given overtime" banner. - otAcknowledgedAt: null, }; return this.prisma.schedule.upsert({ where: { employeeId_workDate: { employeeId: r.employeeId, workDate: r.workDate } }, - create: { employeeId: r.employeeId, workDate: r.workDate, ...data }, - update: data, + create: { employeeId: r.employeeId, workDate: r.workDate, ...shiftData, otStart: r.otStart, otEnd: r.otEnd }, + update: shiftData, // leave OT (and its acknowledgment) untouched }); }); } @@ -345,11 +343,6 @@ export class SchedulesController { if (hasWorkingDay && (!startTime || !endTime)) throw new BadRequestException('startTime and endTime are required for working days.'); - // Overtime is an individual-only setting. Ignore it for team applies. - const wantsOt = !!employeeId && !!body.otStartTime && !!body.otEndTime; - if (employeeId && (body.otStartTime || body.otEndTime) && !wantsOt) - throw new BadRequestException('Provide both an overtime start and end time, or neither.'); - const orgId = await this.orgIdFor(req.user.employeeId); // Resolve target employees (in-org). @@ -382,8 +375,11 @@ export class SchedulesController { for (const eid of employeeIds) { for (const ds of days) { const workDate = new Date(ds); // UTC midnight, matches list queries + // Overtime is managed separately (OVERTIME GRANTS endpoints), so the + // shift builder never sets it — otStart/otEnd stay null here and any + // existing OT grant on this day is preserved by upsertOps(). if (restSet.has(ds)) { - newRows.push({ employeeId: eid, workDate, isRestDay: true, scheduledStart: null, scheduledEnd: null, otStart: null, otEnd: null, isNightShift: false }); + newRows.push({ employeeId: eid, workDate, isRestDay: true, scheduledStart: null, scheduledEnd: null, otStart: null, otEnd: null, isNightShift: !!body.isNightShift }); } else { // WFM-entered times are Philippine wall-clock. Parse them with the // explicit Manila offset — a zone-less string would be read in the @@ -392,31 +388,9 @@ export class SchedulesController { let end = manilaDateTime(ds, endTime!); if (isNaN(start.getTime()) || isNaN(end.getTime())) throw new BadRequestException('Invalid start or end time.'); - const overnightShift = end <= start; - if (overnightShift) end = new Date(end.getTime() + 86_400_000); // crosses midnight - - // Optional overtime window (individual only). Null when not set, so - // re-applying without OT clears any previous OT on that day. - let otStart: Date | null = null; - let otEnd: Date | null = null; - if (wantsOt) { - otStart = manilaDateTime(ds, body.otStartTime!); - otEnd = manilaDateTime(ds, body.otEndTime!); - if (isNaN(otStart.getTime()) || isNaN(otEnd.getTime())) - throw new BadRequestException('Invalid overtime time.'); - if (otEnd <= otStart) otEnd = new Date(otEnd.getTime() + 86_400_000); - // OT normally runs right after the shift. On an OVERNIGHT shift the - // WFM enters OT in next-day wall-clock (e.g. 06:00–08:00 after a - // 22:00–06:00 shift), which parses a day early because it's anchored - // to the shift's calendar date — roll it forward so it sits after - // the shift end. Pure day shifts keep any pre-shift OT intact. - if (overnightShift && otStart < start) { - otStart = new Date(otStart.getTime() + 86_400_000); - otEnd = new Date(otEnd.getTime() + 86_400_000); - } - } - - newRows.push({ employeeId: eid, workDate, isRestDay: false, scheduledStart: start, scheduledEnd: end, otStart, otEnd, isNightShift: !!body.isNightShift }); + if (end <= start) end = new Date(end.getTime() + 86_400_000); // crosses midnight + + newRows.push({ employeeId: eid, workDate, isRestDay: false, scheduledStart: start, scheduledEnd: end, otStart: null, otEnd: null, isNightShift: !!body.isNightShift }); } } } @@ -515,4 +489,114 @@ export class SchedulesController { await this.prisma.schedule.delete({ where: { id } }); return { ok: true }; } + + // ───────────────────────── OVERTIME GRANTS ─────────────────────────── + // Overtime is managed on its own: grant it for a specific date as a start + // time + a number of hours (a duration), independent of the shift builder. + // It rides on that day's schedule row (creating one if the day has none). + + private otWorkDate(date: string) { + const d = new Date(`${date}T00:00:00.000Z`); // UTC midnight, matches schedule rows + if (isNaN(d.getTime())) throw new BadRequestException('Invalid date.'); + return d; + } + + private otHoursOf(otStart: Date | null, otEnd: Date | null) { + if (!otStart || !otEnd) return 0; + return Math.round((otEnd.getTime() - otStart.getTime()) / 360_000) / 10; // 1-decimal hours + } + + /** Grant overtime for one employee on one date: start time + N hours. */ + @Post('overtime') + async grantOvertime( + @Req() req: AuthedReq, + @Body() body: { employeeId: string; date: string; startTime: string; hours: number }, + ) { + const { employeeId, date, startTime } = body; + const hours = Number(body.hours); + if (!employeeId || !date || !startTime) + throw new BadRequestException('employeeId, date, and startTime are required.'); + if (!(hours > 0) || hours > 24) + throw new BadRequestException('hours must be between 0 and 24.'); + + const orgId = await this.orgIdFor(req.user.employeeId); + const emp = await this.prisma.employee.findFirst({ where: { id: employeeId, orgId }, select: { id: true } }); + if (!emp) throw new NotFoundException('Employee not found.'); + + // WFM enters Philippine wall-clock; anchor with the Manila offset so the + // window is the same absolute instant regardless of server timezone. + const otStart = manilaDateTime(date, startTime); + if (isNaN(otStart.getTime())) throw new BadRequestException('Invalid start time.'); + const otEnd = new Date(otStart.getTime() + Math.round(hours * 3_600_000)); + + const workDate = this.otWorkDate(date); + const existing = await this.prisma.schedule.findUnique({ + where: { employeeId_workDate: { employeeId, workDate } }, + }); + // Rest-day status comes from the SCHEDULE, not from "no shift": OT is + // rest-day OT only when the date is actually the employee's scheduled rest + // day. A grant on a bare date is ordinary overtime. The final class (OT / + // NDOT / RDOT / RDNDOT) is then derived from that plus the time of day. + const isRestDay = existing ? existing.isRestDay : false; + const saved = await this.prisma.schedule.upsert({ + where: { employeeId_workDate: { employeeId, workDate } }, + create: { employeeId, workDate, isRestDay, scheduledStart: null, scheduledEnd: null, otStart, otEnd, otAcknowledgedAt: null }, + update: { otStart, otEnd, otAcknowledgedAt: null }, // re-surface the banner + }); + const classification = otClassLabel(classifyOvertime(otStart, otEnd, saved.isRestDay)); + this.events.toEmployee(employeeId, { type: 'OVERTIME_GRANTED', workDate, otStart, otEnd }); + return { ok: true, id: saved.id, otStart, otEnd, hours, isRestDay: saved.isRestDay, classification }; + } + + /** Upcoming overtime grants for one employee (for the management list). */ + @Get('overtime') + async listOvertime(@Req() req: AuthedReq, @Query('employeeId') employeeId?: string) { + if (!employeeId) throw new BadRequestException('employeeId is required.'); + const orgId = await this.orgIdFor(req.user.employeeId); + const emp = await this.prisma.employee.findFirst({ where: { id: employeeId, orgId }, select: { id: true } }); + if (!emp) throw new NotFoundException('Employee not found.'); + const since = new Date(); + since.setDate(since.getDate() - 1); // include yesterday so a just-past grant still lists + const rows = await this.prisma.schedule.findMany({ + where: { + employeeId, + otStart: { not: null }, + workDate: { gte: new Date(Date.UTC(since.getUTCFullYear(), since.getUTCMonth(), since.getUTCDate())) }, + }, + orderBy: { workDate: 'asc' }, + select: { id: true, workDate: true, otStart: true, otEnd: true, isRestDay: true, otAcknowledgedAt: true }, + }); + return rows.map((r) => ({ + id: r.id, + workDate: r.workDate, + otStart: r.otStart, + otEnd: r.otEnd, + hours: this.otHoursOf(r.otStart, r.otEnd), + isRestDay: r.isRestDay, + classification: otClassLabel(classifyOvertime(r.otStart, r.otEnd, r.isRestDay)), + acknowledged: !!r.otAcknowledgedAt, + })); + } + + /** Cancel an overtime grant. Removes the row if it existed only for the OT + * (a rest day with no shift); otherwise just clears the OT window. */ + @Delete('overtime/:id') + async clearOvertime(@Req() req: AuthedReq, @Param('id') id: string) { + const orgId = await this.orgIdFor(req.user.employeeId); + const row = await this.prisma.schedule.findUnique({ + where: { id }, + include: { employee: { select: { orgId: true } } }, + }); + if (!row || row.employee.orgId !== orgId) throw new NotFoundException('Overtime grant not found.'); + if (!row.isRestDay && !row.scheduledStart && !row.scheduledEnd) { + // Row existed only to hold this OT (no shift, not a scheduled rest day). + await this.prisma.schedule.delete({ where: { id } }); + } else { + await this.prisma.schedule.update({ + where: { id }, + data: { otStart: null, otEnd: null, otAcknowledgedAt: null }, + }); + } + return { ok: true }; + } } diff --git a/test/overtime-classify.spec.ts b/test/overtime-classify.spec.ts new file mode 100644 index 0000000..5b9bd8d --- /dev/null +++ b/test/overtime-classify.spec.ts @@ -0,0 +1,44 @@ +import { classifyOvertime, otClassLabel } from '../src/common/overtime'; + +// Pure unit tests (no DB). Dates are built with the local-time constructor so +// the night window (22:00–06:00 local) lines up regardless of the runner's tz. +const at = (h: number, m = 0, day = 15) => new Date(2026, 7, day, h, m, 0); // Aug 2026 + +describe('classifyOvertime — OT / NDOT / RDOT / RDNDOT', () => { + it('working day, daytime → OT', () => { + const c = classifyOvertime(at(10), at(12), false); + expect(c).toEqual({ ot: 2, ndot: 0, rdot: 0, rdndot: 0 }); + expect(otClassLabel(c)).toBe('OT'); + }); + + it('working day, night hours → NDOT', () => { + const c = classifyOvertime(at(22), at(24), false); // 22:00–00:00 + expect(c.ndot).toBeCloseTo(2, 5); + expect(c.ot).toBe(0); + expect(otClassLabel(c)).toBe('NDOT'); + }); + + it('rest day, daytime → RDOT', () => { + const c = classifyOvertime(at(10), at(12), true); + expect(c).toEqual({ ot: 0, ndot: 0, rdot: 2, rdndot: 0 }); + expect(otClassLabel(c)).toBe('RDOT'); + }); + + it('rest day, night hours → RDNDOT', () => { + const c = classifyOvertime(at(23), at(25), true); // 23:00–01:00 next day + expect(c.rdndot).toBeCloseTo(2, 5); + expect(otClassLabel(c)).toBe('RDNDOT'); + }); + + it('window straddling 22:00 splits into a mix', () => { + const c = classifyOvertime(at(20), at(23), false); // 2h day + 1h night + expect(c.ot).toBeCloseTo(2, 5); + expect(c.ndot).toBeCloseTo(1, 5); + expect(otClassLabel(c)).toBe('OT 2h · NDOT 1h'); + }); + + it('empty / inverted windows classify as nothing', () => { + expect(otClassLabel(classifyOvertime(null, null, true))).toBe(''); + expect(otClassLabel(classifyOvertime(at(12), at(10), false))).toBe(''); + }); +}); diff --git a/test/overtime-grant.spec.ts b/test/overtime-grant.spec.ts new file mode 100644 index 0000000..a648bb8 --- /dev/null +++ b/test/overtime-grant.spec.ts @@ -0,0 +1,88 @@ +import { PrismaClient } from '@prisma/client'; +import { SchedulesController } from '../src/scheduling/schedules.controller'; +import { cleanupOrg } from './helpers'; + +const prisma = new PrismaClient(); +const events = { toEmployee() {}, toApprovers() {}, toActivity() {} } as any; +const ctrl = new SchedulesController(prisma as any, events); + +afterAll(async () => { + await prisma.$disconnect(); +}); + +// ── Integration: the separately-managed overtime grant (date + start + hours) ── +describe('overtime grants — date + start time + hours', () => { + let orgId: string; + let empId: string; + const req = () => ({ user: { userId: 'u', employeeId: empId, roles: ['WFM'] } }) as any; + const workDate = (d: string) => new Date(`${d}T00:00:00.000Z`); + + beforeAll(async () => { + const org = await prisma.organization.create({ data: { name: 'TEST-OTG-' + Date.now(), timezone: 'Asia/Manila' } }); + orgId = org.id; + await prisma.shiftPolicy.create({ data: { orgId, name: 'T', shiftHours: 8 } }); + const emp = await prisma.employee.create({ + data: { orgId, employeeCode: 'OTG-1', fullName: 'OT Grant Tester', hireDate: new Date(), hourlyRate: 100 }, + }); + empId = emp.id; + }); + + afterEach(async () => { + await prisma.schedule.deleteMany({ where: { employeeId: empId } }); + }); + + afterAll(async () => { + await cleanupOrg(prisma, orgId); + }); + + it('grants OT on a bare date as ordinary OT (not rest day) and stores start + N hours', async () => { + const res = await ctrl.grantOvertime(req(), { employeeId: empId, date: '2026-08-15', startTime: '18:00', hours: 3 }); + expect(res.ok).toBe(true); + expect(res.isRestDay).toBe(false); // bare date is NOT a rest day + expect(res.classification).toBe('OT'); // 6–9 PM is daytime + expect(res.hours).toBe(3); + const row = await prisma.schedule.findUnique({ where: { employeeId_workDate: { employeeId: empId, workDate: workDate('2026-08-15') } } }); + expect(row!.isRestDay).toBe(false); + expect(row!.scheduledStart).toBeNull(); + expect((row!.otEnd!.getTime() - row!.otStart!.getTime()) / 3_600_000).toBeCloseTo(3, 5); + }); + + it('grants OT on a scheduled rest day as RDOT', async () => { + await prisma.schedule.create({ + data: { employeeId: empId, workDate: workDate('2026-08-20'), isRestDay: true, scheduledStart: null, scheduledEnd: null }, + }); + const res = await ctrl.grantOvertime(req(), { employeeId: empId, date: '2026-08-20', startTime: '10:00', hours: 2 }); + expect(res.isRestDay).toBe(true); + expect(res.classification).toBe('RDOT'); + }); + + it('grants OT on an existing working day as ordinary OT, preserving the shift', async () => { + await prisma.schedule.create({ + data: { employeeId: empId, workDate: workDate('2026-08-16'), isRestDay: false, + scheduledStart: new Date('2026-08-16T09:00:00'), scheduledEnd: new Date('2026-08-16T17:00:00') }, + }); + const res = await ctrl.grantOvertime(req(), { employeeId: empId, date: '2026-08-16', startTime: '17:00', hours: 2 }); + expect(res.isRestDay).toBe(false); // rides on the shift → ordinary OT + const row = await prisma.schedule.findUnique({ where: { employeeId_workDate: { employeeId: empId, workDate: workDate('2026-08-16') } } }); + expect(row!.scheduledStart).not.toBeNull(); // shift preserved + expect(row!.otStart).not.toBeNull(); + }); + + it('lists grants with computed hours + classification, and clearing a bare OT day removes the row', async () => { + const g = await ctrl.grantOvertime(req(), { employeeId: empId, date: '2026-08-17', startTime: '10:00', hours: 2.5 }); + const list = await ctrl.listOvertime(req(), empId); + expect(list.length).toBe(1); + expect(list[0].hours).toBe(2.5); + expect(list[0].isRestDay).toBe(false); + expect(list[0].classification).toBe('OT'); + + await ctrl.clearOvertime(req(), g.id); + const row = await prisma.schedule.findUnique({ where: { employeeId_workDate: { employeeId: empId, workDate: workDate('2026-08-17') } } }); + expect(row).toBeNull(); // OT-only row (no shift, not a rest day) is removed on clear + }); + + it('rejects invalid hours', async () => { + await expect(ctrl.grantOvertime(req(), { employeeId: empId, date: '2026-08-18', startTime: '18:00', hours: 0 })) + .rejects.toThrow(/hours/); + }); +}); diff --git a/test/payroll-logic.spec.ts b/test/payroll-logic.spec.ts index ad15e17..7f883b3 100644 --- a/test/payroll-logic.spec.ts +++ b/test/payroll-logic.spec.ts @@ -112,4 +112,49 @@ describe('computeEarnings — authorization-based overtime + session cap', () => // Capped at 16:00 → 8h. Without the cap it would bill all the way to the period end. expect(row.regularHours).toBeCloseTo(8, 5); }); + + it('pays rest-day overtime at the RD-OT multiplier, distinct from regular OT', async () => { + await resetShift(); + const clockIn = new Date(`${day}T10:00:00`); + const clockOut = new Date(`${day}T12:00:00`); // 2h worked, all inside RD OT + const te = await prisma.timeEntry.create({ + data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date(`${day}T12:00:00`), clockOutAt: clockOut, status: 'CLOSED' }, + }); + await prisma.activitySession.create({ data: { timeEntryId: te.id, activityType: 'Inbound Calls', startedAt: clockIn, endedAt: clockOut } }); + // Rest day with a WFM-granted OT window 10:00–12:00 (daytime → no night diff). + await prisma.schedule.create({ + data: { employeeId: empId, workDate: new Date(`${day}T00:00:00Z`), isRestDay: true, + otStart: new Date(`${day}T10:00:00`), otEnd: new Date(`${day}T12:00:00`) }, + }); + + const [row] = await earn(); + expect(row.rdOvertimeHours).toBeCloseTo(2, 5); + expect(row.overtimeHours).toBe(0); // not counted as ordinary OT + expect(row.regularHours).toBe(0); // rest-day work isn't regular pay + expect(row.nightHours).toBe(0); + // 2h × ₱100 × 1.69 (default rdOtMultiplier) = 338. + expect(row.gross).toBeCloseTo(338, 2); + }); + + it('classifies rest-day night OT as RDNDOT and stacks the night differential', async () => { + await resetShift(); + const clockIn = new Date(`${day}T22:00:00`); + const clockOut = new Date('2026-06-02T00:00:00'); // 2h, all rest-day night + const te = await prisma.timeEntry.create({ + data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date('2026-06-02T00:00:00'), clockOutAt: clockOut, status: 'CLOSED' }, + }); + await prisma.activitySession.create({ data: { timeEntryId: te.id, activityType: 'Inbound Calls', startedAt: clockIn, endedAt: clockOut } }); + await prisma.schedule.create({ + data: { employeeId: empId, workDate: new Date(`${day}T00:00:00Z`), isRestDay: true, + otStart: new Date(`${day}T22:00:00`), otEnd: new Date('2026-06-02T00:00:00') }, + }); + + const [row] = await earn(); + expect(row.rdndotHours).toBeCloseTo(2, 5); + expect(row.rdOvertimeHours).toBeCloseTo(2, 5); // rdot + rdndot + expect(row.overtimeHours).toBe(0); + expect(row.nightHours).toBeCloseTo(2, 5); + // 2h × ₱100 × (1.69 rest-day + 0.10 night) = 358. + expect(row.gross).toBeCloseTo(358, 2); + }); }); From ebfda26f6fd079c3e0fea0eb584a34246a10d505 Mon Sep 17 00:00:00 2001 From: Kristhian Cadiz Date: Mon, 6 Jul 2026 23:26:36 +0800 Subject: [PATCH 4/5] Evaluate the night window in Manila time, not server-local CI (UTC) failed the overtime classification and payroll night-differential tests: OT windows are Manila wall-clock instants, but the 22:00-06:00 night window was computed in the server's local time, so the same window classified differently on a UTC runner vs a Manila one (10:00 Manila read as 02:00 UTC and wrongly counted as night). Compute the night window in Philippine time (UTC+8) explicitly so it's correct and identical on any server. Anchor the timezone-naive date literals in the payroll test to +08:00 so its assertions hold regardless of runner tz. Also fixes a latent server-timezone bug the payroll night differential already had. Co-Authored-By: Claude Opus 4.8 --- WFM-Features.pptx | Bin 0 -> 27376 bytes src/common/overtime.ts | 29 ++++++++++++++----------- test/overtime-classify.spec.ts | 7 +++--- test/payroll-logic.spec.ts | 38 ++++++++++++++++----------------- 4 files changed, 39 insertions(+), 35 deletions(-) create mode 100644 WFM-Features.pptx diff --git a/WFM-Features.pptx b/WFM-Features.pptx new file mode 100644 index 0000000000000000000000000000000000000000..25c10808f5c00ca7526fe352f16f8a6b97d7cfd3 GIT binary patch literal 27376 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 literal 0 HcmV?d00001 diff --git a/src/common/overtime.ts b/src/common/overtime.ts index a9ea650..92a6214 100644 --- a/src/common/overtime.ts +++ b/src/common/overtime.ts @@ -6,28 +6,31 @@ // • night hours 22:00–06:00 → night differential // yielding OT, NDOT (night), RDOT (rest day), and RDNDOT (rest day + night). // -// The night window is computed in the server's local time — identical to the -// payroll night-differential calc — so classification labels and pay always -// agree. (Both share the latent assumption that the server runs Manila time.) +// The night window (22:00–06:00) is evaluated in PHILIPPINE time regardless of +// the server's timezone — OT times are Manila wall-clock, so night must be too, +// or the same OT window would classify differently on a UTC vs Manila server. const HOUR = 3_600_000; +const DAY = 86_400_000; +const MANILA_OFFSET = 8 * HOUR; // PH is UTC+8, no DST const NIGHT_START = 22; // 10:00 PM const NIGHT_END = 6; // 6:00 AM -/** Hours of [a, b) that fall inside the nightly 22:00–06:00 window (local). */ +/** Hours of [a, b) that fall inside the nightly 22:00–06:00 window (Manila). */ export function nightOverlapHours(a: Date, b: Date): number { + // Shift instants into a Manila-local frame, then apply fixed daily windows. + const aM = a.getTime() + MANILA_OFFSET; + const bM = b.getTime() + MANILA_OFFSET; + if (bM <= aM) return 0; let total = 0; - const day = new Date(a); - day.setHours(0, 0, 0, 0); - day.setDate(day.getDate() - 1); // a night window can start the previous evening + let day = Math.floor(aM / DAY) * DAY - DAY; // include the prior evening's window let guard = 0; - while (day.getTime() < b.getTime() && guard++ < 400) { - const winStart = new Date(day); winStart.setHours(NIGHT_START, 0, 0, 0); - const winEnd = new Date(day); winEnd.setHours(NIGHT_END, 0, 0, 0); - winEnd.setDate(winEnd.getDate() + 1); // wraps past midnight - const ov = Math.min(b.getTime(), winEnd.getTime()) - Math.max(a.getTime(), winStart.getTime()); + while (day < bM && guard++ < 400) { + const winStart = day + NIGHT_START * HOUR; + const winEnd = day + DAY + NIGHT_END * HOUR; // wraps to next-day 06:00 + const ov = Math.min(bM, winEnd) - Math.max(aM, winStart); if (ov > 0) total += ov / HOUR; - day.setDate(day.getDate() + 1); + day += DAY; } return total; } diff --git a/test/overtime-classify.spec.ts b/test/overtime-classify.spec.ts index 5b9bd8d..9da6a53 100644 --- a/test/overtime-classify.spec.ts +++ b/test/overtime-classify.spec.ts @@ -1,8 +1,9 @@ import { classifyOvertime, otClassLabel } from '../src/common/overtime'; -// Pure unit tests (no DB). Dates are built with the local-time constructor so -// the night window (22:00–06:00 local) lines up regardless of the runner's tz. -const at = (h: number, m = 0, day = 15) => new Date(2026, 7, day, h, m, 0); // Aug 2026 +// Pure unit tests (no DB). Dates are anchored to Manila time (the frame the +// night window uses) so results are identical on a UTC or Manila runner. +const BASE = Date.parse('2026-08-15T00:00:00+08:00'); +const at = (h: number, m = 0) => new Date(BASE + h * 3_600_000 + m * 60_000); describe('classifyOvertime — OT / NDOT / RDOT / RDNDOT', () => { it('working day, daytime → OT', () => { diff --git a/test/payroll-logic.spec.ts b/test/payroll-logic.spec.ts index 7f883b3..19c4dc6 100644 --- a/test/payroll-logic.spec.ts +++ b/test/payroll-logic.spec.ts @@ -38,8 +38,8 @@ describe('computeEarnings — authorization-based overtime + session cap', () => let orgId: string; let empId: string; const day = '2026-06-01'; - const periodStart = new Date(`${day}T00:00:00`); - const periodEndExcl = new Date('2026-06-02T00:00:00'); + const periodStart = new Date(`${day}T00:00:00+08:00`); + const periodEndExcl = new Date('2026-06-02T00:00:00+08:00'); beforeAll(async () => { const org = await prisma.organization.create({ data: { name: 'TEST-OT-' + Date.now(), timezone: 'Asia/Manila' } }); @@ -68,15 +68,15 @@ describe('computeEarnings — authorization-based overtime + session cap', () => it('pays OT only for worked time inside an authorized OT window', async () => { await resetShift(); - const clockIn = new Date(`${day}T18:00:00`); - const clockOut = new Date(`${day}T22:00:00`); // 4h worked + const clockIn = new Date(`${day}T18:00:00+08:00`); + const clockOut = new Date(`${day}T22:00:00+08:00`); // 4h worked const te = await prisma.timeEntry.create({ - data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date(`${day}T23:00:00`), clockOutAt: clockOut, status: 'CLOSED' }, + data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date(`${day}T23:00:00+08:00`), clockOutAt: clockOut, status: 'CLOSED' }, }); await prisma.activitySession.create({ data: { timeEntryId: te.id, activityType: 'Inbound Calls', startedAt: clockIn, endedAt: clockOut } }); // WFM-authorized OT window 20:00–22:00 (2h) await prisma.schedule.create({ - data: { employeeId: empId, workDate: new Date(`${day}T00:00:00Z`), otStart: new Date(`${day}T20:00:00`), otEnd: new Date(`${day}T22:00:00`) }, + data: { employeeId: empId, workDate: new Date(`${day}T00:00:00Z`), otStart: new Date(`${day}T20:00:00+08:00`), otEnd: new Date(`${day}T22:00:00+08:00`) }, }); const [row] = await earn(); @@ -86,10 +86,10 @@ describe('computeEarnings — authorization-based overtime + session cap', () => it('pays NO overtime without an authorized window, even past 8h worked', async () => { await resetShift(); - const clockIn = new Date(`${day}T14:00:00`); - const clockOut = new Date(`${day}T23:00:00`); // 9h worked, no OT window + const clockIn = new Date(`${day}T14:00:00+08:00`); + const clockOut = new Date(`${day}T23:00:00+08:00`); // 9h worked, no OT window const te = await prisma.timeEntry.create({ - data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date('2026-06-02T00:00:00'), clockOutAt: clockOut, status: 'CLOSED' }, + data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date('2026-06-02T00:00:00+08:00'), clockOutAt: clockOut, status: 'CLOSED' }, }); await prisma.activitySession.create({ data: { timeEntryId: te.id, activityType: 'Inbound Calls', startedAt: clockIn, endedAt: clockOut } }); @@ -101,8 +101,8 @@ describe('computeEarnings — authorization-based overtime + session cap', () => it('caps a still-open session at the shift end, not "now" (#5)', async () => { await resetShift(); - const clockIn = new Date(`${day}T08:00:00`); - const shiftEnds = new Date(`${day}T16:00:00`); // 8h window, already in the past + const clockIn = new Date(`${day}T08:00:00+08:00`); + const shiftEnds = new Date(`${day}T16:00:00+08:00`); // 8h window, already in the past const te = await prisma.timeEntry.create({ data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: shiftEnds, status: 'OPEN' }, }); @@ -115,16 +115,16 @@ describe('computeEarnings — authorization-based overtime + session cap', () => it('pays rest-day overtime at the RD-OT multiplier, distinct from regular OT', async () => { await resetShift(); - const clockIn = new Date(`${day}T10:00:00`); - const clockOut = new Date(`${day}T12:00:00`); // 2h worked, all inside RD OT + const clockIn = new Date(`${day}T10:00:00+08:00`); + const clockOut = new Date(`${day}T12:00:00+08:00`); // 2h worked, all inside RD OT const te = await prisma.timeEntry.create({ - data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date(`${day}T12:00:00`), clockOutAt: clockOut, status: 'CLOSED' }, + data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date(`${day}T12:00:00+08:00`), clockOutAt: clockOut, status: 'CLOSED' }, }); await prisma.activitySession.create({ data: { timeEntryId: te.id, activityType: 'Inbound Calls', startedAt: clockIn, endedAt: clockOut } }); // Rest day with a WFM-granted OT window 10:00–12:00 (daytime → no night diff). await prisma.schedule.create({ data: { employeeId: empId, workDate: new Date(`${day}T00:00:00Z`), isRestDay: true, - otStart: new Date(`${day}T10:00:00`), otEnd: new Date(`${day}T12:00:00`) }, + otStart: new Date(`${day}T10:00:00+08:00`), otEnd: new Date(`${day}T12:00:00+08:00`) }, }); const [row] = await earn(); @@ -138,15 +138,15 @@ describe('computeEarnings — authorization-based overtime + session cap', () => it('classifies rest-day night OT as RDNDOT and stacks the night differential', async () => { await resetShift(); - const clockIn = new Date(`${day}T22:00:00`); - const clockOut = new Date('2026-06-02T00:00:00'); // 2h, all rest-day night + const clockIn = new Date(`${day}T22:00:00+08:00`); + const clockOut = new Date('2026-06-02T00:00:00+08:00'); // 2h, all rest-day night const te = await prisma.timeEntry.create({ - data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date('2026-06-02T00:00:00'), clockOutAt: clockOut, status: 'CLOSED' }, + data: { employeeId: empId, clockInAt: clockIn, shiftEndsAt: new Date('2026-06-02T00:00:00+08:00'), clockOutAt: clockOut, status: 'CLOSED' }, }); await prisma.activitySession.create({ data: { timeEntryId: te.id, activityType: 'Inbound Calls', startedAt: clockIn, endedAt: clockOut } }); await prisma.schedule.create({ data: { employeeId: empId, workDate: new Date(`${day}T00:00:00Z`), isRestDay: true, - otStart: new Date(`${day}T22:00:00`), otEnd: new Date('2026-06-02T00:00:00') }, + otStart: new Date(`${day}T22:00:00+08:00`), otEnd: new Date('2026-06-02T00:00:00+08:00') }, }); const [row] = await earn(); From c366ca641d9bc4db754c2be0c85c6510c785fe9e Mon Sep 17 00:00:00 2001 From: Kristhian Cadiz Date: Mon, 6 Jul 2026 23:27:04 +0800 Subject: [PATCH 5/5] Remove accidentally-committed slide deck WFM-Features.pptx was a presentation artifact that doesn't belong in the repository; it was swept in by git add -A. Co-Authored-By: Claude Opus 4.8 --- WFM-Features.pptx | Bin 27376 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 WFM-Features.pptx diff --git a/WFM-Features.pptx b/WFM-Features.pptx deleted file mode 100644 index 25c10808f5c00ca7526fe352f16f8a6b97d7cfd3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27376 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