From 60aac39daa8adda2f7fb34dc7c2cd62095c8a5c8 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 22 Oct 2025 13:50:17 -0400 Subject: [PATCH] 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 --- .../helpers/timesheets-date-time-helpers.ts | 12 +- .../timesheet-get-overview.service.ts | 133 ++++++++++++------ 2 files changed, 104 insertions(+), 41 deletions(-) diff --git a/src/modules/timesheets/helpers/timesheets-date-time-helpers.ts b/src/modules/timesheets/helpers/timesheets-date-time-helpers.ts index 4a77edf..fab7730 100644 --- a/src/modules/timesheets/helpers/timesheets-date-time-helpers.ts +++ b/src/modules/timesheets/helpers/timesheets-date-time-helpers.ts @@ -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}`; -} \ No newline at end of file +} + diff --git a/src/modules/timesheets/services/timesheet-get-overview.service.ts b/src/modules/timesheets/services/timesheet-get-overview.service.ts index 9697c44..a3c3c91 100644 --- a/src/modules/timesheets/services/timesheet-get-overview.service.ts +++ b/src/modules/timesheets/services/timesheet-get-overview.service.ts @@ -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(); 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(); 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'; }