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 abf034c..4e3754a 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(); @@ -597,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(); @@ -637,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; @@ -730,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); @@ -1224,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); @@ -1232,10 +1279,50 @@ 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' + : 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+=`
`+ + `
⏱ ${r.otClass||'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..c73cc6d 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.
+
+
+
+
@@ -165,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..92a6214 --- /dev/null +++ b/src/common/overtime.ts @@ -0,0 +1,69 @@ +// 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 (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 (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; + let day = Math.floor(aM / DAY) * DAY - DAY; // include the prior evening's window + let guard = 0; + 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 += DAY; + } + 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 d8b11d7..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') @@ -122,6 +126,51 @@ 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), + otClass: otClassLabel(classifyOvertime(r.otStart, r.otEnd, r.isRestDay)), + otAcknowledged: !!r.otAcknowledgedAt, + })); + } + private utcMidnight(d: Date) { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); } diff --git a/src/scheduling/schedules.controller.ts b/src/scheduling/schedules.controller.ts index a4f1dbd..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,21 +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.'); - if (end <= start) end = new Date(end.getTime() + 86_400_000); // overnight - - // 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); - } - - 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 }); } } } @@ -505,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/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); + }); +}); diff --git a/test/overtime-classify.spec.ts b/test/overtime-classify.spec.ts new file mode 100644 index 0000000..9da6a53 --- /dev/null +++ b/test/overtime-classify.spec.ts @@ -0,0 +1,45 @@ +import { classifyOvertime, otClassLabel } from '../src/common/overtime'; + +// 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', () => { + 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..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' }, }); @@ -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+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+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+08:00`), otEnd: new Date(`${day}T12:00:00+08: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+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+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+08:00`), otEnd: new Date('2026-06-02T00:00:00+08: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); + }); });