335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
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<DayKey, Array<{ start: Date; end: Date }>> = DAY_KEYS.reduce((acc, key) => {
|
|
acc[key] = []; return acc;
|
|
}, {} as Record<DayKey, Array<{ start: Date; end: Date}>>);
|
|
|
|
//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<DayKey, ShiftsHours> = DAY_KEYS.reduce((acc, key) => {
|
|
acc[key] = make_hours(); return acc;
|
|
}, {} as Record<DayKey, ShiftsHours>);
|
|
|
|
//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<DayKey, ExpensesAmount> = DAY_KEYS.reduce((acc, key) => {
|
|
acc[key] = make_amounts(); return acc;
|
|
}, {} as Record<DayKey, ExpensesAmount>);
|
|
|
|
const dayExpenseRows: Record<DayKey, { km: ExpenseRow[]; cash: ExpenseRow[] }> = DAY_KEYS.reduce((acc, key) => {
|
|
acc[key] = {km: [], cash: [] }; return acc;
|
|
}, {} as Record<DayKey, { km: ExpenseRow[], cash: ExpenseRow[] }>);
|
|
|
|
//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,
|
|
};
|
|
}
|
|
|
|
|