From 7fe2b6265ae214e0c1205c0fe5ec72228f8f92ab Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 22 Oct 2025 12:42:32 -0400 Subject: [PATCH 1/2] refactor(expenses): init setup to use session infos --- .../controllers/expense.controller.ts | 17 +++++-- src/modules/expenses/dtos/expense.dto.ts | 14 ++++++ src/modules/expenses/dtos/get-expense.dto.ts | 11 +++++ .../expenses/dtos/update-expense.dto.ts | 6 +++ .../helpers/expenses-date-time-helpers.ts | 3 ++ .../services/expense-upsert.service.ts | 46 +++++++++++++++++++ .../shifts/services/shifts-upsert.service.ts | 2 +- 7 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 src/modules/expenses/helpers/expenses-date-time-helpers.ts create mode 100644 src/modules/expenses/services/expense-upsert.service.ts diff --git a/src/modules/expenses/controllers/expense.controller.ts b/src/modules/expenses/controllers/expense.controller.ts index 2359b7a..bb37634 100644 --- a/src/modules/expenses/controllers/expense.controller.ts +++ b/src/modules/expenses/controllers/expense.controller.ts @@ -1,10 +1,21 @@ -import { Controller } from "@nestjs/common"; +import { Body, Controller, Param, ParseIntPipe, Post } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; +import { ExpenseDto } from "../dtos/expense.dto"; +import { CreateResult, ExpenseUpsertService } from "../services/expense-upsert.service"; @Controller('expense') export class ExpenseController { - constructor(private readonly prisma: PrismaService){} + constructor( + private readonly prisma: PrismaService, + private readonly upsert_service: ExpenseUpsertService, + ){} - + + // @Post(':timesheet_id') + // create( + // @Param('timesheet_id', ParseIntPipe) timesheet_id: number, + // @Body() dto: ExpenseDto): Promise{ + // return this.upsert_service.createExpense(timesheet_id, dto); + // } } \ No newline at end of file diff --git a/src/modules/expenses/dtos/expense.dto.ts b/src/modules/expenses/dtos/expense.dto.ts index e69de29..51cc174 100644 --- a/src/modules/expenses/dtos/expense.dto.ts +++ b/src/modules/expenses/dtos/expense.dto.ts @@ -0,0 +1,14 @@ +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator"; + +export class ExpenseDto { + @IsInt() bank_code_id!: number; + @IsInt() timesheet_id!: number; + @IsString() @IsOptional() attachment?: string; + + @IsString() date!: string; + @IsInt() @IsOptional() amount?: number; + @IsInt() @IsOptional() mileage?: number; + @IsString() @MaxLength(280) comment!: string; + @IsBoolean() is_approved!: boolean; + @IsString() @MaxLength(280) @IsOptional() supervisor_comment?: string +} \ No newline at end of file diff --git a/src/modules/expenses/dtos/get-expense.dto.ts b/src/modules/expenses/dtos/get-expense.dto.ts index e69de29..6c3056e 100644 --- a/src/modules/expenses/dtos/get-expense.dto.ts +++ b/src/modules/expenses/dtos/get-expense.dto.ts @@ -0,0 +1,11 @@ +export class GetExpenseDto { + timesheet_id: number; + bank_code_id: number; + attachment?: string; + date: string; + comment: string; + mileage?: number; + amount?: number; + supervisor_comment?: string; + is_approved: boolean; +} \ No newline at end of file diff --git a/src/modules/expenses/dtos/update-expense.dto.ts b/src/modules/expenses/dtos/update-expense.dto.ts index e69de29..fc709bb 100644 --- a/src/modules/expenses/dtos/update-expense.dto.ts +++ b/src/modules/expenses/dtos/update-expense.dto.ts @@ -0,0 +1,6 @@ +import { OmitType, PartialType } from "@nestjs/swagger"; +import { ExpenseDto } from "./expense.dto"; + +export class updateExpenseDto extends PartialType ( + OmitType(ExpenseDto, ['is_approved', 'timesheet_id'] as const) +){} \ No newline at end of file diff --git a/src/modules/expenses/helpers/expenses-date-time-helpers.ts b/src/modules/expenses/helpers/expenses-date-time-helpers.ts new file mode 100644 index 0000000..ce14e69 --- /dev/null +++ b/src/modules/expenses/helpers/expenses-date-time-helpers.ts @@ -0,0 +1,3 @@ +export const toDateFromString = (ymd: string): Date => { + return new Date(`${ymd}T00:00:00:000Z`); +} \ No newline at end of file diff --git a/src/modules/expenses/services/expense-upsert.service.ts b/src/modules/expenses/services/expense-upsert.service.ts new file mode 100644 index 0000000..5e47a1e --- /dev/null +++ b/src/modules/expenses/services/expense-upsert.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "src/prisma/prisma.service"; +import { GetExpenseDto } from "../dtos/get-expense.dto"; +import { updateExpenseDto } from "../dtos/update-expense.dto"; +import { ExpenseDto } from "../dtos/expense.dto"; +import { toDateFromString } from "../helpers/expenses-date-time-helpers"; + +type Normalized = { date: Date; comment: string; supervisor_comment: string; }; + +export type CreateResult = { ok: true; data: GetExpenseDto } | { ok: false; error: any }; +export type UpdatePayload = { id: number; dto: updateExpenseDto }; +export type UpdateResult = { ok: true; id: number; data: GetExpenseDto } | { ok: false; id: number; error: any }; +export type DeleteResult = { ok: true; id: number; } | { ok: false; id: number; error: any }; + +type NormedOk = { dto: GetExpenseDto; normed: Normalized }; +type NormedErr = { error: any }; + +@Injectable() +export class ExpenseUpsertService { + constructor(private readonly prisma: PrismaService){} + + //_________________________________________________________________ + // CREATE + //_________________________________________________________________ + //normalized frontend data to match DB + async createExpense(timesheet_id: number, dto: ExpenseDto){ + const normed_expense = this.normalizeExpenseDto(dto) + + + } + + //_________________________________________________________________ + // LOCAL HELPERS + //_________________________________________________________________ + private normalizeExpenseDto(dto: ExpenseDto): Normalized { + const date = toDateFromString(dto.date); + const comment = this.truncate280(dto.comment); + const supervisor_comment = this.truncate280(dto.supervisor_comment? dto.supervisor_comment : ''); + return { date, comment, supervisor_comment }; + } + + //makes sure that a string cannot exceed 280 chars + private truncate280 = (input: string): string => { + return input.length > 280 ? input.slice(0, 280) : input; + } +} \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-upsert.service.ts b/src/modules/shifts/services/shifts-upsert.service.ts index 9ed8457..e63380b 100644 --- a/src/modules/shifts/services/shifts-upsert.service.ts +++ b/src/modules/shifts/services/shifts-upsert.service.ts @@ -333,7 +333,7 @@ export class ShiftsUpsertService { //_________________________________________________________________ // DELETE //_________________________________________________________________ - //finds shift using shit_ids + //finds shifts using shit_ids //recalc overtime shifts after delete //blocs deletion if approved async deleteShift(shift_id: number) { From 60aac39daa8adda2f7fb34dc7c2cd62095c8a5c8 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 22 Oct 2025 13:50:17 -0400 Subject: [PATCH 2/2] 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'; }