Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
105 changes: 96 additions & 9 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand All @@ -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();
Expand Down Expand Up @@ -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='<div class="muted" style="font-size:13px;padding:4px 0">Loading…</div>';
let rows;
try{ rows=await api("/schedules/overtime?employeeId="+encodeURIComponent(eid)); }
catch(e){ box.innerHTML='<div class="muted" style="font-size:13px">Could not load overtime.</div>'; return; }
if(!rows.length){ box.innerHTML='<div class="muted" style="font-size:13px">No upcoming overtime.</div>'; 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=`<div><div style="font-weight:600;font-size:13px">${fmtDay(r.workDate)} · ${r.hours}h${r.classification?` <span style="color:var(--amber)">· ${r.classification}</span>`:''}</div>`+
`<div class="muted" style="font-size:12px">${fmtTime(r.otStart)} – ${fmtTime(r.otEnd)}${r.acknowledged?' · acknowledged':''}</div></div>`;
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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1224,18 +1271,58 @@ 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=`<div><b style="color:var(--amber)">⏱ WFM has provided you with overtime</b>`+
`<div class="muted" style="font-size:13px;margin-top:2px">${fmtDay(g.workDate)} · ${fmtTime(g.otStart)}–${fmtTime(g.otEnd)}</div></div>`;
`<div class="muted" style="font-size:13px;margin-top:2px">${fmtDay(g.workDate)} · ${fmtTime(g.otStart)}–${fmtTime(g.otEnd)}${g.classification?` · ${g.classification}`:''}</div></div>`;
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);
card.appendChild(btn); box.appendChild(card);
});
}
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='<div class="muted" style="font-size:13px;padding:4px 0">Loading…</div>';
let rows;
try{ rows=await api("/me/schedule"); }
catch(e){ box.innerHTML='<div class="muted" style="font-size:13px">Could not load your schedule.</div>'; return; }
if(!rows.length){ box.innerHTML='<div class="muted" style="font-size:13px">No upcoming shifts scheduled.</div>'; 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
? '<span class="muted">Rest day — no shift</span>'
: r.scheduledStart
? `<b>${fmtTime(r.scheduledStart)} – ${fmtTime(r.scheduledEnd)}</b>`+(r.isNightShift?' <span class="muted" style="font-size:12px">· night shift</span>':'')
: '<span class="muted">Overtime only — no regular shift</span>';
let html=`<div class="row" style="justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">`+
`<div><div style="font-size:13px;color:var(--muted)">${fmtDay(r.workDate)}</div>`+
`<div style="margin-top:2px">${shift}</div></div>`;
if(r.hasOvertime){
html+=`<div style="border:1px solid var(--amber);border-radius:10px;padding:8px 12px;background:#16130a">`+
`<div style="color:var(--amber);font-weight:600;font-size:13px">⏱ ${r.otClass||'Overtime'}</div>`+
`<div style="font-size:13px;margin-top:2px">${fmtTime(r.otStart)} – ${fmtTime(r.otEnd)}</div>`+
`<div class="muted" style="font-size:11px;margin-top:2px">${r.otAcknowledged?"Acknowledged":"New — not yet acknowledged"}</div>`+
`</div>`;
}
html+=`</div>`;
card.innerHTML=html;
box.appendChild(card);
});
}

// ── My payslips (every member) ──
async function loadMyPayslips(){
const box=document.getElementById("my-payslip-list");
Expand Down
35 changes: 24 additions & 11 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ <h2 id="mfa-title">Verify it's you</h2>
</div>
</div>

<!-- Employee schedule (read-only) -->
<div id="tab-myschedule" class="hide">
<div class="panel">
<h2>My schedule</h2>
<div class="sub">Your upcoming shifts. Any overtime you've been granted is highlighted here — and it stays here even after you dismiss the reminder.</div>
<div id="mysched-list" style="margin-top:14px"></div>
</div>
</div>

<!-- Employee leave -->
<div id="tab-leave" class="hide">
<div class="grid2">
Expand Down Expand Up @@ -165,20 +174,24 @@ <h2>Build schedule</h2>
<div id="sc-restdays" class="chips"></div>
</div>

<div id="sc-ot-section" style="margin-top:20px;border:1px solid var(--line);border-radius:12px;padding:14px;background:var(--panel-2)">
<label style="display:flex;align-items:center;gap:8px;font-weight:600;font-size:14px;cursor:pointer">
<input id="sc-ot-on" type="checkbox" style="width:auto"/>
Add overtime window <span class="muted" style="font-weight:400;font-size:12px">— individual only, applies to working days</span>
</label>
<div id="sc-ot-fields" class="row hide" style="gap:10px;margin-top:12px">
<label class="fld" style="margin:0;flex:1;min-width:120px"><span>OT start</span><input id="sc-ot-start" type="time"/></label>
<label class="fld" style="margin:0;flex:1;min-width:120px"><span>OT end</span><input id="sc-ot-end" type="time"/></label>
</div>
</div>

<button class="btn primary" id="sc-apply" style="width:100%;margin-top:20px">Apply schedule</button>
</div>

<div class="panel">
<h2>Overtime</h2>
<div class="sub">Grant overtime for a specific date — managed separately from shifts. Pick the person, the exact date, a start time, and the number of hours.</div>
<label class="fld"><span>Employee</span><select id="ot-emp"></select></label>
<div class="row" style="gap:10px">
<label class="fld" style="margin:0;flex:1;min-width:130px"><span>Date</span><input id="ot-date" type="date"/></label>
<label class="fld" style="margin:0;flex:1;min-width:110px"><span>Start time</span><input id="ot-start" type="time"/></label>
<label class="fld" style="margin:0;flex:1;min-width:90px"><span>Hours</span><input id="ot-hours" type="number" min="0.5" max="24" step="0.5" value="2"/></label>
</div>
<div class="muted" style="font-size:12px;margin-top:2px">Classified automatically from the date &amp; time — <b>OT</b>, <b>NDOT</b> (night 22:00–06:00), <b>RDOT</b> (rest day), or <b>RDNDOT</b> (rest day + night). Mark the date as a rest day in the schedule for RD rates.</div>
<button class="btn primary" id="ot-grant" style="width:100%;margin-top:12px">Grant overtime</button>
<div style="margin-top:20px"><div class="muted" style="font-size:12px;text-transform:uppercase;letter-spacing:.5px">Upcoming overtime</div>
<div id="ot-list" class="feed"></div></div>
</div>

<div class="panel">
<h2>Copy a week</h2>
<div class="sub">Duplicate a week's schedule — shifts, rest days, and overtime — to another week.</div>
Expand Down
69 changes: 69 additions & 0 deletions src/common/overtime.ts
Original file line number Diff line number Diff line change
@@ -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(' · ');
}
Loading
Loading