From 301d5f2c9d2be5d73515a3f5dcb3b1d86ca6c36d Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 25 Aug 2025 08:07:48 -0400 Subject: [PATCH] feat(timesheet): replaced findAll by getPeriodByQuery with year, period_no and email query params. --- .../controllers/timesheets.controller.ts | 30 ++-- .../timesheets/dtos/timesheet-period.dto.ts | 45 ++++++ .../services/timesheets-query.service.ts | 89 ++++++++---- .../timesheets/utils/timesheet.helpers.ts | 134 ++++++++++++++++++ 4 files changed, 254 insertions(+), 44 deletions(-) create mode 100644 src/modules/timesheets/dtos/timesheet-period.dto.ts create mode 100644 src/modules/timesheets/utils/timesheet.helpers.ts diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index 8ee56ed..a5519ef 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; import { TimesheetsQueryService } from '../services/timesheets-query.service'; import { CreateTimesheetDto } from '../dtos/create-timesheet.dto'; import { Timesheets } from '@prisma/client'; @@ -8,6 +8,7 @@ import { Roles as RoleEnum } from '.prisma/client'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { TimesheetsCommandService } from '../services/timesheets-command.service'; import { SearchTimesheetDto } from '../dtos/search-timesheet.dto'; +import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; @ApiTags('Timesheets') @ApiBearerAuth('access-token') @@ -15,8 +16,8 @@ import { SearchTimesheetDto } from '../dtos/search-timesheet.dto'; @Controller('timesheets') export class TimesheetsController { constructor( - private readonly timesheetsService: TimesheetsQueryService, - private readonly timesheetsCommandService: TimesheetsCommandService, + private readonly timesheetsQuery: TimesheetsQueryService, + private readonly timesheetsCommand: TimesheetsCommandService, ) {} @Post() @@ -25,17 +26,18 @@ export class TimesheetsController { @ApiResponse({ status: 201, description: 'Timesheet created', type: CreateTimesheetDto }) @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) create(@Body() dto: CreateTimesheetDto): Promise { - return this.timesheetsService.create(dto); + return this.timesheetsQuery.create(dto); } @Get() //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find all timesheets' }) - @ApiResponse({ status: 201, description: 'List of timesheet found', type: CreateTimesheetDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of timesheets not found' }) - @UsePipes(new ValidationPipe({transform: true, whitelist: true })) - findAll(@Query() filters: SearchTimesheetDto): Promise { - return this.timesheetsService.findAll(filters); + async getPeriodByQuery( + @Query('year', ParseIntPipe ) year: number, + @Query('period_no', ParseIntPipe ) period_no: number, + @Query('email') email?: string + ): Promise { + if(!email || !email.trim()) throw new BadRequestException('Query param "email" is mandatory for this route.'); + return this.timesheetsQuery.findAll(year, period_no, email.trim()); } @Get(':id') @@ -44,7 +46,7 @@ export class TimesheetsController { @ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto }) @ApiResponse({ status: 400, description: 'Timesheet not found' }) findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.timesheetsService.findOne(id); + return this.timesheetsQuery.findOne(id); } @Patch(':id') @@ -56,7 +58,7 @@ export class TimesheetsController { @Param('id', ParseIntPipe) id:number, @Body() dto: UpdateTimesheetDto, ): Promise { - return this.timesheetsService.update(id, dto); + return this.timesheetsQuery.update(id, dto); } @Delete(':id') @@ -65,12 +67,12 @@ export class TimesheetsController { @ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto }) @ApiResponse({ status: 400, description: 'Timesheet not found' }) remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.timesheetsService.remove(id); + return this.timesheetsQuery.remove(id); } @Patch(':id/approval') //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { - return this.timesheetsCommandService.updateApproval(id, isApproved); + return this.timesheetsCommand.updateApproval(id, isApproved); } } diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts new file mode 100644 index 0000000..71a8e2f --- /dev/null +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -0,0 +1,45 @@ +export class ShiftDto { + start: string; + end : string; + is_approved: boolean; +} + +export class ExpenseDto { + amount: number; + is_approved: boolean; +} + +export type DayShiftsDto = ShiftDto[]; + +export class DayExpensesDto { + cash: ExpenseDto[] = []; + km : ExpenseDto[] = []; + [otherType:string]: ExpenseDto[] | any; //pour si on ajoute d'autre type de dépenses +} + +export class WeekDto { + is_approved: boolean; + shifts: { + sun: DayShiftsDto; + mon: DayShiftsDto; + tue: DayShiftsDto; + wed: DayShiftsDto; + thu: DayShiftsDto; + fri: DayShiftsDto; + sat: DayShiftsDto; + } + expenses: { + sun: DayExpensesDto; + mon: DayExpensesDto; + tue: DayExpensesDto; + wed: DayExpensesDto; + thu: DayExpensesDto; + fri: DayExpensesDto; + sat: DayExpensesDto; + } +} + +export class TimesheetPeriodDto { + week1: WeekDto; + week2: WeekDto; +} diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 39e71c8..0fe08ee 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -7,8 +7,12 @@ import { OvertimeService } from 'src/modules/business-logics/services/overtime.s import { computeHours } from 'src/common/utils/date-utils'; import { buildPrismaWhere } from 'src/common/shared/build-prisma-where'; import { SearchTimesheetDto } from '../dtos/search-timesheet.dto'; +import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; +import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; -const ROUND_TO = 5; +// deprecated (used with old findAll) const ROUND_TO = 5; +type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean }; +type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean }; @Injectable() export class TimesheetsQueryService { @@ -30,38 +34,63 @@ export class TimesheetsQueryService { }); } - async findAll(filters: SearchTimesheetDto): Promise { - const where = buildPrismaWhere(filters); - - //fetchs lists of shifts and expenses for a selected timesheet - const rawlist = await this.prisma.timesheets.findMany({ - where, include: { - shift: { include: {bank_code: true } }, - expense: { include: { bank_code: true } }, - employee: { include: { user : true } }, - }, + async findAll(year: number, period_no: number, email: string): Promise { + //finds the employee + const employee = await this.prisma.employees.findFirst({ + where: { user: { is: { email } } }, + select: { id: true }, }); + if(!employee) throw new NotFoundException(`no employee with email ${email} found`); - const detailedlist = await Promise.all( - rawlist.map(async timesheet => { - //detailed shifts - const detailedShifts = timesheet.shift.map(shift => { - const totalhours = computeHours(shift.start_time, shift.end_time, ROUND_TO); - const regularHours = Math.min(8, totalhours); - const dailyOvertime = this.overtime.getDailyOvertimeHours(shift.start_time, shift.end_time); - const payRegular = regularHours * shift.bank_code.modifier; - const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, shift.bank_code.modifier); - return { ...shift, totalhours, payRegular, payOvertime }; - }); - //calculate overtime weekly - const weeklyOvertimeHours = detailedShifts.length - ? await this.overtime.getWeeklyOvertimeHours( - timesheet.employee_id, - timesheet.shift[0].date): 0; - return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours }; + //finds the period + const period = await this.prisma.payPeriods.findFirst({ + where: { pay_year: year, pay_period_no: period_no }, + select: { period_start: true, period_end: true }, + }); + if(!period) throw new NotFoundException(`Period ${year}-${period_no} not found`); + + const from = toUTCDateOnly(period.period_start); + const to = endOfDayUTC(period.period_end); + + //collects data from shifts and expenses + const [ raw_shifts, raw_expenses] = await Promise.all([ + this.prisma.shifts.findMany({ + where: { + timesheet: { is: { employee_id: employee.id } }, + date: { gte: from, lte: to }, + }, + select: { date: true,start_time: true, end_time: true, is_approved: true }, + orderBy: [{date: 'asc'}, { start_time: 'asc' }], }), - ); - return detailedlist; + this.prisma.expenses.findMany({ + where: { + timesheet: { is: { employee_id: employee.id } }, + date: { gte: from, lte: to }, + }, + select: { date: true, amount: true, is_approved: true, bank_code: { + select: { type: true } }, + }, + orderBy: { date: 'asc' }, + }), + ]); + + //Shift data mapping + const shifts: ShiftRow[] = raw_shifts.map(shift => ({ + date: shift.date, + start_time: shift.start_time, + end_time: shift.end_time, + is_approved: shift.is_approved ?? true, + })); + + const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ + date: expense.date, + amount: typeof (expense.amount as any)?.toNumber() === 'function' ? + (expense.amount as any).toNumber() : Number(expense.amount), + type: expense.bank_code?.type ?? 'CASH', + is_approved: expense.is_approved ?? true, + })); + + return buildPeriod(period.period_start, period.period_end, shifts , expenses); } async findOne(id: number): Promise { diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts new file mode 100644 index 0000000..6dfef18 --- /dev/null +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -0,0 +1,134 @@ +import { DayExpensesDto, DayShiftsDto, 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 = 'sun'|'mon'|'tue'|'wed'|'thu'|'fri'|'sat'; + +//DB line types +type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean }; +type ExpenseRow = {date: Date; amount: number; type: string; is_approved?: boolean }; + +export function dayKeyFromDate(date: Date, useUTC = true): DayKey { + const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday + return DAY_KEYS[index]; +} + +const MS_PER_DAY = 86_400_000; + +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 dayIndex(week_start: Date, date: Date): 1|2|3|4|5|6|7 { + const diff = Math.floor((toUTCDateOnly(date).getTime() - toUTCDateOnly(week_start).getTime())/ MS_PER_DAY); + const index = Math.min(6, Math.max(0, diff)) + 1; + return index as 1|2|3|4|5|6|7; +} + +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; +} + +export function makeEmptyDayShifts(): DayShiftsDto { return []; } + +export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; } + +export function makeEmptyWeek(): WeekDto { + return { + is_approved: true, + shifts: { + sun: makeEmptyDayShifts(), + mon: makeEmptyDayShifts(), + tue: makeEmptyDayShifts(), + wed: makeEmptyDayShifts(), + thu: makeEmptyDayShifts(), + fri: makeEmptyDayShifts(), + sat: makeEmptyDayShifts(), + }, + expenses: { + sun: makeEmptyDayExpenses(), + mon: makeEmptyDayExpenses(), + tue: makeEmptyDayExpenses(), + wed: makeEmptyDayExpenses(), + thu: makeEmptyDayExpenses(), + fri: makeEmptyDayExpenses(), + sat: makeEmptyDayExpenses(), + }, + }; +} + +export function makeEmptyPeriod(): TimesheetPeriodDto { + return { week1: makeEmptyWeek(), week2: makeEmptyWeek() }; +} + +//needs ajusting according to DB's data for expenses types +export function normalizeExpenseBucket(db_type: string): 'km' | 'cash' { + const type = db_type.trim().toUpperCase(); + if(type.includes('KM') || type.includes('MILEAGE')) return 'km'; + return 'cash'; +} + +export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): WeekDto { + const week = makeEmptyWeek(); + let all_approved = true; + + //Shifts mapped and filtered by dates + 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].push({ + start: toTimeString(shift.start_time), + end : toTimeString(shift.end_time), + is_approved: shift.is_approved ?? true, + } as ShiftDto); + all_approved = all_approved && (shift.is_approved ?? true); + } + + //Expenses mapped and filtered by dates + 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 bucket = normalizeExpenseBucket(expense.type); + if (!Array.isArray(week.expenses[key][bucket])) week.expenses[key][bucket] = []; + week.expenses[key][bucket].push({ + amount: round2(expense.amount), + is_approved: expense.is_approved ?? true, + }); + all_approved = all_approved && (expense.is_approved ?? true); + } + week.is_approved = all_approved; + return week; +} + +export function buildPeriod( period_start: Date, period_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): 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 { + week1: buildWeek(week1_start, week1_end, shifts, expenses), + week2: buildWeek(week2_start, week2_end, shifts, expenses), + }; +} \ No newline at end of file