new temporal folder to test
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user