feat(timesheets): added an option to generate a second timesheet in case of a pay-period as no data in either the 1st or 2nd week

This commit is contained in:
Matthieu Haineault 2025-10-22 13:50:17 -04:00
parent 7fe2b6265a
commit 60aac39daa
2 changed files with 104 additions and 41 deletions

View File

@ -1,3 +1,11 @@
export function weekStartSunday(date_local: Date): Date {
const start = new Date(Date.UTC(date_local.getFullYear(), date_local.getMonth(), date_local.getDate()));
const dow = start.getDay();
start.setDate(start.getDate() - dow);
start.setHours(0, 0, 0, 0);
return start;
}
export const toDateFromString = ( date: Date | string):Date => {
const d = new Date(date);
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
@ -10,6 +18,7 @@ export const sevenDaysFrom = (date: Date | string): Date[] => {
return d;
});
}
export const toStringFromDate = (date: Date | string): string => {
const d = toDateFromString(date);
const year = d.getUTCFullYear();
@ -23,4 +32,5 @@ export const toHHmmFromDate = (input: Date | string): string => {
const hh = String(date.getUTCHours()).padStart(2, '0');
const mm = String(date.getUTCMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
}

View File

@ -19,32 +19,52 @@ type TotalExpenses = {
mileage: number;
};
const NUMBER_OF_TIMESHEETS_TO_RETURN = 2;
@Injectable()
export class GetTimesheetsOverviewService {
constructor(private readonly prisma: PrismaService) { }
//-----------------------------------------------------------------------------------
// GET TIMESHEETS FOR A SELECTED EMPLOYEE
//-----------------------------------------------------------------------------------
async getTimesheetsForEmployeeByPeriod(employee_id: number, pay_year: number, pay_period_no: number) {
//find period using year and period_no
const period = await this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no },
});
const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no } });
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${pay_period_no} not found`);
//loads the timesheets related to the fetched pay-period
const rows = await this.loadTimesheets({
employee_id,
start_date: { gte: period.period_start, lte: period.period_end },
});
const timesheet_range = { employee_id, start_date: { gte: period.period_start, lte: period.period_end } };
let rows = await this.loadTimesheets(timesheet_range);
//Normalized dates from pay-period
const normalized_start = toDateFromString(period.period_start);
const normalized_end = toDateFromString(period.period_end);
//creates empty timesheet to make sure to return desired amount of timesheet
for (let i = 0; i < NUMBER_OF_TIMESHEETS_TO_RETURN; i++) {
const week_start = new Date(normalized_start);
week_start.setUTCDate(week_start.getUTCDate() + i * 7);
if (week_start.getTime() > normalized_end.getTime()) break;
const exists = rows.some(
(row) => toDateFromString(row.start_date).getTime() === week_start.getTime()
);
if (!exists) await this.ensureTimesheet(employee_id, week_start);
}
rows = await this.loadTimesheets(timesheet_range);
//find user infos using the employee_id
const employee = await this.prisma.employees.findUnique({
where: { id: employee_id },
where: { id: employee_id },
include: { user: true },
});
if (!employee) throw new NotFoundException(`Employee #${employee_id} not found`);
//builds employee full name
const user = employee.user;
const user = employee.user;
const employee_fullname = `${user.first_name} ${user.last_name}`.trim();
//maps all timesheet's infos
@ -54,16 +74,17 @@ export class GetTimesheetsOverviewService {
//-----------------------------------------------------------------------------------
// MAPPERS & HELPERS
// MAPPERS & HELPERS
//-----------------------------------------------------------------------------------
//fetch timesheet's infos
private async loadTimesheets(where: any) {
return this.prisma.timesheets.findMany({
where,
include: {
employee: { include: { user: true } },
shift: { include: { bank_code: true } },
expense: { include: { bank_code: true, attachment_record: true } },
shift: { include: { bank_code: true } },
expense: { include: { bank_code: true, attachment_record: true } },
},
orderBy: { start_date: 'asc' },
});
@ -71,14 +92,14 @@ export class GetTimesheetsOverviewService {
private mapOneTimesheet(timesheet: any) {
//converts string to UTC date format
const start = toDateFromString(timesheet.start_date);
const start = toDateFromString(timesheet.start_date);
const day_dates = sevenDaysFrom(start);
//map of shifts by days
const shifts_by_date = new Map<string, any[]>();
for (const shift of timesheet.shift) {
const date = toStringFromDate(shift.date);
const arr = shifts_by_date.get(date) ?? [];
const arr = shifts_by_date.get(date) ?? [];
arr.push(shift);
shifts_by_date.set(date, arr);
}
@ -86,40 +107,40 @@ export class GetTimesheetsOverviewService {
const expenses_by_date = new Map<string, any[]>();
for (const expense of timesheet.expense) {
const date = toStringFromDate(expense.date);
const arr = expenses_by_date.get(date) ?? [];
const arr = expenses_by_date.get(date) ?? [];
arr.push(expense);
expenses_by_date.set(date, arr);
}
//weekly totals
const weekly_hours: TotalHours[] = [emptyHours()];
const weekly_hours: TotalHours[] = [emptyHours()];
const weekly_expenses: TotalExpenses[] = [emptyExpenses()];
//map of days
const days = day_dates.map((date) => {
const date_iso = toStringFromDate(date);
const shifts_source = shifts_by_date.get(date_iso) ?? [];
const shifts_source = shifts_by_date.get(date_iso) ?? [];
const expenses_source = expenses_by_date.get(date_iso) ?? [];
//inner map of shifts
const shifts = shifts_source.map((shift) => ({
date: toStringFromDate(shift.date),
start_time: toHHmmFromDate(shift.start_time),
end_time: toHHmmFromDate(shift.end_time),
type: shift.bank_code?.type ?? '',
is_remote: shift.is_remote ?? false,
date: toStringFromDate(shift.date),
start_time: toHHmmFromDate(shift.start_time),
end_time: toHHmmFromDate(shift.end_time),
type: shift.bank_code?.type ?? '',
is_remote: shift.is_remote ?? false,
is_approved: shift.is_approved ?? false,
shift_id: shift.id ?? null,
comment: shift.comment ?? null,
shift_id: shift.id ?? null,
comment: shift.comment ?? null,
}));
//inner map of expenses
const expenses = expenses_source.map((expense) => ({
date: toStringFromDate(expense.date),
amount: expense.amount ? Number(expense.amount) : undefined,
mileage: expense.mileage ? Number(expense.mileage) : undefined,
expense_id: expense.id ?? null,
attachment: expense.attachment_record ? String(expense.attachment_record.id) : undefined,
date: toStringFromDate(expense.date),
amount: expense.amount ? Number(expense.amount) : undefined,
mileage: expense.mileage ? Number(expense.mileage) : undefined,
expense_id: expense.id ?? null,
attachment: expense.attachment_record ? String(expense.attachment_record.id) : undefined,
is_approved: expense.is_approved ?? false,
comment: expense.comment ?? '',
comment: expense.comment ?? '',
supervisor_comment: expense.supervisor_comment,
}));
@ -131,7 +152,7 @@ export class GetTimesheetsOverviewService {
for (const shift of shifts_source) {
const hours = diffOfHours(shift.start_time, shift.end_time);
const subgroup = hoursSubGroupFromBankCode(shift.bank_code);
daily_hours[0][subgroup] += hours;
daily_hours[0][subgroup] += hours;
weekly_hours[0][subgroup] += hours;
}
@ -166,12 +187,44 @@ export class GetTimesheetsOverviewService {
});
return {
timesheet_id: timesheet.id,
is_approved: timesheet.is_approved ?? false,
is_approved: timesheet.is_approved ?? false,
days,
weekly_hours,
weekly_expenses,
};
}
private ensureTimesheet = async (employee_id: number, start_date: Date | string) => {
const start = toDateFromString(start_date);
let row = await this.prisma.timesheets.findFirst({
where: { employee_id, start_date: start },
include: {
employee: { include: { user: true } },
shift: { include: { bank_code: true } },
expense: { include: { bank_code: true, attachment_record: true } },
},
});
if (row) return row;
await this.prisma.timesheets.create({
data: {
employee_id,
start_date: start,
is_approved: false
},
});
row = await this.prisma.timesheets.findFirst({
where: { employee_id, start_date: start },
include: {
employee: { include: { user: true } },
shift: { include: { bank_code: true } },
expense: { include: { bank_code: true, attachment_record: true } },
},
});
return row!;
}
}
//filled array with default values
@ -190,20 +243,20 @@ const num = (value: any): number => { return value ? Number(value) : 0 };
// shift's subgroup types
const hoursSubGroupFromBankCode = (bank_code: any): keyof TotalHours => {
const type = bank_code.type;
if (type.includes('EVENING')) return 'evening';
if (type.includes('EVENING')) return 'evening';
if (type.includes('EMERGENCY')) return 'emergency';
if (type.includes('OVERTIME')) return 'overtime';
if (type.includes('VACATION')) return 'vacation';
if (type.includes('HOLIDAY')) return 'holiday';
if (type.includes('SICK')) return 'sick';
if (type.includes('OVERTIME')) return 'overtime';
if (type.includes('VACATION')) return 'vacation';
if (type.includes('HOLIDAY')) return 'holiday';
if (type.includes('SICK')) return 'sick';
return 'regular'
}
// expense's subgroup types
const expenseSubgroupFromBankCode = (bank_code: any): keyof TotalExpenses => {
const type = bank_code.type;
if (type.includes('MILEAGE')) return 'mileage';
if (type.includes('MILEAGE')) return 'mileage';
if (type.includes('PER_DIEM')) return 'per_diem';
if (type.includes('ON_CALL')) return 'on_call';
if (type.includes('ON_CALL')) return 'on_call';
return 'expenses';
}