Files
Krow-workspace/frontend-web-free/src/components/scheduling/OvertimeCalculator.jsx
2025-12-04 18:02:28 -05:00

221 lines
6.5 KiB
JavaScript

/**
* Overtime & Double Time Calculator
* Calculates OT/DT exposure based on state regulations
*/
// State-specific OT/DT rules
const STATE_RULES = {
CA: {
dailyOT: 8, // OT after 8 hours/day
dailyDT: 12, // DT after 12 hours/day
weeklyOT: 40, // OT after 40 hours/week
seventhDayDT: true, // 7th consecutive day = DT
otRate: 1.5,
dtRate: 2.0,
},
DEFAULT: {
dailyOT: null, // No daily OT in most states
dailyDT: null,
weeklyOT: 40,
seventhDayDT: false,
otRate: 1.5,
dtRate: 2.0,
}
};
export function getStateRules(state) {
return STATE_RULES[state] || STATE_RULES.DEFAULT;
}
/**
* Calculate OT status for a worker considering a shift
* @param {Object} worker - Worker with current hours
* @param {Object} shift - Shift to assign
* @param {Array} allEvents - All events to check existing assignments
* @returns {Object} OT analysis
*/
export function calculateOTStatus(worker, shift, allEvents = []) {
const state = shift.state || worker.state || "DEFAULT";
const rules = getStateRules(state);
// Get shift duration
const shiftHours = calculateShiftHours(shift);
// Calculate current hours from existing assignments
const currentHours = calculateWorkerCurrentHours(worker, allEvents, shift.date);
// Project new hours
const projectedDayHours = currentHours.currentDayHours + shiftHours;
const projectedWeekHours = currentHours.currentWeekHours + shiftHours;
// Calculate OT/DT
let otHours = 0;
let dtHours = 0;
let status = "GREEN";
let summary = "No OT or DT triggered";
let costImpact = 0;
// Daily OT/DT (CA-specific)
if (rules.dailyOT && projectedDayHours > rules.dailyOT) {
if (rules.dailyDT && projectedDayHours > rules.dailyDT) {
// Some hours are DT
dtHours = projectedDayHours - rules.dailyDT;
otHours = rules.dailyDT - rules.dailyOT;
status = "RED";
summary = `Triggers ${otHours.toFixed(1)}h OT + ${dtHours.toFixed(1)}h DT (${state})`;
} else {
// Only OT, no DT
otHours = projectedDayHours - rules.dailyOT;
status = projectedDayHours >= rules.dailyDT - 1 ? "AMBER" : "AMBER";
summary = `Triggers ${otHours.toFixed(1)}h OT (${state})`;
}
}
// Weekly OT
if (rules.weeklyOT && projectedWeekHours > rules.weeklyOT && !otHours) {
otHours = projectedWeekHours - rules.weeklyOT;
status = "AMBER";
summary = `Triggers ${otHours.toFixed(1)}h weekly OT`;
}
// Near thresholds (warning zone)
if (status === "GREEN") {
if (rules.dailyOT && projectedDayHours >= rules.dailyOT - 1) {
status = "AMBER";
summary = `Near daily OT threshold (${projectedDayHours.toFixed(1)}h)`;
} else if (rules.weeklyOT && projectedWeekHours >= rules.weeklyOT - 4) {
status = "AMBER";
summary = `Approaching weekly OT (${projectedWeekHours.toFixed(1)}h)`;
} else {
summary = `Safe · No OT (${projectedDayHours.toFixed(1)}h day, ${projectedWeekHours.toFixed(1)}h week)`;
}
}
// Calculate cost impact
const baseRate = worker.hourly_rate || shift.rate_per_hour || 20;
const baseCost = shiftHours * baseRate;
const otCost = otHours * baseRate * rules.otRate;
const dtCost = dtHours * baseRate * rules.dtRate;
costImpact = otCost + dtCost;
return {
status,
summary,
currentDayHours: currentHours.currentDayHours,
currentWeekHours: currentHours.currentWeekHours,
projectedDayHours,
projectedWeekHours,
otHours,
dtHours,
baseCost,
costImpact,
totalCost: baseCost + costImpact,
rulePattern: `${state}_${rules.dailyOT ? 'DAILY' : 'WEEKLY'}_OT`,
canAssign: true, // Always allow but warn
requiresApproval: status === "RED",
};
}
/**
* Calculate shift duration in hours
*/
function calculateShiftHours(shift) {
if (shift.hours) return shift.hours;
// Try to parse from start/end times
if (shift.start_time && shift.end_time) {
const [startH, startM] = shift.start_time.split(':').map(Number);
const [endH, endM] = shift.end_time.split(':').map(Number);
const startMins = startH * 60 + startM;
const endMins = endH * 60 + endM;
const duration = (endMins - startMins) / 60;
return duration > 0 ? duration : duration + 24; // Handle overnight
}
return 8; // Default 8-hour shift
}
/**
* Calculate worker's current hours for the day and week
*/
function calculateWorkerCurrentHours(worker, allEvents, shiftDate) {
let currentDayHours = 0;
let currentWeekHours = 0;
if (!allEvents || !shiftDate) {
return {
currentDayHours: worker.current_day_hours || 0,
currentWeekHours: worker.current_week_hours || 0,
};
}
const shiftDateObj = new Date(shiftDate);
const shiftDay = shiftDateObj.getDay();
// Get start of week (Sunday)
const weekStart = new Date(shiftDateObj);
weekStart.setDate(shiftDateObj.getDate() - shiftDay);
weekStart.setHours(0, 0, 0, 0);
// Count hours from existing assignments
allEvents.forEach(event => {
if (event.status === "Canceled" || event.status === "Completed") return;
const isAssigned = event.assigned_staff?.some(s => s.staff_id === worker.id);
if (!isAssigned) return;
const eventDate = new Date(event.date);
// Same day hours
if (eventDate.toDateString() === shiftDateObj.toDateString()) {
(event.shifts || []).forEach(shift => {
(shift.roles || []).forEach(role => {
if (event.assigned_staff?.some(s => s.staff_id === worker.id && s.role === role.role)) {
currentDayHours += role.hours || 8;
}
});
});
}
// Same week hours
if (eventDate >= weekStart && eventDate <= shiftDateObj) {
(event.shifts || []).forEach(shift => {
(shift.roles || []).forEach(role => {
if (event.assigned_staff?.some(s => s.staff_id === worker.id && s.role === role.role)) {
currentWeekHours += role.hours || 8;
}
});
});
}
});
return { currentDayHours, currentWeekHours };
}
/**
* Get OT badge component props
*/
export function getOTBadgeProps(status) {
switch (status) {
case "GREEN":
return {
className: "bg-emerald-500 text-white",
label: "Safe · No OT"
};
case "AMBER":
return {
className: "bg-amber-500 text-white",
label: "Near OT"
};
case "RED":
return {
className: "bg-red-500 text-white",
label: "OT/DT Risk"
};
default:
return {
className: "bg-slate-500 text-white",
label: "Unknown"
};
}
}