import { toDateString } from "src/modules/pay-periods/utils/pay-year.util"; import { DayExpensesDto, DetailedShifts, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto"; //makes the strings indexes for arrays export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; export type DayKey = typeof DAY_KEYS[number]; export function dayKeyFromDate(date: Date, useUTC = true): DayKey { const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday return DAY_KEYS[index]; } export const toHHmm = (date: Date) => date.toISOString().slice(11, 16); //create shifts within timesheet's week - employee overview functions export function parseISODate(iso: string): Date { const [ y, m, d ] = iso.split('-').map(Number); return new Date(y, (m ?? 1) - 1, d ?? 1); } export function parseHHmm(t: string): Date { const [ hh, mm ] = t.split(':').map(Number); return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0); } //Date & Format const MS_PER_DAY = 86_400_000; const MS_PER_HOUR = 3_600_000; // Types const SHIFT_TYPES = { REGULAR: 'REGULAR', EVENING: 'EVENING', OVERTIME: 'OVERTIME', EMERGENCY: 'EMERGENCY', HOLIDAY: 'HOLIDAY', VACATION: 'VACATION', SICK: 'SICK', } as const; const EXPENSE_TYPES = { MILEAGE: 'MILEAGE', EXPENSE: 'EXPENSES', PER_DIEM: 'PER_DIEM', COMMISSION: 'COMMISSION', PRIME_DISPO: 'PRIME_DISPO', } as const; //DB line types export type ShiftRow = { date: Date; start_time: Date; end_time: Date; comment: string; is_approved?: boolean; is_remote: boolean; type: string }; export type ExpenseRow = { date: Date; amount: number; comment: string; supervisor_comment: string; is_approved?: boolean; type: string; }; //helper functions export function toUTCDateOnly(date: Date | string): Date { const d = new Date(date); return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); } export function addDays(date:Date, days: number): Date { return new Date(date.getTime() + days * MS_PER_DAY); } export function endOfDayUTC(date: Date | string): Date { const d = toUTCDateOnly(date); return new Date(d.getTime() + MS_PER_DAY - 1); } export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): boolean { const time = date.getTime(); return time >= start.getTime() && time <= end_inclusive.getTime(); } export function toTimeString(date: Date): string { const hours = String(date.getUTCHours()).padStart(2,'0'); const minutes = String(date.getUTCMinutes()).padStart(2,'0'); return `${hours}:${minutes}`; } export function round2(num: number) { return Math.round(num * 100) / 100; } function shortDate(date:Date): string { const mm = String(date.getUTCMonth()+1).padStart(2,'0'); const dd = String(date.getUTCDate()).padStart(2,'0'); return `${mm}/${dd}`; } // Factories export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; } export function makeEmptyWeek(week_start: Date): WeekDto { const make_empty_shifts = (offset: number): DetailedShifts => ({ shifts: [], regular_hours: 0, evening_hours: 0, emergency_hours: 0, overtime_hours: 0, comment: '', short_date: shortDate(addDays(week_start, offset)), break_durations: 0, }); return { is_approved: true, shifts: { sun: make_empty_shifts(0), mon: make_empty_shifts(1), tue: make_empty_shifts(2), wed: make_empty_shifts(3), thu: make_empty_shifts(4), fri: make_empty_shifts(5), sat: make_empty_shifts(6), }, expenses: { sun: makeEmptyDayExpenses(), mon: makeEmptyDayExpenses(), tue: makeEmptyDayExpenses(), wed: makeEmptyDayExpenses(), thu: makeEmptyDayExpenses(), fri: makeEmptyDayExpenses(), sat: makeEmptyDayExpenses(), }, }; } export function makeEmptyPeriod(): TimesheetPeriodDto { return { weeks: [ makeEmptyWeek(new Date()), makeEmptyWeek(new Date())], employee_full_name: " " }; } export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[], ): WeekDto { const week = makeEmptyWeek(week_start); let all_approved = true; //breaks const day_times: Record> = DAY_KEYS.reduce((acc, key) => { acc[key] = []; return acc; }, {} as Record>); //shifts's hour by type type ShiftsHours = { regular: number; evening: number; overtime: number; emergency: number; sick: number; vacation: number; holiday: number; }; const make_hours = (): ShiftsHours => ({ regular: 0, evening: 0, overtime: 0, emergency: 0, sick: 0, vacation: 0, holiday: 0 }); const day_hours: Record = DAY_KEYS.reduce((acc, key) => { acc[key] = make_hours(); return acc; }, {} as Record); //expenses's amount by type type ExpensesAmount = { mileage: number; expense: number; per_diem: number; commission: number; prime_dispo: number }; const make_amounts = (): ExpensesAmount => ({ mileage: 0, expense: 0, per_diem: 0, commission: 0, prime_dispo: 0 }); const day_amounts: Record = DAY_KEYS.reduce((acc, key) => { acc[key] = make_amounts(); return acc; }, {} as Record); const dayExpenseRows: Record = DAY_KEYS.reduce((acc, key) => { acc[key] = {km: [], cash: [] }; return acc; }, {} as Record); //regroup hours per type of shifts const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end)); for (const shift of week_shifts) { const key = dayKeyFromDate(shift.date, true); week.shifts[key].shifts.push({ date: toDateString(shift.date), type: shift.type, start_time: toTimeString(shift.start_time), end_time: toTimeString(shift.end_time), comment: shift.comment, is_approved: shift.is_approved ?? true, is_remote: shift.is_remote, } as ShiftDto); day_times[key].push({ start: shift.start_time, end: shift.end_time}); const duration = Math.max(0, (shift.end_time.getTime() - shift.start_time.getTime())/ MS_PER_HOUR); const type = (shift.type || '').toUpperCase(); if ( type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration; else if( type === SHIFT_TYPES.EVENING) day_hours[key].evening += duration; else if( type === SHIFT_TYPES.EMERGENCY) day_hours[key].emergency += duration; else if( type === SHIFT_TYPES.OVERTIME) day_hours[key].overtime += duration; else if( type === SHIFT_TYPES.SICK) day_hours[key].sick += duration; else if( type === SHIFT_TYPES.VACATION) day_hours[key].vacation += duration; else if( type === SHIFT_TYPES.HOLIDAY) day_hours[key].holiday += duration; all_approved = all_approved && (shift.is_approved ?? true ); } //regroupe amounts to type of expenses const week_expenses = expenses.filter(expense => isBetweenUTC(expense.date, week_start, week_end)); for (const expense of week_expenses) { const key = dayKeyFromDate(expense.date, true); const type = (expense.type || '').toUpperCase(); if (type === EXPENSE_TYPES.MILEAGE) { day_amounts[key].mileage += expense.amount; dayExpenseRows[key].km.push(expense); } else if (type === EXPENSE_TYPES.EXPENSE) { day_amounts[key].expense += expense.amount; dayExpenseRows[key].cash.push(expense) } else if (type === EXPENSE_TYPES.PER_DIEM) { day_amounts[key].per_diem += expense.amount; dayExpenseRows[key].cash.push(expense) } else if (type === EXPENSE_TYPES.COMMISSION) { day_amounts[key].commission += expense.amount; dayExpenseRows[key].cash.push(expense) } else if (type === EXPENSE_TYPES.PRIME_DISPO) { day_amounts[key].prime_dispo += expense.amount; dayExpenseRows[key].cash.push(expense) } all_approved = all_approved && (expense.is_approved ?? true ); } for (const key of DAY_KEYS) { //return exposed dto data week.shifts[key].regular_hours = round2(day_hours[key].regular); week.shifts[key].evening_hours = round2(day_hours[key].evening); week.shifts[key].overtime_hours = round2(day_hours[key].overtime); week.shifts[key].emergency_hours = round2(day_hours[key].emergency); //calculate gaps between shifts const times = day_times[key].sort((a,b) => a.start.getTime() - b.start.getTime()); let gaps = 0; for (let i = 1; i < times.length; i++) { const gap = (times[i].start.getTime() - times[i - 1].end.getTime()) / MS_PER_HOUR; if(gap > 0) gaps += gap; } week.shifts[key].break_durations = round2(gaps); //daily totals const totals = day_amounts[key]; const total_mileage = totals.mileage; const total_expense = totals.expense + totals.per_diem + totals.commission + totals.prime_dispo; //pushing mileage rows for(const row of dayExpenseRows[key].km) { week.expenses[key].km.push({ amount: round2(row.amount), comment: row.comment, supervisor_comment: row.supervisor_comment, total_mileage: round2(total_mileage), total_expense: round2(total_expense), is_approved: row.is_approved ?? true, }); } //pushing expense rows for(const row of dayExpenseRows[key].cash) { week.expenses[key].cash.push({ amount: round2(row.amount), comment: row.comment, supervisor_comment: row.supervisor_comment, total_mileage: round2(total_mileage), total_expense: round2(total_expense), is_approved: row.is_approved ?? true, }); } } week.is_approved = all_approved; return week; } export function buildPeriod( period_start: Date, period_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[], employee_full_name: string, ): TimesheetPeriodDto { const week1_start = toUTCDateOnly(period_start); const week1_end = endOfDayUTC(addDays(week1_start, 6)); const week2_start = toUTCDateOnly(addDays(week1_start, 7)); const week2_end = endOfDayUTC(period_end); return { weeks: [ buildWeek(week1_start, week1_end, shifts, expenses), buildWeek(week2_start, week2_end, shifts, expenses), ], employee_full_name, }; }