/** * 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" }; } }