From 11f6cf204971f6ac9ed1462cb62e8374c6939414 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 21 Oct 2025 15:59:33 -0400 Subject: [PATCH] refactor(timesheets): deep refactor of the timesheet module and small corrections of the shift module. --- .../shifts/controllers/shift.controller.ts | 17 +- .../shifts/services/shifts-upsert.service.ts | 57 ++---- .../controllers/timesheet.controller.ts | 14 +- src/modules/timesheets/dtos/timesheet.dto.ts | 7 +- .../helpers/timesheets-date-time-helpers.ts | 26 +++ .../timesheet-get-overview.service.ts | 188 +++++++++++++++++- src/modules/timesheets/timesheets.module.ts | 32 ++- .../timesheet.helpers.ts | 0 .../timesheet.mappers.ts | 0 .../timesheet.selectors.ts | 0 .../timesheet.types.ts | 0 .../timesheet.utils.ts | 0 .../timesheets.controller.ts | 2 +- 13 files changed, 254 insertions(+), 89 deletions(-) create mode 100644 src/modules/timesheets/helpers/timesheets-date-time-helpers.ts rename src/modules/timesheets/~misc_deprecated-files/{utils-helpers-others => }/timesheet.helpers.ts (100%) rename src/modules/timesheets/~misc_deprecated-files/{utils-helpers-others => }/timesheet.mappers.ts (100%) rename src/modules/timesheets/~misc_deprecated-files/{utils-helpers-others => }/timesheet.selectors.ts (100%) rename src/modules/timesheets/~misc_deprecated-files/{utils-helpers-others => }/timesheet.types.ts (100%) rename src/modules/timesheets/~misc_deprecated-files/{utils-helpers-others => }/timesheet.utils.ts (100%) diff --git a/src/modules/shifts/controllers/shift.controller.ts b/src/modules/shifts/controllers/shift.controller.ts index 3c5eea7..bffa9cb 100644 --- a/src/modules/shifts/controllers/shift.controller.ts +++ b/src/modules/shifts/controllers/shift.controller.ts @@ -1,5 +1,5 @@ import { BadRequestException, Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query } from "@nestjs/common"; -import { CreateResult, DeleteResult, ShiftsUpsertService, ShiftWithOvertimeDto, UpdateResult } from "../services/shifts-upsert.service"; +import { CreateResult, ShiftsUpsertService, UpdateResult } from "../services/shifts-upsert.service"; import { updateShiftDto } from "../dtos/update-shift.dto"; import { ShiftDto } from "../dtos/shift.dto"; import { ShiftsGetService } from "../services/shifts-get.service"; @@ -12,10 +12,10 @@ export class ShiftController { private readonly get_service: ShiftsGetService ){} - @Get("shifts") + @Get() async getShiftsByIds( @Query("shift_ids") shift_ids: string) { - const parsed = shift_ids.split(", ").map(value => Number(value)).filter(Number.isFinite); + const parsed = shift_ids.split(/,\s*/).map(value => Number(value)).filter(Number.isFinite); return this.get_service.getShiftByShiftId(parsed); } @@ -42,15 +42,8 @@ export class ShiftController { } @Delete(':shift_id') - removeBatch( - @Body() body: { ids: number[] }): Promise { - const ids = Array.isArray(body?.ids) - ? body.ids.filter((value) => Number.isFinite(value)) - : []; - if( ids.length === 0) { - throw new BadRequestException('Body is missing or invalid (delete shifts)'); - } - return this.upsert_service.deleteShifts(ids); + remove(@Param('shift_id') shift_id: number ) { + return this.upsert_service.deleteShift(shift_id); } } \ 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 6eb0f1a..9ed8457 100644 --- a/src/modules/shifts/services/shifts-upsert.service.ts +++ b/src/modules/shifts/services/shifts-upsert.service.ts @@ -39,11 +39,6 @@ export class ShiftsUpsertService { //checks for overlaping shifts //create new shifts //calculate overtime - async createShift(timesheet_id: number, dto: ShiftDto): Promise { - const [result] = await this.createShifts(timesheet_id, [dto]); - if (!result.ok) throw result.error; - return result.data; - } async createShifts(timesheet_id: number, dtos: ShiftDto[]): Promise { if (!Array.isArray(dtos) || dtos.length === 0) return []; @@ -179,13 +174,6 @@ export class ShiftsUpsertService { // update shifts in DB // recalculate overtime after update // return an updated version to display - - async updateShift(shift_id: number, dto: updateShiftDto): Promise { - const [results] = await this.updateShifts([{ id: shift_id, dto }]); - if (!results.ok) throw results.error; - return results.data; - } - async updateShifts(updates: UpdatePayload[]): Promise { if (!Array.isArray(updates) || updates.length === 0) return []; @@ -345,43 +333,24 @@ export class ShiftsUpsertService { //_________________________________________________________________ // DELETE //_________________________________________________________________ - //finds shifts using shit_ids + //finds shift using shit_ids //recalc overtime shifts after delete //blocs deletion if approved - async deleteShift(shift_id: number) { - const [result] = await this.deleteShifts([shift_id]); - if (!result.ok) throw result.error; - return { success: true, overtime: result.overtime }; - } - async deleteShifts(shift_ids: number[]): Promise { - if (!Array.isArray(shift_ids) || shift_ids.length === 0) return []; - - return this.prisma.$transaction(async (tx) => { - const rows = await tx.shifts.findMany({ - where: { id: { in: shift_ids } }, - select: { id: true, date: true, timesheet_id: true, is_approved: true }, + async deleteShift(shift_id: number) { + return await this.prisma.$transaction(async (tx) =>{ + const shift = await tx.shifts.findUnique({ + where: { id: shift_id }, + select: { id: true, date: true, timesheet_id: true }, }); - const byId = new Map(rows.map(row => [row.id, row])); + if(!shift) throw new NotFoundException(`Shift with id #${shift_id} not found`); - for (const id of shift_ids) { - const row = byId.get(id); - if (!row) { - return shift_ids.map(existing_shift_id => - existing_shift_id === id - ? ({ ok: false, id: existing_shift_id, error: new NotFoundException(`Shift with id #${existing_shift_id} not found`) } as DeleteResult) - : ({ ok: false, id: existing_shift_id, error: new BadRequestException('Batch aborted due to missing shift') }) - ); - } - } + await tx.shifts.delete({ where: { id: shift_id } }); - const results: DeleteResult[] = []; - for (const id of shift_ids) { - const row = byId.get(id)!; - await tx.shifts.delete({ where: { id } }); - const summary = await this.overtime.getWeekOvertimeSummary(row.timesheet_id, row.date, tx); - results.push({ ok: true, id, overtime: summary }); - } - return results; + const summary = await this.overtime.getWeekOvertimeSummary( shift.timesheet_id, shift.date, tx); + return { + success: true, + overtime: summary + }; }); } diff --git a/src/modules/timesheets/controllers/timesheet.controller.ts b/src/modules/timesheets/controllers/timesheet.controller.ts index 8289902..83f28c0 100644 --- a/src/modules/timesheets/controllers/timesheet.controller.ts +++ b/src/modules/timesheets/controllers/timesheet.controller.ts @@ -1,8 +1,16 @@ -import { Controller} from "@nestjs/common"; +import { GetTimesheetsOverviewService } from "../services/timesheet-get-overview.service"; +import { Controller, Get, Query} from "@nestjs/common"; -@Controller('timesheet') +@Controller('timesheets') export class TimesheetController { - constructor(){} + constructor(private readonly timesheetOverview: GetTimesheetsOverviewService){} + + @Get() + async getTimesheetByIds( + @Query('timesheet_ids') timesheet_ids: string ) { + const parsed = timesheet_ids.split(/,\s*/).map(value => Number(value)).filter(Number.isFinite); + return this.timesheetOverview.getTimesheetsByIds(parsed); + } diff --git a/src/modules/timesheets/dtos/timesheet.dto.ts b/src/modules/timesheets/dtos/timesheet.dto.ts index 8b51f30..3d30c8b 100644 --- a/src/modules/timesheets/dtos/timesheet.dto.ts +++ b/src/modules/timesheets/dtos/timesheet.dto.ts @@ -1,8 +1,3 @@ - - - - - export class Timesheets { employee_fullname: string; timesheets: Timesheet[]; @@ -35,7 +30,7 @@ export class TotalHours { } export class TotalExpenses { expenses: number; - perd_diem: number; + per_diem: number; on_call: number; mileage: number; } diff --git a/src/modules/timesheets/helpers/timesheets-date-time-helpers.ts b/src/modules/timesheets/helpers/timesheets-date-time-helpers.ts new file mode 100644 index 0000000..4a77edf --- /dev/null +++ b/src/modules/timesheets/helpers/timesheets-date-time-helpers.ts @@ -0,0 +1,26 @@ +export const toDateFromString = ( date: Date | string):Date => { + const d = new Date(date); + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); +} + +export const sevenDaysFrom = (date: Date | string): Date[] => { + return Array.from({length: 7 }, (_,i) => { + const d = new Date(date); + d.setUTCDate(d.getUTCDate() + i ); + return d; + }); +} +export const toStringFromDate = (date: Date | string): string => { + const d = toDateFromString(date); + const year = d.getUTCFullYear(); + const month = String(d.getUTCMonth() + 1).padStart(2, '0'); + const day = String(d.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${d}`; +} + +export const toHHmmFromDate = (input: Date | string): string => { + const date = new Date(input); + 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 dba5876..8cf22a8 100644 --- a/src/modules/timesheets/services/timesheet-get-overview.service.ts +++ b/src/modules/timesheets/services/timesheet-get-overview.service.ts @@ -1,10 +1,192 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; +import { sevenDaysFrom, toDateFromString, toHHmmFromDate, toStringFromDate } from "../helpers/timesheets-date-time-helpers"; +type TotalHours = { + regular: number; + evening: number; + emergency: number; + overtime: number; + vacation: number; + holiday: number; + sick: number; +}; + +type TotalExpenses = { + expenses: number; + per_diem: number; + on_call: number; + mileage: number; +}; @Injectable() export class GetTimesheetsOverviewService { - constructor(private readonly prisma: PrismaService){} + constructor(private readonly prisma: PrismaService) { } + + async getTimesheetsByIds(timesheet_ids: number[]) { + if (!Array.isArray(timesheet_ids) || timesheet_ids.length === 0) throw new NotFoundException(`Timesheet_ids are missing`); + + //fetch all needed data using timesheet ids + const rows = await this.prisma.timesheets.findMany({ + where: { id: { in: timesheet_ids } }, + include: { + employee: { include: { user: true } }, + shift: { include: { bank_code: true } }, + expense: { include: { bank_code: true, attachment_record: true } }, + }, + orderBy: { start_date: 'asc' }, + }); + + if (rows.length === 0) throw new NotFoundException('Timesheet(s) not found'); + + //build full name + const user = rows[0].employee.user; + const employee_fullname = `${user.first_name} ${user.last_name}`.trim(); + + const timesheets = rows.map((timesheet) => this.mapOneTimesheet(timesheet)); + return { employee_fullname, timesheets }; + } -} \ No newline at end of file + //----------------------------------------------------------------------------------- + // MAPPERS & HELPERS + //----------------------------------------------------------------------------------- + private mapOneTimesheet(timesheet: any) { + //converts string to UTC date format + 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) ?? []; + arr.push(shift); + shifts_by_date.set(date, arr); + } + //map of expenses by days + const expenses_by_date = new Map(); + for (const expense of timesheet.expense) { + const date = toStringFromDate(expense.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_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 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, + is_approved: shift.is_approved ?? false, + 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, + is_approved: expense.is_approved ?? false, + comment: expense.comment ?? '', + supervisor_comment: expense.supervisor_comment, + })); + + //daily totals + const daily_hours = [emptyHours()]; + const daily_expenses = [emptyExpenses()]; + + //totals by shift types + 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; + weekly_hours[0][subgroup] += hours; + } + + //totals by expense types + for (const expense of expenses_source) { + const subgroup = expenseSubgroupFromBankCode(expense.bank_code); + if (subgroup === 'mileage') { + const mileage = num(expense.mileage); + daily_expenses[0].mileage += mileage; + weekly_expenses[0].mileage += mileage; + } else if (subgroup === 'per_diem') { + const amount = num(expense.amount); + daily_expenses[0].per_diem += amount; + weekly_expenses[0].per_diem += amount; + } else if (subgroup === 'on_call') { + const amount = num(expense.amount); + daily_expenses[0].on_call += amount; + weekly_expenses[0].on_call += amount; + } else { + const amount = num(expense.amount); + daily_expenses[0].expenses += amount; + weekly_expenses[0].expenses += amount; + } + } + return { + date: date_iso, + shifts, + expenses, + daily_hours, + daily_expenses, + }; + }); + return { + timesheet_id: timesheet.id, + is_approved: timesheet.is_approved ?? false, + days, + weekly_hours, + weekly_expenses, + }; + } +} + +const emptyHours = (): TotalHours => { + return { regular: 0, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0 }; +} +const emptyExpenses = (): TotalExpenses => { + return { expenses: 0, per_diem: 0, on_call: 0, mileage: 0 }; +} + +const diffOfHours = (a: Date, b: Date): number => { + const ms = new Date(b).getTime() - new Date(a).getTime(); + return Math.max(0, Math.round((ms / 36e5) * 1000) / 1000); +} + +const num = (value: any): number => { + return value ? Number(value) : 0; +} + +const hoursSubGroupFromBankCode = (bank_code: any): keyof TotalHours => { + const type = bank_code.type; + 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'; + return 'regular' +} + +const expenseSubgroupFromBankCode = (bank_code: any): keyof TotalExpenses => { + const type = bank_code.type; + if (type.includes('MILEAGE')) return 'mileage'; + if (type.includes('PER_DIEM')) return 'per_diem'; + if (type.includes('ON_CALL')) return 'on_call'; + return 'expenses'; +} diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index e49b0b2..32d2c52 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -1,32 +1,24 @@ -import { TimesheetsController } from './~misc_deprecated-files/timesheets.controller'; -import { TimesheetsQueryService } from './~misc_deprecated-files/timesheets-query.service'; -import { TimesheetArchiveService } from './services/timesheet-archive.service'; -import { TimesheetsCommandService } from './~misc_deprecated-files/timesheets-command.service'; -import { ExpensesCommandService } from '../expenses/services/expenses-command.service'; -import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; -import { SharedModule } from '../shared/shared.module'; -import { Module } from '@nestjs/common'; -import { ShiftsGetService } from '../shifts/services/shifts-get.service'; -import { TimesheetSelectorsService } from './~misc_deprecated-files/utils-helpers-others/timesheet.selectors'; +import { GetTimesheetsOverviewService } from './services/timesheet-get-overview.service'; +import { TimesheetArchiveService } from './services/timesheet-archive.service'; +import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; +import { TimesheetController } from './controllers/timesheet.controller'; +import { SharedModule } from '../shared/shared.module'; +import { ShiftsModule } from '../shifts/shifts.module'; +import { Module } from '@nestjs/common'; @Module({ imports: [ BusinessLogicsModule, SharedModule, - ShiftsGetService, + ShiftsModule, ], - controllers: [TimesheetsController], + controllers: [TimesheetController], providers: [ - TimesheetsQueryService, - TimesheetsCommandService, - ExpensesCommandService, - TimesheetArchiveService, - TimesheetSelectorsService, + TimesheetArchiveService, + GetTimesheetsOverviewService, ], exports: [ - TimesheetsQueryService, - TimesheetArchiveService, - TimesheetsCommandService + ], }) export class TimesheetsModule {} diff --git a/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.helpers.ts b/src/modules/timesheets/~misc_deprecated-files/timesheet.helpers.ts similarity index 100% rename from src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.helpers.ts rename to src/modules/timesheets/~misc_deprecated-files/timesheet.helpers.ts diff --git a/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.mappers.ts b/src/modules/timesheets/~misc_deprecated-files/timesheet.mappers.ts similarity index 100% rename from src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.mappers.ts rename to src/modules/timesheets/~misc_deprecated-files/timesheet.mappers.ts diff --git a/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.selectors.ts b/src/modules/timesheets/~misc_deprecated-files/timesheet.selectors.ts similarity index 100% rename from src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.selectors.ts rename to src/modules/timesheets/~misc_deprecated-files/timesheet.selectors.ts diff --git a/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.types.ts b/src/modules/timesheets/~misc_deprecated-files/timesheet.types.ts similarity index 100% rename from src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.types.ts rename to src/modules/timesheets/~misc_deprecated-files/timesheet.types.ts diff --git a/src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.utils.ts b/src/modules/timesheets/~misc_deprecated-files/timesheet.utils.ts similarity index 100% rename from src/modules/timesheets/~misc_deprecated-files/utils-helpers-others/timesheet.utils.ts rename to src/modules/timesheets/~misc_deprecated-files/timesheet.utils.ts diff --git a/src/modules/timesheets/~misc_deprecated-files/timesheets.controller.ts b/src/modules/timesheets/~misc_deprecated-files/timesheets.controller.ts index f6049a0..7eebd57 100644 --- a/src/modules/timesheets/~misc_deprecated-files/timesheets.controller.ts +++ b/src/modules/timesheets/~misc_deprecated-files/timesheets.controller.ts @@ -5,8 +5,8 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { Roles as RoleEnum } from '.prisma/client'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { TimesheetsCommandService } from './timesheets-command.service'; -import { TimesheetMap } from './utils-helpers-others/timesheet.types'; import { TimesheetPeriodDto } from './timesheet-period.dto'; +import { TimesheetMap } from './timesheet.types'; @ApiTags('Timesheets')