221 lines
6.5 KiB
JavaScript
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"
|
|
};
|
|
}
|
|
} |