From 7b86b8c662634a304d86adbde0e24d8bfea403e2 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 26 Aug 2025 12:50:11 -0400 Subject: [PATCH 01/69] refactor(employee): switched phone_number type from number to string --- prisma/schema.prisma | 2 +- src/modules/employees/dtos/create-employee.dto.ts | 5 ++--- src/modules/employees/dtos/profil-employee.dto.ts | 2 +- src/modules/employees/dtos/update-employee.dto.ts | 2 +- .../pay-periods/services/pay-periods-query.service.ts | 4 +++- src/modules/users-management/dtos/user.dto.ts | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 53c624b..613be4a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,7 +19,7 @@ model Users { first_name String last_name String email String @unique - phone_number Int @unique + phone_number String @unique residence String? role Roles @default(GUEST) diff --git a/src/modules/employees/dtos/create-employee.dto.ts b/src/modules/employees/dtos/create-employee.dto.ts index bfdc973..2fb22aa 100644 --- a/src/modules/employees/dtos/create-employee.dto.ts +++ b/src/modules/employees/dtos/create-employee.dto.ts @@ -62,10 +62,9 @@ export class CreateEmployeeDto { example: '82538437464', description: 'Employee`s phone number', }) - @Type(() => Number) - @IsInt() + @IsString() @IsPositive() - phone_number: number; + phone_number: string; @ApiProperty({ example: '1 Bagshot Row, Hobbiton, The Shire, Middle-earth', diff --git a/src/modules/employees/dtos/profil-employee.dto.ts b/src/modules/employees/dtos/profil-employee.dto.ts index d790558..c6836cf 100644 --- a/src/modules/employees/dtos/profil-employee.dto.ts +++ b/src/modules/employees/dtos/profil-employee.dto.ts @@ -6,7 +6,7 @@ export class EmployeeProfileItemDto { company_name: number | null; job_title: string | null; email: string | null; - phone_number: number; + phone_number: string; first_work_day: string; last_work_day?: string | null; residence: string | null; diff --git a/src/modules/employees/dtos/update-employee.dto.ts b/src/modules/employees/dtos/update-employee.dto.ts index 517c48f..3bf49bd 100644 --- a/src/modules/employees/dtos/update-employee.dto.ts +++ b/src/modules/employees/dtos/update-employee.dto.ts @@ -18,5 +18,5 @@ export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) { supervisor_id?: number; @Max(2147483647) - phone_number: number; + phone_number: string; } diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index 8e20b08..9e7cb9b 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -113,12 +113,13 @@ export class PayPeriodsQueryService { payday : period.payday, pay_year : period.pay_year, label : period.label, + //add is_approved }, { filtered_employee_ids: crew_ids, seed_names }); } private async buildOverview( period: { period_start: string | Date; period_end: string | Date; payday: string | Date; - period_no: number; pay_year: number; label: string; }, + period_no: number; pay_year: number; label: string; }, //add is_approved options?: { filtered_employee_ids?: number[]; seed_names?: Map} ): Promise { const toDateString = (d: Date) => d.toISOString().slice(0, 10); @@ -287,6 +288,7 @@ export class PayPeriodsQueryService { period_start: period.period_start, period_end: period.period_end, label: period.label, + //add is_approved })); } diff --git a/src/modules/users-management/dtos/user.dto.ts b/src/modules/users-management/dtos/user.dto.ts index 24cf8e4..8598b1f 100644 --- a/src/modules/users-management/dtos/user.dto.ts +++ b/src/modules/users-management/dtos/user.dto.ts @@ -30,7 +30,7 @@ export class UserDto { example: 5141234567, description: 'Unique phone number', }) - phone_number: number; + phone_number: string; @ApiProperty({ example: 'Minas Tirith, Gondor', From cb30d0142eb3eccc2d8edd535477a1d34a14f89e Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 26 Aug 2025 12:58:02 -0400 Subject: [PATCH 02/69] feat(migration): phone_number prisma migration --- .../20250826165409_changed_phone_type_to_string/migration.sql | 2 ++ prisma/mock-seeds-scripts/02-users.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20250826165409_changed_phone_type_to_string/migration.sql diff --git a/prisma/migrations/20250826165409_changed_phone_type_to_string/migration.sql b/prisma/migrations/20250826165409_changed_phone_type_to_string/migration.sql new file mode 100644 index 0000000..c7ba659 --- /dev/null +++ b/prisma/migrations/20250826165409_changed_phone_type_to_string/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."users" ALTER COLUMN "phone_number" SET DATA TYPE TEXT; diff --git a/prisma/mock-seeds-scripts/02-users.ts b/prisma/mock-seeds-scripts/02-users.ts index 5078b3e..442678e 100644 --- a/prisma/mock-seeds-scripts/02-users.ts +++ b/prisma/mock-seeds-scripts/02-users.ts @@ -1,7 +1,7 @@ import { PrismaClient, Roles } from '@prisma/client'; const prisma = new PrismaClient(); -const BASE_PHONE = 1_100_000_000; // < 2_147_483_647 +const BASE_PHONE = '1_100_000_000'; // < 2_147_483_647 function emailFor(i: number) { return `user${i + 1}@example.test`; @@ -16,7 +16,7 @@ async function main() { first_name: string; last_name: string; email: string; - phone_number: number; + phone_number: string; residence?: string | null; role: Roles; }[] = []; From 13bc09c77e8c6eccd6aa36ed9cad36e50b0f3c25 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 26 Aug 2025 13:23:54 -0400 Subject: [PATCH 03/69] fix(customer): phone_number type fix --- docs/swagger/swagger-spec.json | 8 ++++---- src/modules/customers/dtos/create-customer.dto.ts | 5 ++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 2751e71..94438f6 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -2304,7 +2304,7 @@ "description": "Employee`s email" }, "phone_number": { - "type": "number", + "type": "string", "example": "82538437464", "description": "Employee`s phone number" }, @@ -2389,7 +2389,7 @@ "description": "Employee`s email" }, "phone_number": { - "type": "number", + "type": "string", "example": "82538437464", "description": "Employee`s phone number" }, @@ -2834,7 +2834,7 @@ "description": "Customer`s email" }, "phone_number": { - "type": "number", + "type": "string", "example": "8436637464", "description": "Customer`s phone number" }, @@ -2887,7 +2887,7 @@ "description": "Customer`s email" }, "phone_number": { - "type": "number", + "type": "string", "example": "8436637464", "description": "Customer`s phone number" }, diff --git a/src/modules/customers/dtos/create-customer.dto.ts b/src/modules/customers/dtos/create-customer.dto.ts index bc35918..24289f8 100644 --- a/src/modules/customers/dtos/create-customer.dto.ts +++ b/src/modules/customers/dtos/create-customer.dto.ts @@ -55,10 +55,9 @@ export class CreateCustomerDto { example: '8436637464', description: 'Customer`s phone number', }) - @Type(() => Number) - @IsInt() + @IsString() @IsPositive() - phone_number: number; + phone_number: string; @ApiProperty({ example: '1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ', From 9bc5c41de803ed98890a009937ed8d48b818797e Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 28 Aug 2025 10:22:00 -0400 Subject: [PATCH 04/69] refactor(timesheets): refactored findAll to return more data --- .../services/holiday.service.ts | 25 +- .../controllers/csv-exports.controller.ts | 11 +- .../exports/dtos/export-csv-options.dto.ts | 61 ++-- .../exports/services/csv-exports.service.ts | 290 +++++++++++------- .../timesheets/dtos/timesheet-period.dto.ts | 7 + .../timesheets/utils/timesheet.helpers.ts | 3 +- 6 files changed, 248 insertions(+), 149 deletions(-) diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index daf6e96..4fce9e0 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../../../prisma/prisma.service"; import { computeHours, getWeekStart } from "src/common/utils/date-utils"; @@ -8,7 +8,23 @@ export class HolidayService { constructor(private readonly prisma: PrismaService) {} - //switch employeeId for email + //fetch employee_id by email + private async resolveEmployeeByEmail(email: string): Promise { + const employee = await this.prisma.employees.findFirst({ + where: { + user: { email } + }, + select: { id: true }, + }); + if(!employee) throw new NotFoundException(`Employee with email : ${email} not found`); + return employee.id; + } + + private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise { + const employee_id = await this.resolveEmployeeByEmail(email); + return this.computeHoursPrevious4Weeks(employee_id, holiday_date) + } + private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise { //sets the end of the window to 1ms before the week with the holiday const holiday_week_start = getWeekStart(holiday_date); @@ -32,9 +48,8 @@ export class HolidayService { return daily_hours; } - //switch employeeId for email - async calculateHolidayPay( employee_id: number, holiday_date: Date, modifier: number): Promise { - const hours = await this.computeHoursPrevious4Weeks(employee_id, holiday_date); + async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise { + const hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date); const daily_rate = Math.min(hours, 8); this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`); return daily_rate * modifier; diff --git a/src/modules/exports/controllers/csv-exports.controller.ts b/src/modules/exports/controllers/csv-exports.controller.ts index f59e84c..7346bcb 100644 --- a/src/modules/exports/controllers/csv-exports.controller.ts +++ b/src/modules/exports/controllers/csv-exports.controller.ts @@ -11,13 +11,13 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators"; export class CsvExportController { constructor(private readonly csvService: CsvExportService) {} - @Get('csv') + @Get('csv/:year/:period_no') @Header('Content-Type', 'text/csv; charset=utf-8') - @Header('Content-Dispoition', 'attachment; filename="export.csv"') + @Header('Content-Disposition', 'attachment; filename="export.csv"') //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) async exportCsv(@Query() options: ExportCsvOptionsDto, @Query('period') periodId: string ): Promise { - + //modify to accept year and period_number //sets default values const companies = options.companies && options.companies.length ? options.companies : [ ExportCompany.TARGO, ExportCompany.SOLUCOM]; @@ -28,11 +28,10 @@ export class CsvExportController { const all = await this.csvService.collectTransaction(Number(periodId), companies); //filters by type - const filtered = all.filter(r => { - switch (r.bank_code.toLocaleLowerCase()) { + const filtered = all.filter(row => { + switch (row.bank_code.toLocaleLowerCase()) { case 'holiday' : return types.includes(ExportType.HOLIDAY); case 'vacation' : return types.includes(ExportType.VACATION); - case 'sick-leave': return types.includes(ExportType.SICK_LEAVE); case 'expenses' : return types.includes(ExportType.EXPENSES); default : return types.includes(ExportType.SHIFTS); } diff --git a/src/modules/exports/dtos/export-csv-options.dto.ts b/src/modules/exports/dtos/export-csv-options.dto.ts index dc969ad..f2a2b49 100644 --- a/src/modules/exports/dtos/export-csv-options.dto.ts +++ b/src/modules/exports/dtos/export-csv-options.dto.ts @@ -1,26 +1,47 @@ -import { IsArray, IsEnum, IsOptional } from "class-validator"; +import { Transform } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator"; -export enum ExportType { - SHIFTS = 'Quart de travail', - EXPENSES = 'Depenses', - HOLIDAY = 'Ferie', - VACATION = 'Vacance', - SICK_LEAVE = 'Absence' -} - -export enum ExportCompany { - TARGO = 'Targo', - SOLUCOM = 'Solucom', +function toBoolean(v: any) { + if(typeof v === 'boolean') return v; + if(typeof v === 'string') return ['true', '1', 'on','yes'].includes(v.toLowerCase()); + return false; } export class ExportCsvOptionsDto { - @IsOptional() - @IsArray() - @IsEnum(ExportCompany, { each: true }) - companies?: ExportCompany[]; - @IsOptional() - @IsArray() - @IsEnum(ExportType, { each: true }) - type?: ExportType[]; + @Transform(({ value }) => parseInt(value,10)) + @IsInt() @Min(2023) + year! : number; + + @Transform(({ value }) => parseInt(value,10)) + @IsInt() @Min(1) @Max(26) + period_no!: number; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + approved? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + shifts? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + expenses? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + holiday? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + vacation? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + targo? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + solucom? : boolean = true; } \ No newline at end of file diff --git a/src/modules/exports/services/csv-exports.service.ts b/src/modules/exports/services/csv-exports.service.ts index ed9fab2..00157db 100644 --- a/src/modules/exports/services/csv-exports.service.ts +++ b/src/modules/exports/services/csv-exports.service.ts @@ -1,6 +1,5 @@ import { PrismaService } from "src/prisma/prisma.service"; -import { ExportCompany } from "../dtos/export-csv-options.dto"; -import { Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; export interface CsvRow { company_code: number; @@ -14,135 +13,189 @@ export interface CsvRow { holiday_date?: string; } +type Filters = { + types: { + shifts: boolean; + expenses: boolean; + holiday: boolean; + vacation: boolean; + }; + companies: { + targo: boolean; + solucom: boolean; + }; + approved: boolean; +}; + @Injectable() export class CsvExportService { constructor(private readonly prisma: PrismaService) {} - async collectTransaction( period_id: number, companies: ExportCompany[], approved: boolean = true): - Promise { - - const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); - + async collectTransaction( + year: number, + period_no: number, + filters: Filters, + approved: boolean = true + ): Promise { + //fetch period const period = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no: period_id }, + where: { pay_year: year, pay_period_no: period_no }, + select: { period_start: true, period_end: true }, }); - if(!period) throw new NotFoundException(`Pay period ${period_id} not found`); + if(!period) throw new NotFoundException(`Pay period ${ year }-${ period_no } not found`); - const start_date = period.period_start; - const end_date = period.period_end; + const start = period.period_start; + const end = period.period_end; - const approved_filter = approved ? { is_approved: true } : {}; + //fetch company codes from .env + const comapany_codes = this.resolveCompanyCodes(filters.companies); + if(comapany_codes.length === 0) throw new BadRequestException('No company selected'); - //fetching shifts - const shifts = await this.prisma.shifts.findMany({ - where: { - date: { gte: start_date, lte: end_date }, - ...approved_filter, - timesheet: { - employee: { company_code: { in: company_codes} } }, - }, - include: { - bank_code: true, - timesheet: { include: { - employee: { include: { - user:true, - supervisor: { include: { - user:true } } } } } }, - }, - }); - - //fetching expenses - const expenses = await this.prisma.expenses.findMany({ - where: { - date: { gte: start_date, lte: end_date }, - ...approved_filter, - timesheet: { employee: { company_code: { in: company_codes} } }, - }, - include: { bank_code: true, - timesheet: { include: { - employee: { include: { - user: true, - supervisor: { include: { - user:true } } } } } }, - }, - }); - - //fetching leave requests - const leaves = await this.prisma.leaveRequests.findMany({ - where : { - start_date_time: { gte: start_date, lte: end_date }, - employee: { company_code: { in: company_codes } }, - }, - include: { - bank_code: true, - employee: { include: { - user: true, - supervisor: { include: { - user: true } } } }, - }, - }); - - const rows: CsvRow[] = []; - - //Shifts Mapping - for (const shift of shifts) { - const emp = shift.timesheet.employee; - const week_number = this.computeWeekNumber(start_date, shift.date); - const hours = this.computeHours(shift.start_time, shift.end_time); - - rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: shift.bank_code.bank_code, - quantity_hours: hours, - amount: undefined, - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); + //Flag types + const { shifts: want_shifts, expenses: want_expense, holiday: want_holiday, vacation: want_vacation } = filters.types; + if(!want_shifts && !want_expense && !want_holiday && !want_vacation) { + throw new BadRequestException(' No export type selected '); } - //Expenses Mapping - for (const e of expenses) { - const emp = e.timesheet.employee; - const week_number = this.computeWeekNumber(start_date, e.date); + const approved_filter = filters.approved? { is_approved: true } : {}; - rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: e.bank_code.bank_code, - quantity_hours: undefined, - amount: Number(e.amount), - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); - } + //Prisma queries + const [shifts, expenses] = await Promise.all([ + want_shifts || want_expense || want_holiday || want_vacation + ]) - //Leaves Mapping - for(const l of leaves) { - if(!l.bank_code) continue; - const emp = l.employee; - const start = l.start_date_time; - const end = l.end_date_time ?? start; - const week_number = this.computeWeekNumber(start_date, start); - const hours = this.computeHours(start, end); - rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: l.bank_code.bank_code, - quantity_hours: hours, - amount: undefined, - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); - } + + + + + // const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); + + // const period = await this.prisma.payPeriods.findFirst({ + // where: { pay_period_no: period_id }, + // }); + // if(!period) throw new NotFoundException(`Pay period ${period_id} not found`); + + // const start_date = period.period_start; + // const end_date = period.period_end; + + // const included_shifts = await this.prisma.shifts.findMany({ + // where: { } + // }) + + // const approved_filter = approved ? { is_approved: true } : {}; + + // //fetching shifts + // const shifts = await this.prisma.shifts.findMany({ + // where: { + // date: { gte: start_date, lte: end_date }, + // ...approved_filter, + // timesheet: { + // employee: { company_code: { in: company_codes} } }, + // }, + // include: { + // bank_code: true, + // timesheet: { include: { + // employee: { include: { + // user:true, + // supervisor: { include: { + // user:true } } } } } }, + // }, + // }); + + // //fetching expenses + // const expenses = await this.prisma.expenses.findMany({ + // where: { + // date: { gte: start_date, lte: end_date }, + // ...approved_filter, + // timesheet: { employee: { company_code: { in: company_codes} } }, + // }, + // include: { bank_code: true, + // timesheet: { include: { + // employee: { include: { + // user: true, + // supervisor: { include: { + // user:true } } } } } }, + // }, + // }); + + // //fetching leave requests + // const leaves = await this.prisma.leaveRequests.findMany({ + // where : { + // start_date_time: { gte: start_date, lte: end_date }, + // employee: { company_code: { in: company_codes } }, + // }, + // include: { + // bank_code: true, + // employee: { include: { + // user: true, + // supervisor: { include: { + // user: true } } } }, + // }, + // }); + + // const rows: CsvRow[] = []; + + // //Shifts Mapping + // for (const shift of shifts) { + // const emp = shift.timesheet.employee; + // const week_number = this.computeWeekNumber(start_date, shift.date); + // const hours = this.computeHours(shift.start_time, shift.end_time); + + // rows.push({ + // company_code: emp.company_code, + // external_payroll_id: emp.external_payroll_id, + // full_name: `${emp.user.first_name} ${emp.user.last_name}`, + // bank_code: shift.bank_code.bank_code, + // quantity_hours: hours, + // amount: undefined, + // week_number, + // pay_date: this.formatDate(end_date), + // holiday_date: undefined, + // }); + // } + + // //Expenses Mapping + // for (const e of expenses) { + // const emp = e.timesheet.employee; + // const week_number = this.computeWeekNumber(start_date, e.date); + + // rows.push({ + // company_code: emp.company_code, + // external_payroll_id: emp.external_payroll_id, + // full_name: `${emp.user.first_name} ${emp.user.last_name}`, + // bank_code: e.bank_code.bank_code, + // quantity_hours: undefined, + // amount: Number(e.amount), + // week_number, + // pay_date: this.formatDate(end_date), + // holiday_date: undefined, + // }); + // } + + // //Leaves Mapping + // for(const l of leaves) { + // if(!l.bank_code) continue; + // const emp = l.employee; + // const start = l.start_date_time; + // const end = l.end_date_time ?? start; + + // const week_number = this.computeWeekNumber(start_date, start); + // const hours = this.computeHours(start, end); + + // rows.push({ + // company_code: emp.company_code, + // external_payroll_id: emp.external_payroll_id, + // full_name: `${emp.user.first_name} ${emp.user.last_name}`, + // bank_code: l.bank_code.bank_code, + // quantity_hours: hours, + // amount: undefined, + // week_number, + // pay_date: this.formatDate(end_date), + // holiday_date: undefined, + // }); + // } //Final Mapping and sorts return rows.sort((a,b) => { @@ -155,6 +208,9 @@ export class CsvExportService { return a.week_number - b.week_number; }); } + resolveCompanyCodes(companies: { targo: boolean; solucom: boolean; }) { + throw new Error("Method not implemented."); + } generateCsv(rows: CsvRow[]): Buffer { const header = [ @@ -172,7 +228,7 @@ export class CsvExportService { const body = rows.map(r => [ r.company_code, r.external_payroll_id, - `${r.full_name.replace(/"/g, '""')}"`, + `${r.full_name.replace(/"/g, '""')}`, r.bank_code, r.quantity_hours?.toFixed(2) ?? '', r.week_number, diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index 71a8e2f..c15b3a5 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -11,6 +11,13 @@ export class ExpenseDto { export type DayShiftsDto = ShiftDto[]; +export class DetailedShifts { + shifts: DayShiftsDto; + total_hours: number; + short_date: string; + break_durations?: number; +} + export class DayExpensesDto { cash: ExpenseDto[] = []; km : ExpenseDto[] = []; diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 6dfef18..6ad14af 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -6,7 +6,7 @@ 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 }; +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 @@ -98,6 +98,7 @@ export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], for (const shift of week_shifts) { const key = dayKeyFromDate(shift.date, true); week.shifts[key].push({ + shifts: [], start: toTimeString(shift.start_time), end : toTimeString(shift.end_time), is_approved: shift.is_approved ?? true, From 994e02ba7fdf1046150eb90ae76c8445f43557d7 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 28 Aug 2025 10:22:19 -0400 Subject: [PATCH 05/69] refactor(timesheets): refactored findAll to return more data --- docs/swagger/swagger-spec.json | 114 ++++++------------ prisma/mock-seeds-scripts/02-users.ts | 4 +- src/app.module.ts | 4 +- .../customers/dtos/create-customer.dto.ts | 4 +- .../employees/dtos/create-employee.dto.ts | 6 +- .../employees/dtos/profil-employee.dto.ts | 2 +- .../employees/dtos/update-employee.dto.ts | 4 +- .../controllers/csv-exports.controller.ts | 54 ++++----- .../exports/services/csv-exports.service.ts | 72 +++++------ .../controllers/timesheets.controller.ts | 6 +- .../timesheets/dtos/timesheet-period.dto.ts | 14 +-- .../services/timesheets-query.service.ts | 10 +- .../timesheets/utils/timesheet.helpers.ts | 95 +++++++++++---- 13 files changed, 196 insertions(+), 193 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 9d03aa2..d3afcc6 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -464,24 +464,36 @@ ] }, "get": { - "operationId": "TimesheetsController_findAll", - "parameters": [], - "responses": { - "201": { - "description": "List of timesheet found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateTimesheetDto" - } - } - } + "operationId": "TimesheetsController_getPeriodByQuery", + "parameters": [ + { + "name": "year", + "required": true, + "in": "query", + "schema": { + "type": "number" } }, - "400": { - "description": "List of timesheets not found" + { + "name": "period_no", + "required": true, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "email", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" } }, "security": [ @@ -489,7 +501,6 @@ "access-token": [] } ], - "summary": "Find all timesheets", "tags": [ "Timesheets" ] @@ -618,7 +629,7 @@ ] } }, - "/timesheets/{id}/approval": { + "/timesheets/approval/{id}": { "patch": { "operationId": "TimesheetsController_approve", "parameters": [ @@ -1206,7 +1217,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/CreateLeaveRequestsDto" + "$ref": "#/components/schemas/LeaveRequestViewDto" } } } @@ -1246,7 +1257,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateLeaveRequestsDto" + "$ref": "#/components/schemas/LeaveRequestViewDto" } } } @@ -1293,7 +1304,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateLeaveRequestsDto" + "$ref": "#/components/schemas/LeaveRequestViewDto" } } } @@ -1525,29 +1536,6 @@ ] } }, - "/exports/csv": { - "get": { - "operationId": "CsvExportController_exportCsv", - "parameters": [ - { - "name": "period", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "tags": [ - "CsvExport" - ] - } - }, "/customers": { "post": { "operationId": "CustomersController_create", @@ -2293,7 +2281,7 @@ "description": "Employee`s email" }, "phone_number": { - "type": "number", + "type": "string", "example": "82538437464", "description": "Employee`s phone number" }, @@ -2378,7 +2366,7 @@ "description": "Employee`s email" }, "phone_number": { - "type": "number", + "type": "string", "example": "82538437464", "description": "Employee`s phone number" }, @@ -2646,16 +2634,6 @@ "CreateLeaveRequestsDto": { "type": "object", "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Leave request`s unique id(auto-incremented)" - }, - "employee_id": { - "type": "number", - "example": "4655867", - "description": "Employee`s id" - }, "bank_code_id": { "type": "number", "example": 7, @@ -2667,13 +2645,11 @@ "description": "type of leave request for an accounting perception" }, "start_date_time": { - "format": "date-time", "type": "string", "example": "22/06/2463", "description": "Leave request`s start date" }, "end_date_time": { - "format": "date-time", "type": "string", "example": "25/03/3019", "description": "Leave request`s end date" @@ -2690,8 +2666,6 @@ } }, "required": [ - "id", - "employee_id", "bank_code_id", "leave_type", "start_date_time", @@ -2700,19 +2674,13 @@ "approval_status" ] }, + "LeaveRequestViewDto": { + "type": "object", + "properties": {} + }, "UpdateLeaveRequestsDto": { "type": "object", "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Leave request`s unique id(auto-incremented)" - }, - "employee_id": { - "type": "number", - "example": "4655867", - "description": "Employee`s id" - }, "bank_code_id": { "type": "number", "example": 7, @@ -2724,13 +2692,11 @@ "description": "type of leave request for an accounting perception" }, "start_date_time": { - "format": "date-time", "type": "string", "example": "22/06/2463", "description": "Leave request`s start date" }, "end_date_time": { - "format": "date-time", "type": "string", "example": "25/03/3019", "description": "Leave request`s end date" @@ -2845,7 +2811,7 @@ "description": "Customer`s email" }, "phone_number": { - "type": "number", + "type": "string", "example": "8436637464", "description": "Customer`s phone number" }, @@ -2898,7 +2864,7 @@ "description": "Customer`s email" }, "phone_number": { - "type": "number", + "type": "string", "example": "8436637464", "description": "Customer`s phone number" }, diff --git a/prisma/mock-seeds-scripts/02-users.ts b/prisma/mock-seeds-scripts/02-users.ts index 5078b3e..442678e 100644 --- a/prisma/mock-seeds-scripts/02-users.ts +++ b/prisma/mock-seeds-scripts/02-users.ts @@ -1,7 +1,7 @@ import { PrismaClient, Roles } from '@prisma/client'; const prisma = new PrismaClient(); -const BASE_PHONE = 1_100_000_000; // < 2_147_483_647 +const BASE_PHONE = '1_100_000_000'; // < 2_147_483_647 function emailFor(i: number) { return `user${i + 1}@example.test`; @@ -16,7 +16,7 @@ async function main() { first_name: string; last_name: string; email: string; - phone_number: number; + phone_number: string; residence?: string | null; role: Roles; }[] = []; diff --git a/src/app.module.ts b/src/app.module.ts index c1a20e8..7a4aadf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,7 +5,7 @@ import { ArchivalModule } from './modules/archival/archival.module'; import { AuthenticationModule } from './modules/authentication/auth.module'; import { BankCodesModule } from './modules/bank-codes/bank-codes.module'; import { BusinessLogicsModule } from './modules/business-logics/business-logics.module'; -import { CsvExportModule } from './modules/exports/csv-exports.module'; +// import { CsvExportModule } from './modules/exports/csv-exports.module'; import { CustomersModule } from './modules/customers/customers.module'; import { EmployeesModule } from './modules/employees/employees.module'; import { ExpensesModule } from './modules/expenses/expenses.module'; @@ -30,7 +30,7 @@ import { ConfigModule } from '@nestjs/config'; BankCodesModule, BusinessLogicsModule, ConfigModule.forRoot({isGlobal: true}), - CsvExportModule, + // CsvExportModule, CustomersModule, EmployeesModule, ExpensesModule, diff --git a/src/modules/customers/dtos/create-customer.dto.ts b/src/modules/customers/dtos/create-customer.dto.ts index bc35918..398da0e 100644 --- a/src/modules/customers/dtos/create-customer.dto.ts +++ b/src/modules/customers/dtos/create-customer.dto.ts @@ -55,10 +55,10 @@ export class CreateCustomerDto { example: '8436637464', description: 'Customer`s phone number', }) - @Type(() => Number) + @IsString() @IsInt() @IsPositive() - phone_number: number; + phone_number: string; @ApiProperty({ example: '1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ', diff --git a/src/modules/employees/dtos/create-employee.dto.ts b/src/modules/employees/dtos/create-employee.dto.ts index bfdc973..89279ef 100644 --- a/src/modules/employees/dtos/create-employee.dto.ts +++ b/src/modules/employees/dtos/create-employee.dto.ts @@ -62,10 +62,8 @@ export class CreateEmployeeDto { example: '82538437464', description: 'Employee`s phone number', }) - @Type(() => Number) - @IsInt() - @IsPositive() - phone_number: number; + @IsString() + phone_number: string; @ApiProperty({ example: '1 Bagshot Row, Hobbiton, The Shire, Middle-earth', diff --git a/src/modules/employees/dtos/profil-employee.dto.ts b/src/modules/employees/dtos/profil-employee.dto.ts index d790558..c6836cf 100644 --- a/src/modules/employees/dtos/profil-employee.dto.ts +++ b/src/modules/employees/dtos/profil-employee.dto.ts @@ -6,7 +6,7 @@ export class EmployeeProfileItemDto { company_name: number | null; job_title: string | null; email: string | null; - phone_number: number; + phone_number: string; first_work_day: string; last_work_day?: string | null; residence: string | null; diff --git a/src/modules/employees/dtos/update-employee.dto.ts b/src/modules/employees/dtos/update-employee.dto.ts index 517c48f..334a01a 100644 --- a/src/modules/employees/dtos/update-employee.dto.ts +++ b/src/modules/employees/dtos/update-employee.dto.ts @@ -17,6 +17,6 @@ export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) { @IsOptional() supervisor_id?: number; - @Max(2147483647) - phone_number: number; + @IsOptional() + phone_number: string; } diff --git a/src/modules/exports/controllers/csv-exports.controller.ts b/src/modules/exports/controllers/csv-exports.controller.ts index 7346bcb..c3363f9 100644 --- a/src/modules/exports/controllers/csv-exports.controller.ts +++ b/src/modules/exports/controllers/csv-exports.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, Header, Query, UseGuards } from "@nestjs/common"; import { RolesGuard } from "src/common/guards/roles.guard"; import { Roles as RoleEnum } from '.prisma/client'; import { CsvExportService } from "../services/csv-exports.service"; -import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto"; +// import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; @@ -11,34 +11,34 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators"; export class CsvExportController { constructor(private readonly csvService: CsvExportService) {} - @Get('csv/:year/:period_no') - @Header('Content-Type', 'text/csv; charset=utf-8') - @Header('Content-Disposition', 'attachment; filename="export.csv"') - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) - async exportCsv(@Query() options: ExportCsvOptionsDto, - @Query('period') periodId: string ): Promise { - //modify to accept year and period_number - //sets default values - const companies = options.companies && options.companies.length ? options.companies : - [ ExportCompany.TARGO, ExportCompany.SOLUCOM]; - const types = options.type && options.type.length ? options.type : - Object.values(ExportType); + // @Get('csv/:year/:period_no') + // @Header('Content-Type', 'text/csv; charset=utf-8') + // @Header('Content-Disposition', 'attachment; filename="export.csv"') + // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) + // async exportCsv(@Query() options: ExportCsvOptionsDto, + // @Query('period') periodId: string ): Promise { + // //modify to accept year and period_number + // //sets default values + // const companies = options.companies && options.companies.length ? options.companies : + // [ ExportCompany.TARGO, ExportCompany.SOLUCOM]; + // const types = options.type && options.type.length ? options.type : + // Object.values(ExportType); - //collects all - const all = await this.csvService.collectTransaction(Number(periodId), companies); + // //collects all + // const all = await this.csvService.collectTransaction(Number(periodId), companies); - //filters by type - const filtered = all.filter(row => { - switch (row.bank_code.toLocaleLowerCase()) { - case 'holiday' : return types.includes(ExportType.HOLIDAY); - case 'vacation' : return types.includes(ExportType.VACATION); - case 'expenses' : return types.includes(ExportType.EXPENSES); - default : return types.includes(ExportType.SHIFTS); - } - }); + // //filters by type + // const filtered = all.filter(row => { + // switch (row.bank_code.toLocaleLowerCase()) { + // case 'holiday' : return types.includes(ExportType.HOLIDAY); + // case 'vacation' : return types.includes(ExportType.VACATION); + // case 'expenses' : return types.includes(ExportType.EXPENSES); + // default : return types.includes(ExportType.SHIFTS); + // } + // }); - //generating the csv file - return this.csvService.generateCsv(filtered); - } + // //generating the csv file + // return this.csvService.generateCsv(filtered); + // } } \ No newline at end of file diff --git a/src/modules/exports/services/csv-exports.service.ts b/src/modules/exports/services/csv-exports.service.ts index 00157db..d8bd73e 100644 --- a/src/modules/exports/services/csv-exports.service.ts +++ b/src/modules/exports/services/csv-exports.service.ts @@ -31,38 +31,38 @@ type Filters = { export class CsvExportService { constructor(private readonly prisma: PrismaService) {} - async collectTransaction( - year: number, - period_no: number, - filters: Filters, - approved: boolean = true - ): Promise { + // async collectTransaction( + // year: number, + // period_no: number, + // filters: Filters, + // approved: boolean = true + // ): Promise { //fetch 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(`Pay period ${ year }-${ period_no } not found`); + // 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(`Pay period ${ year }-${ period_no } not found`); - const start = period.period_start; - const end = period.period_end; + // const start = period.period_start; + // const end = period.period_end; - //fetch company codes from .env - const comapany_codes = this.resolveCompanyCodes(filters.companies); - if(comapany_codes.length === 0) throw new BadRequestException('No company selected'); + // //fetch company codes from .env + // const comapany_codes = this.resolveCompanyCodes(filters.companies); + // if(comapany_codes.length === 0) throw new BadRequestException('No company selected'); - //Flag types - const { shifts: want_shifts, expenses: want_expense, holiday: want_holiday, vacation: want_vacation } = filters.types; - if(!want_shifts && !want_expense && !want_holiday && !want_vacation) { - throw new BadRequestException(' No export type selected '); - } + // //Flag types + // const { shifts: want_shifts, expenses: want_expense, holiday: want_holiday, vacation: want_vacation } = filters.types; + // if(!want_shifts && !want_expense && !want_holiday && !want_vacation) { + // throw new BadRequestException(' No export type selected '); + // } - const approved_filter = filters.approved? { is_approved: true } : {}; + // const approved_filter = filters.approved? { is_approved: true } : {}; - //Prisma queries - const [shifts, expenses] = await Promise.all([ - want_shifts || want_expense || want_holiday || want_vacation - ]) + // //Prisma queries + // const [shifts, expenses] = await Promise.all([ + // want_shifts || want_expense || want_holiday || want_vacation + // ]) @@ -198,16 +198,16 @@ export class CsvExportService { // } //Final Mapping and sorts - return rows.sort((a,b) => { - if(a.external_payroll_id !== b.external_payroll_id) { - return a.external_payroll_id - b.external_payroll_id; - } - if(a.bank_code !== b.bank_code) { - return a.bank_code.localeCompare(b.bank_code); - } - return a.week_number - b.week_number; - }); - } + // return rows.sort((a,b) => { + // if(a.external_payroll_id !== b.external_payroll_id) { + // return a.external_payroll_id - b.external_payroll_id; + // } + // if(a.bank_code !== b.bank_code) { + // return a.bank_code.localeCompare(b.bank_code); + // } + // return a.week_number - b.week_number; + // }); + // } resolveCompanyCodes(companies: { targo: boolean; solucom: boolean; }) { throw new Error("Method not implemented."); } diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index a5519ef..7280c3c 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -36,8 +36,8 @@ export class TimesheetsController { @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()); + if(!email || !(email = email.trim())) throw new BadRequestException('Query param "email" is mandatory for this route.'); + return this.timesheetsQuery.findAll(year, period_no, email); } @Get(':id') @@ -70,7 +70,7 @@ export class TimesheetsController { return this.timesheetsQuery.remove(id); } - @Patch(':id/approval') + @Patch('approval/:id') //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { 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 index c15b3a5..e383b08 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -27,13 +27,13 @@ export class DayExpensesDto { export class WeekDto { is_approved: boolean; shifts: { - sun: DayShiftsDto; - mon: DayShiftsDto; - tue: DayShiftsDto; - wed: DayShiftsDto; - thu: DayShiftsDto; - fri: DayShiftsDto; - sat: DayShiftsDto; + sun: DetailedShifts; + mon: DetailedShifts; + tue: DetailedShifts; + wed: DetailedShifts; + thu: DetailedShifts; + fri: DetailedShifts; + sat: DetailedShifts; } expenses: { sun: DayExpensesDto; diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 0fe08ee..cb6f67f 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -5,14 +5,10 @@ import { Timesheets, TimesheetsArchive } from '@prisma/client'; import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto'; import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; 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'; +import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers'; -// 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 { @@ -21,8 +17,6 @@ export class TimesheetsQueryService { private readonly overtime: OvertimeService, ) {} - - async create(dto : CreateTimesheetDto): Promise { const { employee_id, is_approved } = dto; return this.prisma.timesheets.create({ @@ -74,7 +68,7 @@ export class TimesheetsQueryService { }), ]); - //Shift data mapping + // data mapping const shifts: ShiftRow[] = raw_shifts.map(shift => ({ date: shift.date, start_time: shift.start_time, diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 6ad14af..7e32901 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -1,12 +1,12 @@ -import { DayExpensesDto, DayShiftsDto, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto"; +import { DayExpensesDto, DayShiftsDto, DetailedShifts, 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'; +export type DayKey = typeof DAY_KEYS[number]; //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 type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean }; +export 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 @@ -14,6 +14,7 @@ export function dayKeyFromDate(date: Date, useUTC = true): DayKey { } const MS_PER_DAY = 86_400_000; +const MS_PER_HOUR = 3_600_000; export function toUTCDateOnly(date: Date | string): Date { const d = new Date(date); @@ -34,12 +35,6 @@ export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): bool 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'); @@ -50,21 +45,33 @@ export function round2(num: number) { return Math.round(num * 100) / 100; } -export function makeEmptyDayShifts(): DayShiftsDto { return []; } +function shortDate(date:Date): string { + const mm = String(date.getUTCMonth()+1).padStart(2,'0'); + const dd = String(date.getUTCDate()).padStart(2,'0'); + return `${mm}/${dd}`; +} + +// export function makeEmptyDayShifts(): DayShiftsDto { return []; } export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; } -export function makeEmptyWeek(): WeekDto { +export function makeEmptyWeek(week_start: Date): WeekDto { + const make_empty_shifts = (offset: number): DetailedShifts => ({ + shifts: [], + total_hours: 0, + short_date: shortDate(addDays(week_start, offset)), + break_durations: undefined, + }); return { is_approved: true, shifts: { - sun: makeEmptyDayShifts(), - mon: makeEmptyDayShifts(), - tue: makeEmptyDayShifts(), - wed: makeEmptyDayShifts(), - thu: makeEmptyDayShifts(), - fri: makeEmptyDayShifts(), - sat: makeEmptyDayShifts(), + sun: make_empty_shifts(0), + mon: make_empty_shifts(1), + tue: make_empty_shifts(2), + wed: make_empty_shifts(3), + thu: make_empty_shifts(4), + fri: make_empty_shifts(5), + sat: make_empty_shifts(6), }, expenses: { sun: makeEmptyDayExpenses(), @@ -79,7 +86,7 @@ export function makeEmptyWeek(): WeekDto { } export function makeEmptyPeriod(): TimesheetPeriodDto { - return { week1: makeEmptyWeek(), week2: makeEmptyWeek() }; + return { week1: makeEmptyWeek(new Date()), week2: makeEmptyWeek(new Date()) }; } //needs ajusting according to DB's data for expenses types @@ -89,16 +96,27 @@ export function normalizeExpenseBucket(db_type: string): 'km' | 'cash' { return 'cash'; } -export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): WeekDto { - const week = makeEmptyWeek(); +export function buildWeek( + week_start: Date, + week_end: Date, + shifts: ShiftRow[], + expenses: ExpenseRow[], + ): WeekDto { + const week = makeEmptyWeek(week_start); let all_approved = true; + //array of shifts per day ( to check for break_gaps and calculate daily total hours ) + const dayTimes: Record> = { + sun: [], mon: [], tue: [], wed: [],thu: [], fri: [], sat: [], + }; + + //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({ - shifts: [], + dayTimes[key].push({start: shift.start_time, end:shift.end_time }); + week.shifts[key].shifts.push({ start: toTimeString(shift.start_time), end : toTimeString(shift.end_time), is_approved: shift.is_approved ?? true, @@ -118,11 +136,38 @@ export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], }); all_approved = all_approved && (expense.is_approved ?? true); } + + for (const key of DAY_KEYS) { + //sorts shifts in chronological order + const times = dayTimes[key].sort((a,b) => a.start.getTime() - b.start.getTime()); + + //daily total hours + const total = times.reduce((sum, time) => { + const duration = (time.end.getTime() - time.start.getTime()) / MS_PER_HOUR; + return sum + Math.max(0, duration); + }, 0); + week.shifts[key].total_hours = round2(total); + + //break_duration + if (times.length >= 2) { + let break_gaps = 0; + for (let i = 1; i < times.length; i++) { + const gap = (times[i].start.getTime() - times[i-1].end.getTime()) / MS_PER_HOUR; + if(gap > 0) break_gaps += gap; + } + if(break_gaps > 0) week.shifts[key].break_durations = round2(break_gaps); + } + } week.is_approved = all_approved; return week; } -export function buildPeriod( period_start: Date, period_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): TimesheetPeriodDto { +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)); From 5b2377796ac18651baba229eb88551e477f1035f Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 28 Aug 2025 13:33:31 -0400 Subject: [PATCH 06/69] refactor(CSV): modify export csv to match checklist options --- .../controllers/csv-exports.controller.ts | 54 ++- .../exports/services/csv-exports.service.ts | 396 ++++++++++-------- 2 files changed, 245 insertions(+), 205 deletions(-) diff --git a/src/modules/exports/controllers/csv-exports.controller.ts b/src/modules/exports/controllers/csv-exports.controller.ts index c3363f9..71cde76 100644 --- a/src/modules/exports/controllers/csv-exports.controller.ts +++ b/src/modules/exports/controllers/csv-exports.controller.ts @@ -4,6 +4,7 @@ import { Roles as RoleEnum } from '.prisma/client'; import { CsvExportService } from "../services/csv-exports.service"; // import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; +import { ExportCsvOptionsDto } from "../dtos/export-csv-options.dto"; @Controller('exports') @@ -11,34 +12,29 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators"; export class CsvExportController { constructor(private readonly csvService: CsvExportService) {} - // @Get('csv/:year/:period_no') - // @Header('Content-Type', 'text/csv; charset=utf-8') - // @Header('Content-Disposition', 'attachment; filename="export.csv"') - // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) - // async exportCsv(@Query() options: ExportCsvOptionsDto, - // @Query('period') periodId: string ): Promise { - // //modify to accept year and period_number - // //sets default values - // const companies = options.companies && options.companies.length ? options.companies : - // [ ExportCompany.TARGO, ExportCompany.SOLUCOM]; - // const types = options.type && options.type.length ? options.type : - // Object.values(ExportType); - - // //collects all - // const all = await this.csvService.collectTransaction(Number(periodId), companies); - - // //filters by type - // const filtered = all.filter(row => { - // switch (row.bank_code.toLocaleLowerCase()) { - // case 'holiday' : return types.includes(ExportType.HOLIDAY); - // case 'vacation' : return types.includes(ExportType.VACATION); - // case 'expenses' : return types.includes(ExportType.EXPENSES); - // default : return types.includes(ExportType.SHIFTS); - // } - // }); - - // //generating the csv file - // return this.csvService.generateCsv(filtered); - // } + @Get('csv') + @Header('Content-Type', 'text/csv; charset=utf-8') + @Header('Content-Disposition', 'attachment; filename="export.csv"') + //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) + async exportCsv(@Query() query: ExportCsvOptionsDto ): Promise { + const rows = await this.csvService.collectTransaction( + query.year, + query.period_no, + { + approved: query.approved ?? true, + types: { + shifts: query.shifts ?? true, + expenses: query.expenses ?? true, + holiday: query.holiday ?? true, + vacation: query.vacation ?? true, + }, + companies: { + targo: query.targo ?? true, + solucom: query.solucom ?? true, + }, + } + ); + return this.csvService.generateCsv(rows); + } } \ No newline at end of file diff --git a/src/modules/exports/services/csv-exports.service.ts b/src/modules/exports/services/csv-exports.service.ts index d8bd73e..3035156 100644 --- a/src/modules/exports/services/csv-exports.service.ts +++ b/src/modules/exports/services/csv-exports.service.ts @@ -31,187 +31,222 @@ type Filters = { export class CsvExportService { constructor(private readonly prisma: PrismaService) {} - // async collectTransaction( - // year: number, - // period_no: number, - // filters: Filters, - // approved: boolean = true - // ): Promise { + async collectTransaction( + year: number, + period_no: number, + filters: Filters, + approved: boolean = true + ): Promise { //fetch 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(`Pay period ${ year }-${ period_no } not found`); + 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(`Pay period ${ year }-${ period_no } not found`); - // const start = period.period_start; - // const end = period.period_end; + const start = period.period_start; + const end = period.period_end; - // //fetch company codes from .env - // const comapany_codes = this.resolveCompanyCodes(filters.companies); - // if(comapany_codes.length === 0) throw new BadRequestException('No company selected'); + //fetch company codes from .env + const company_codes = this.resolveCompanyCodes(filters.companies); + if(company_codes.length === 0) throw new BadRequestException('No company selected'); - // //Flag types - // const { shifts: want_shifts, expenses: want_expense, holiday: want_holiday, vacation: want_vacation } = filters.types; - // if(!want_shifts && !want_expense && !want_holiday && !want_vacation) { - // throw new BadRequestException(' No export type selected '); - // } + //Flag types + const { shifts: want_shifts, expenses: want_expense, holiday: want_holiday, vacation: want_vacation } = filters.types; + if(!want_shifts && !want_expense && !want_holiday && !want_vacation) { + throw new BadRequestException(' No export type selected '); + } - // const approved_filter = filters.approved? { is_approved: true } : {}; + const approved_filter = filters.approved? { is_approved: true } : {}; - // //Prisma queries - // const [shifts, expenses] = await Promise.all([ - // want_shifts || want_expense || want_holiday || want_vacation - // ]) + const {holiday_code, vacation_code} = this.resolveLeaveCodes(); + //Prisma queries + const promises: Array> = []; + if (want_shifts) { + promises.push( this.prisma.shifts.findMany({ + where: { + date: { gte: start, lte: end }, + ...approved_filter, + bank_code: { bank_code: { notIn: [ holiday_code, vacation_code ] } }, + timesheet: { employee: { company_code: { in: company_codes } } }, + }, + select: { + date: true, + start_time: true, + end_time: true, + bank_code: { select: { bank_code: true } }, + timesheet: { select: { + employee: { select: { + company_code: true, + external_payroll_id: true, + user: { select: { first_name: true, last_name: true } }, + }}, + }}, + }, + })); + } else { + promises.push(Promise.resolve([])); + } + if(want_holiday) { + promises.push( this.prisma.shifts.findMany({ + where: { + date: { gte: start, lte: end }, + ...approved_filter, + bank_code: { bank_code: holiday_code }, + timesheet: { employee: { company_code: { in: company_codes } } }, + }, + select: { + date: true, + start_time: true, + end_time: true, + bank_code: { select: { bank_code: true } }, + timesheet: { select: { + employee: { select: { + company_code: true, + external_payroll_id: true, + user: { select: { first_name: true,last_name: true } }, + } }, + } }, + }, + })); + }else { + promises.push(Promise.resolve([])); + } + if(want_vacation) { + promises.push( this.prisma.shifts.findMany({ + where: { + date: { gte: start, lte: end }, + ...approved_filter, + bank_code: { bank_code: vacation_code }, + timesheet: { employee: { company_code: { in: company_codes } } }, + }, + select: { + date: true, + start_time: true, + end_time: true, + bank_code: { select: { bank_code: true } }, + timesheet: { select: { + employee: { select: { + company_code: true, + external_payroll_id: true, + user: { select: { first_name: true,last_name: true } }, + } }, + } }, + }, + })); + }else { + promises.push(Promise.resolve([])); + } + if(want_expense) { + promises.push( this.prisma.expenses.findMany({ + where: { + date: { gte: start, lte: end }, + ...approved_filter, + timesheet: { employee: { company_code: { in: company_codes } } }, + }, + select: { + date: true, + amount: true, + bank_code: { select: { bank_code: true } }, + timesheet: { select: { + employee: { select: { + company_code: true, + external_payroll_id: true, + user: { select: { first_name: true, last_name: true } }, + }}, + }}, + }, + })); + } else { + promises.push(Promise.resolve([])); + } + //array of arrays + const [ base_shifts, holiday_shifts, vacation_shifts, expenses ] = await Promise.all(promises); + //mapping + const rows: CsvRow[] = []; - // const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); + const map_shifts = (shift: any, is_holiday: boolean) => { + const employee = shift.timesheet.employee; + const week = this.computeWeekNumber(start, shift.date); + return { + company_code: employee.company_code, + external_payroll_id: employee.external_payroll_id, + full_name: `${employee.first_name} ${ employee.last_name}`, + bank_code: shift.bank_code?.bank_code ?? '', + quantity_hours: this.computeHours(shift.start_time, shift.end_time), + amount: undefined, + week_number: week, + pay_date: this.formatDate(end), + holiday_date: is_holiday? this.formatDate(shift.date) : '', + } as CsvRow; + }; + //final mapping of all shifts based filters + for (const shift of base_shifts) rows.push(map_shifts(shift, false)); + for (const shift of holiday_shifts) rows.push(map_shifts(shift, true )); + for (const shift of vacation_shifts) rows.push(map_shifts(shift, false)); - // const period = await this.prisma.payPeriods.findFirst({ - // where: { pay_period_no: period_id }, - // }); - // if(!period) throw new NotFoundException(`Pay period ${period_id} not found`); + for (const expense of expenses) { + const employee = expense.timesheet.employee; + const week = this.computeWeekNumber(start, expense.date); + rows.push({ + company_code: employee.company_code, + external_payroll_id: employee.external_payroll_id, + full_name: `${employee.first_name} ${ employee.last_name}`, + bank_code: expense.bank_code?.bank_code ?? '', + quantity_hours: undefined, + amount: Number(expense.amount), + week_number: week, + pay_date: this.formatDate(end), + holiday_date: '', + }) + } - // const start_date = period.period_start; - // const end_date = period.period_end; + //Final mapping and sorts + rows.sort((a,b) => { + if(a.external_payroll_id !== b.external_payroll_id) { + return a.external_payroll_id - b.external_payroll_id; + } + const bk_code = String(a.bank_code).localeCompare(String(b.bank_code)); + if(bk_code !== 0) return bk_code; + if(a.bank_code !== b.bank_code) return a.bank_code.localeCompare(b.bank_code); + return 0; + }); - // const included_shifts = await this.prisma.shifts.findMany({ - // where: { } - // }) - - // const approved_filter = approved ? { is_approved: true } : {}; - - // //fetching shifts - // const shifts = await this.prisma.shifts.findMany({ - // where: { - // date: { gte: start_date, lte: end_date }, - // ...approved_filter, - // timesheet: { - // employee: { company_code: { in: company_codes} } }, - // }, - // include: { - // bank_code: true, - // timesheet: { include: { - // employee: { include: { - // user:true, - // supervisor: { include: { - // user:true } } } } } }, - // }, - // }); - - // //fetching expenses - // const expenses = await this.prisma.expenses.findMany({ - // where: { - // date: { gte: start_date, lte: end_date }, - // ...approved_filter, - // timesheet: { employee: { company_code: { in: company_codes} } }, - // }, - // include: { bank_code: true, - // timesheet: { include: { - // employee: { include: { - // user: true, - // supervisor: { include: { - // user:true } } } } } }, - // }, - // }); - - // //fetching leave requests - // const leaves = await this.prisma.leaveRequests.findMany({ - // where : { - // start_date_time: { gte: start_date, lte: end_date }, - // employee: { company_code: { in: company_codes } }, - // }, - // include: { - // bank_code: true, - // employee: { include: { - // user: true, - // supervisor: { include: { - // user: true } } } }, - // }, - // }); - - // const rows: CsvRow[] = []; - - // //Shifts Mapping - // for (const shift of shifts) { - // const emp = shift.timesheet.employee; - // const week_number = this.computeWeekNumber(start_date, shift.date); - // const hours = this.computeHours(shift.start_time, shift.end_time); - - // rows.push({ - // company_code: emp.company_code, - // external_payroll_id: emp.external_payroll_id, - // full_name: `${emp.user.first_name} ${emp.user.last_name}`, - // bank_code: shift.bank_code.bank_code, - // quantity_hours: hours, - // amount: undefined, - // week_number, - // pay_date: this.formatDate(end_date), - // holiday_date: undefined, - // }); - // } - - // //Expenses Mapping - // for (const e of expenses) { - // const emp = e.timesheet.employee; - // const week_number = this.computeWeekNumber(start_date, e.date); - - // rows.push({ - // company_code: emp.company_code, - // external_payroll_id: emp.external_payroll_id, - // full_name: `${emp.user.first_name} ${emp.user.last_name}`, - // bank_code: e.bank_code.bank_code, - // quantity_hours: undefined, - // amount: Number(e.amount), - // week_number, - // pay_date: this.formatDate(end_date), - // holiday_date: undefined, - // }); - // } - - // //Leaves Mapping - // for(const l of leaves) { - // if(!l.bank_code) continue; - // const emp = l.employee; - // const start = l.start_date_time; - // const end = l.end_date_time ?? start; - - // const week_number = this.computeWeekNumber(start_date, start); - // const hours = this.computeHours(start, end); - - // rows.push({ - // company_code: emp.company_code, - // external_payroll_id: emp.external_payroll_id, - // full_name: `${emp.user.first_name} ${emp.user.last_name}`, - // bank_code: l.bank_code.bank_code, - // quantity_hours: hours, - // amount: undefined, - // week_number, - // pay_date: this.formatDate(end_date), - // holiday_date: undefined, - // }); - // } - - //Final Mapping and sorts - // return rows.sort((a,b) => { - // if(a.external_payroll_id !== b.external_payroll_id) { - // return a.external_payroll_id - b.external_payroll_id; - // } - // if(a.bank_code !== b.bank_code) { - // return a.bank_code.localeCompare(b.bank_code); - // } - // return a.week_number - b.week_number; - // }); - // } - resolveCompanyCodes(companies: { targo: boolean; solucom: boolean; }) { - throw new Error("Method not implemented."); + return rows; } + resolveLeaveCodes(): { holiday_code: string; vacation_code: string; } { + const holiday_code = process.env.HOLIDAY_CODE?.trim(); + if(!holiday_code) throw new BadRequestException('Missing Holiday bank code'); + + const vacation_code = process.env.VACATION_CODE?.trim(); + if(!vacation_code) throw new BadRequestException('Missing Vacation bank code'); + + return { holiday_code, vacation_code}; + } + + resolveCompanyCodes(companies: { targo: boolean; solucom: boolean; }): number[] { + const out: number[] = []; + if (companies.targo) { + const code_no = parseInt(process.env.TARGO_NO ?? '', 10); + if(!Number.isFinite(code_no) || code_no <= 0) throw new BadRequestException('Invalid Targo code in env'); + out.push(code_no); + } + if (companies.solucom) { + const code_no = parseInt(process.env.SOLUCOM_NO ?? '', 10); + if(!Number.isFinite(code_no) || code_no <= 0) throw new BadRequestException('Invalid Solucom code in env'); + out.push(code_no); + } + return out; + } + + //csv builder and "mise en page" generateCsv(rows: CsvRow[]): Buffer { const header = [ 'company_code', @@ -225,18 +260,23 @@ export class CsvExportService { 'holiday_date', ].join(',') + '\n'; - const body = rows.map(r => [ - r.company_code, - r.external_payroll_id, - `${r.full_name.replace(/"/g, '""')}`, - r.bank_code, - r.quantity_hours?.toFixed(2) ?? '', - r.week_number, - r.pay_date, - r.holiday_date ?? '', - ].join(',')).join('\n'); - - return Buffer.from('\uFEFF' + header + body, 'utf8'); + const body = rows.map(row => { + const full_name = `${String(row.full_name).replace(/"/g, '""')}`; + const quantity_hours = (typeof row.quantity_hours === 'number') ? row.quantity_hours.toFixed(2) : ''; + const amount = (typeof row.amount === 'number') ? row.amount.toFixed(2) : ''; + return [ + row.company_code, + row.external_payroll_id, + full_name, + row.bank_code, + quantity_hours, + amount, + row.week_number, + row.pay_date, + row.holiday_date ?? '', + ].join(','); + }).join('\n'); + return Buffer.from('\uFEFF' + header + body, 'utf8'); } @@ -246,9 +286,13 @@ export class CsvExportService { } private computeWeekNumber(start: Date, date: Date): number { - const days = Math.floor((date.getTime() - start.getTime()) / (1000*60*60*24)); + const dayMS = 86400000; + const days = Math.floor((this.toUTC(date).getTime() - this.toUTC(start).getTime())/ dayMS); return Math.floor(days / 7 ) + 1; } + toUTC(date: Date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + } private formatDate(d:Date): string { return d.toISOString().split('T')[0]; From 9085e71f0c433feec4b2a5a80284c58f3eda836f Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 28 Aug 2025 14:03:31 -0400 Subject: [PATCH 07/69] fix(seeds): ajusted seeds for shifts and expenses --- .../migration.sql | 2 + prisma/mock-seeds-scripts/01-bankCodes.ts | 1 + prisma/mock-seeds-scripts/02-users.ts | 15 +-- prisma/mock-seeds-scripts/10-shifts.ts | 105 +++++++++++---- prisma/mock-seeds-scripts/12-expenses.ts | 120 ++++++++++++++---- prisma/schema.prisma | 2 +- 6 files changed, 187 insertions(+), 58 deletions(-) create mode 100644 prisma/migrations/20250828175750_changed_phone_number_type_to_string/migration.sql diff --git a/prisma/migrations/20250828175750_changed_phone_number_type_to_string/migration.sql b/prisma/migrations/20250828175750_changed_phone_number_type_to_string/migration.sql new file mode 100644 index 0000000..c7ba659 --- /dev/null +++ b/prisma/migrations/20250828175750_changed_phone_number_type_to_string/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."users" ALTER COLUMN "phone_number" SET DATA TYPE TEXT; diff --git a/prisma/mock-seeds-scripts/01-bankCodes.ts b/prisma/mock-seeds-scripts/01-bankCodes.ts index 6d5cec2..14d552f 100644 --- a/prisma/mock-seeds-scripts/01-bankCodes.ts +++ b/prisma/mock-seeds-scripts/01-bankCodes.ts @@ -9,6 +9,7 @@ async function main() { ['EVENING' ,'SHIFT', 1.25, 'G43'], ['Emergency','SHIFT', 2 , 'G48'], ['HOLIDAY' ,'SHIFT', 2.0 , 'G700'], + ['EXPENSES','EXPENSE', 1.0 , 'G517'], ['MILEAGE' ,'EXPENSE', 0.72, 'G57'], diff --git a/prisma/mock-seeds-scripts/02-users.ts b/prisma/mock-seeds-scripts/02-users.ts index 442678e..0352eb9 100644 --- a/prisma/mock-seeds-scripts/02-users.ts +++ b/prisma/mock-seeds-scripts/02-users.ts @@ -1,17 +1,15 @@ import { PrismaClient, Roles } from '@prisma/client'; const prisma = new PrismaClient(); -const BASE_PHONE = '1_100_000_000'; // < 2_147_483_647 + +// base sans underscore, en string +const BASE_PHONE = "1100000000"; function emailFor(i: number) { return `user${i + 1}@example.test`; } async function main() { - // 50 users total: 40 employees + 10 customers - // Roles distribution for the 40 employees: - // 1 ADMIN, 4 SUPERVISOR, 1 HR, 1 ACCOUNTING, 33 EMPLOYEE - // 10 CUSTOMER (non-employees) const usersData: { first_name: string; last_name: string; @@ -24,7 +22,6 @@ async function main() { const firstNames = ['Alex','Sam','Chris','Jordan','Taylor','Morgan','Jamie','Robin','Avery','Casey']; const lastNames = ['Smith','Johnson','Williams','Brown','Jones','Miller','Davis','Wilson','Taylor','Clark']; - // helper to pick const pick = (arr: T[]) => arr[Math.floor(Math.random() * arr.length)]; const rolesForEmployees: Roles[] = [ @@ -37,14 +34,14 @@ async function main() { // 40 employees for (let i = 0; i < 40; i++) { - const fn = pick(firstNames); const ln = pick(lastNames); usersData.push({ first_name: fn, last_name: ln, email: emailFor(i), - phone_number: BASE_PHONE + i, + // on concatène proprement en string + phone_number: BASE_PHONE + i.toString(), residence: Math.random() < 0.5 ? 'QC' : 'ON', role: rolesForEmployees[i], }); @@ -58,7 +55,7 @@ async function main() { first_name: fn, last_name: ln, email: emailFor(i), - phone_number: BASE_PHONE + i, + phone_number: BASE_PHONE + i.toString(), residence: Math.random() < 0.5 ? 'QC' : 'ON', role: Roles.CUSTOMER, }); diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index 3fe1791..d39c36c 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -2,46 +2,107 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -function timeAt(hour:number, minute:number) { - // stocker une heure (Postgres TIME) via Date (UTC 1970-01-01) +// Stocker une heure (Postgres TIME) via Date (UTC 1970-01-01) +function timeAt(hour: number, minute: number) { return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); } -function daysAgo(n:number) { - const d = new Date(); - d.setUTCDate(d.getUTCDate() - n); - d.setUTCHours(0,0,0,0); + +// Lundi de la semaine (en UTC) pour la date courante +function mondayOfThisWeekUTC(now = new Date()) { + // converti en UTC (sans l'heure) + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... + const diffToMonday = (day + 6) % 7; // 0 si lundi, 1 si mardi, ... 6 si dimanche + d.setUTCDate(d.getUTCDate() - diffToMonday); + d.setUTCHours(0, 0, 0, 0); return d; } +// Retourne les 5 dates Lundi→Vendredi (UTC, à minuit) +function currentWeekDates() { + const monday = mondayOfThisWeekUTC(); + return Array.from({ length: 5 }, (_, i) => { + const d = new Date(monday); + d.setUTCDate(monday.getUTCDate() + i); + return d; + }); +} + async function main() { - const bankCodes = await prisma.bankCodes.findMany({ where: { categorie: 'SHIFT' }, select: { id: true } }); - if (!bankCodes.length) throw new Error('Need SHIFT bank codes'); + // On récupère les bank codes requis (ajuste le nom de la colonne "code" si besoin) + const BANKS = ['G1', 'G305', 'G105'] as const; + const bcRows = await prisma.bankCodes.findMany({ + where: { bank_code: { in: BANKS as unknown as string[] } }, + select: { id: true, bank_code: true }, + }); + const bcMap = new Map(bcRows.map(b => [b.bank_code, b.id])); + + // Vérifications + for (const c of BANKS) { + if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`); + } const employees = await prisma.employees.findMany({ select: { id: true } }); + if (!employees.length) { + console.log('Aucun employé — rien à insérer.'); + return; + } + + const weekDays = currentWeekDates(); + + // Par défaut: G1 (régulier). 1 employé sur 5 : 1 jour de la semaine passe à G305 OU G105 (au hasard) + // Horaires: on décale par employé pour garantir des horaires différents. + // - start = 7, 7h30, 8, 8h30 selon l’index employé + // - durée = 8h sauf vendredi (7h) pour varier un peu + for (let ei = 0; ei < employees.length; ei++) { + const e = employees[ei]; - for (const e of employees) { const tss = await prisma.timesheets.findMany({ where: { employee_id: e.id }, select: { id: true }, + orderBy: { id: 'asc' }, // ajuste si tu préfères "created_at" etc. }); if (!tss.length) continue; - // 10 shifts / employee - for (let i = 0; i < 10; i++) { - const ts = tss[i % tss.length]; - const bc = bankCodes[i % bankCodes.length]; - const date = daysAgo(7 + i); // la dernière quinzaine - const startH = 8 + (i % 3); // 8..10 - const endH = startH + 7 + (i % 2); // 15..17 + // Horaires spécifiques à l’employé (déterministes) + const startHalfHourSlot = ei % 4; // 0..3 -> 7:00, 7:30, 8:00, 8:30 + const startHourBase = 7 + Math.floor(startHalfHourSlot / 2); // 7 ou 8 + const startMinuteBase = (startHalfHourSlot % 2) * 30; // 0 ou 30 + + // Doit-on donner un jour "différent" de G1 à cet employé ? + const isSpecial = (ei % 5) === 0; // 1 sur 5 + const specialDayIdx = isSpecial ? Math.floor(Math.random() * 5) : -1; + const specialCode = isSpecial ? (Math.random() < 0.5 ? 'G305' : 'G105') : 'G1'; + + // 5 jours (lun→ven) + for (let di = 0; di < weekDays.length; di++) { + const date = weekDays[di]; + + // Bank code du jour + const codeToday = (di === specialDayIdx) ? specialCode : 'G1'; + const bank_code_id = bcMap.get(codeToday)!; + + // Durée : 8h habituellement, 7h le vendredi pour varier (di==4) + const duration = (di === 4) ? 7 : 8; + + // Légère variation journalière (+0..2h) pour casser la monotonie, mais bornée + const dayOffset = di % 3; // 0,1,2 + const startH = Math.min(10, startHourBase + dayOffset); + const startM = startMinuteBase; + + const endH = startH + duration; + const endM = startM; + + const ts = tss[di % tss.length]; await prisma.shifts.create({ data: { timesheet_id: ts.id, - bank_code_id: bc.id, - description: `Shift ${i + 1} for emp ${e.id}`, - date, - start_time: timeAt(startH, 0), - end_time: timeAt(endH, 0), + bank_code_id, + description: `Shift ${di + 1} (Semaine courante) emp ${e.id} — ${codeToday}`, + date, // Date du jour (UTC minuit) + start_time: timeAt(startH, startM), + end_time: timeAt(endH, endM), is_approved: Math.random() < 0.5, }, }); @@ -49,7 +110,7 @@ async function main() { } const total = await prisma.shifts.count(); - console.log(`✓ Shifts: ${total} total rows`); + console.log(`✓ Shifts: ${total} total rows (semaine courante L→V)`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 2da1eb5..1b015c1 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -2,43 +2,111 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -function daysAgo(n:number) { - const d = new Date(); - d.setUTCDate(d.getUTCDate() - n); - d.setUTCHours(0,0,0,0); +// Lundi de la semaine (en UTC) pour la date courante +function mondayOfThisWeekUTC(now = new Date()) { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... + const diffToMonday = (day + 6) % 7; // 0 si lundi + d.setUTCDate(d.getUTCDate() - diffToMonday); + d.setUTCHours(0, 0, 0, 0); return d; } -async function main() { - const expenseCodes = await prisma.bankCodes.findMany({ where: { categorie: 'EXPENSE' }, select: { id: true } }); - if (!expenseCodes.length) throw new Error('Need EXPENSE bank codes'); +// Retourne les 5 dates Lundi→Vendredi (UTC, à minuit) +function currentWeekDates() { + const monday = mondayOfThisWeekUTC(); + return Array.from({ length: 5 }, (_, i) => { + const d = new Date(monday); + d.setUTCDate(monday.getUTCDate() + i); + return d; + }); +} - const timesheets = await prisma.timesheets.findMany({ select: { id: true } }); - if (!timesheets.length) { - console.warn('No timesheets found; aborting expenses seed.'); +function rndInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} +function rndAmount(minCents: number, maxCents: number) { + const cents = rndInt(minCents, maxCents); + return (cents / 100).toFixed(2); // string (ex: "123.45") +} + +async function main() { + // On veut explicitement G503 (mileage) et G517 (remboursement) + const wanted = ['G57', 'G517'] as const; + const codes = await prisma.bankCodes.findMany({ + where: { bank_code: { in: wanted as unknown as string[] } }, + select: { id: true, bank_code: true }, + }); + const map = new Map(codes.map(c => [c.bank_code, c.id])); + for (const c of wanted) { + if (!map.has(c)) throw new Error(`Bank code manquant: ${c}`); + } + + const employees = await prisma.employees.findMany({ select: { id: true } }); + if (!employees.length) { + console.warn('Aucun employé — rien à insérer.'); return; } - // 5 expenses distribuées aléatoirement parmi les employés (via timesheets) - for (let i = 0; i < 5; i++) { - const ts = timesheets[Math.floor(Math.random() * timesheets.length)]; - const bc = expenseCodes[i % expenseCodes.length]; - await prisma.expenses.create({ - data: { - timesheet_id: ts.id, - bank_code_id: bc.id, - date: daysAgo(3 + i), - amount: (50 + i * 10).toFixed(2), - attachement: null, - description: `Expense #${i + 1}`, - is_approved: Math.random() < 0.5, - supervisor_comment: Math.random() < 0.3 ? 'OK' : null, - }, + const weekDays = currentWeekDates(); + + // Règles: + // - (index % 5) === 0 -> mileage G503 (km) + // - (index % 5) === 1 -> remboursement G517 ($) + // Les autres: pas de dépense + // On met la dépense un des jours de la semaine (déterministe mais varié). + let created = 0; + + for (let ei = 0; ei < employees.length; ei++) { + const e = employees[ei]; + + const ts = await prisma.timesheets.findFirst({ + where: { employee_id: e.id }, + select: { id: true }, + orderBy: { id: 'asc' }, // ajuste si tu préfères par date }); + if (!ts) continue; + + const dayIdx = ei % 5; // 0..4 -> répartit sur la semaine + const date = weekDays[dayIdx]; + + if (ei % 5 === 0) { + // Mileage (G503) — amount = km + const km = rndInt(10, 180); // 10..180 km + await prisma.expenses.create({ + data: { + timesheet_id: ts.id, + bank_code_id: map.get('G57')!, + date, + amount: km.toString(), // on stocke le nombre de km dans amount (si tu as un champ "quantity_km", remplace ici) + attachement: null, + description: `Mileage ${km} km (emp ${e.id})`, + is_approved: Math.random() < 0.6, + supervisor_comment: Math.random() < 0.2 ? 'OK' : null, + }, + }); + created++; + } else if (ei % 5 === 1) { + // Remboursement (G517) — amount = $ + const dollars = rndAmount(2000, 25000); // 20.00$..250.00$ + await prisma.expenses.create({ + data: { + timesheet_id: ts.id, + bank_code_id: map.get('G517')!, + date, + amount: dollars, + attachement: null, + description: `Remboursement ${dollars}$ (emp ${e.id})`, + is_approved: Math.random() < 0.6, + supervisor_comment: Math.random() < 0.2 ? 'OK' : null, + }, + }); + created++; + } } const total = await prisma.expenses.count(); - console.log(`✓ Expenses: ${total} total rows`); + console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (semaine courante)`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 53c624b..613be4a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,7 +19,7 @@ model Users { first_name String last_name String email String @unique - phone_number Int @unique + phone_number String @unique residence String? role Roles @default(GUEST) From ea76435f4f850681c34d74cc26cbde40b4ce153b Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 28 Aug 2025 14:43:23 -0400 Subject: [PATCH 08/69] fix(seeder): fix seeders --- prisma/mock-seeds-scripts/09-timesheets.ts | 2 +- prisma/mock-seeds-scripts/10-shifts.ts | 148 +++++++++++------- .../mock-seeds-scripts/11-shifts-archive.ts | 2 +- .../mock-seeds-scripts/13-expenses-archive.ts | 2 +- .../timesheets/dtos/timesheet-period.dto.ts | 2 + .../services/timesheets-query.service.ts | 20 ++- .../timesheets/utils/timesheet.helpers.ts | 8 +- 7 files changed, 117 insertions(+), 67 deletions(-) diff --git a/prisma/mock-seeds-scripts/09-timesheets.ts b/prisma/mock-seeds-scripts/09-timesheets.ts index d0dc15c..1d05345 100644 --- a/prisma/mock-seeds-scripts/09-timesheets.ts +++ b/prisma/mock-seeds-scripts/09-timesheets.ts @@ -10,7 +10,7 @@ async function main() { // 8 timesheets / employee for (const e of employees) { - for (let i = 0; i < 8; i++) { + for (let i = 0; i < 16; i++) { const is_approved = Math.random() < 0.3; rows.push({ employee_id: e.id, is_approved }); } diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index d39c36c..fc6c7a4 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -2,6 +2,10 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); +// ====== Config ====== +const PREVIOUS_WEEKS = 5; // nombre de semaines à générer avant la semaine actuelle +const INCLUDE_CURRENT = false; // passe à true si tu veux aussi générer la semaine actuelle + // Stocker une heure (Postgres TIME) via Date (UTC 1970-01-01) function timeAt(hour: number, minute: number) { return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); @@ -9,18 +13,16 @@ function timeAt(hour: number, minute: number) { // Lundi de la semaine (en UTC) pour la date courante function mondayOfThisWeekUTC(now = new Date()) { - // converti en UTC (sans l'heure) const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... - const diffToMonday = (day + 6) % 7; // 0 si lundi, 1 si mardi, ... 6 si dimanche + const diffToMonday = (day + 6) % 7; // 0 si lundi d.setUTCDate(d.getUTCDate() - diffToMonday); d.setUTCHours(0, 0, 0, 0); return d; } -// Retourne les 5 dates Lundi→Vendredi (UTC, à minuit) -function currentWeekDates() { - const monday = mondayOfThisWeekUTC(); +// Retourne les 5 dates Lundi→Vendredi (UTC, à minuit) à partir d’un lundi donné +function weekDatesFromMonday(monday: Date) { return Array.from({ length: 5 }, (_, i) => { const d = new Date(monday); d.setUTCDate(monday.getUTCDate() + i); @@ -28,89 +30,119 @@ function currentWeekDates() { }); } +// Lundi n semaines avant un lundi donné +function mondayNWeeksBefore(monday: Date, n: number) { + const d = new Date(monday); + d.setUTCDate(d.getUTCDate() - n * 7); + return d; +} + +// Random int inclusif +function rndInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + async function main() { - // On récupère les bank codes requis (ajuste le nom de la colonne "code" si besoin) + // Bank codes utilisés const BANKS = ['G1', 'G305', 'G105'] as const; const bcRows = await prisma.bankCodes.findMany({ where: { bank_code: { in: BANKS as unknown as string[] } }, select: { id: true, bank_code: true }, }); const bcMap = new Map(bcRows.map(b => [b.bank_code, b.id])); - - // Vérifications for (const c of BANKS) { if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`); } + // Employés + cache des timesheets par employé (évite un findMany dans les boucles) const employees = await prisma.employees.findMany({ select: { id: true } }); if (!employees.length) { console.log('Aucun employé — rien à insérer.'); return; } - - const weekDays = currentWeekDates(); - - // Par défaut: G1 (régulier). 1 employé sur 5 : 1 jour de la semaine passe à G305 OU G105 (au hasard) - // Horaires: on décale par employé pour garantir des horaires différents. - // - start = 7, 7h30, 8, 8h30 selon l’index employé - // - durée = 8h sauf vendredi (7h) pour varier un peu - for (let ei = 0; ei < employees.length; ei++) { - const e = employees[ei]; - - const tss = await prisma.timesheets.findMany({ - where: { employee_id: e.id }, - select: { id: true }, - orderBy: { id: 'asc' }, // ajuste si tu préfères "created_at" etc. + const tsByEmp = new Map(); + { + const allTs = await prisma.timesheets.findMany({ + where: { employee_id: { in: employees.map(e => e.id) } }, + select: { id: true, employee_id: true }, + orderBy: { id: 'asc' }, }); - if (!tss.length) continue; + for (const e of employees) { + tsByEmp.set(e.id, allTs.filter(t => t.employee_id === e.id).map(t => ({ id: t.id }))); + } + } - // Horaires spécifiques à l’employé (déterministes) - const startHalfHourSlot = ei % 4; // 0..3 -> 7:00, 7:30, 8:00, 8:30 - const startHourBase = 7 + Math.floor(startHalfHourSlot / 2); // 7 ou 8 - const startMinuteBase = (startHalfHourSlot % 2) * 30; // 0 ou 30 + // Construit la liste des semaines à insérer + const mondayThisWeek = mondayOfThisWeekUTC(); + const mondays: Date[] = []; - // Doit-on donner un jour "différent" de G1 à cet employé ? - const isSpecial = (ei % 5) === 0; // 1 sur 5 - const specialDayIdx = isSpecial ? Math.floor(Math.random() * 5) : -1; - const specialCode = isSpecial ? (Math.random() < 0.5 ? 'G305' : 'G105') : 'G1'; + if (INCLUDE_CURRENT) mondays.push(mondayThisWeek); + for (let n = 1; n <= PREVIOUS_WEEKS; n++) { + mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); + } - // 5 jours (lun→ven) - for (let di = 0; di < weekDays.length; di++) { - const date = weekDays[di]; + let created = 0; - // Bank code du jour - const codeToday = (di === specialDayIdx) ? specialCode : 'G1'; - const bank_code_id = bcMap.get(codeToday)!; + // Pour chaque semaine à générer + for (let wi = 0; wi < mondays.length; wi++) { + const monday = mondays[wi]; + const weekDays = weekDatesFromMonday(monday); - // Durée : 8h habituellement, 7h le vendredi pour varier (di==4) - const duration = (di === 4) ? 7 : 8; + for (let ei = 0; ei < employees.length; ei++) { + const e = employees[ei]; + const tss = tsByEmp.get(e.id) ?? []; + if (!tss.length) continue; - // Légère variation journalière (+0..2h) pour casser la monotonie, mais bornée - const dayOffset = di % 3; // 0,1,2 - const startH = Math.min(10, startHourBase + dayOffset); - const startM = startMinuteBase; + // Base horaire spécifique à l’employé (garantit la diversité) + // Heures: 6..10 (selon l'index employé) + const baseStartHour = 6 + (ei % 5); // 6,7,8,9,10 + // Minutes: 0, 15, 30, 45 (selon l'index employé) + const baseStartMinute = (ei * 15) % 60; // 0,15,30,45 (répète) - const endH = startH + duration; - const endM = startM; + // 1 employé sur 5 a un jour spécial (G305/G105) par semaine + const isSpecial = (ei % 5) === 0; + const specialDayIdx = isSpecial ? ((ei + wi) % 5) : -1; + const specialCode = isSpecial ? ((ei + wi) % 2 === 0 ? 'G305' : 'G105') : 'G1'; - const ts = tss[di % tss.length]; + // 5 jours (lun→ven) + for (let di = 0; di < weekDays.length; di++) { + const date = weekDays[di]; - await prisma.shifts.create({ - data: { - timesheet_id: ts.id, - bank_code_id, - description: `Shift ${di + 1} (Semaine courante) emp ${e.id} — ${codeToday}`, - date, // Date du jour (UTC minuit) - start_time: timeAt(startH, startM), - end_time: timeAt(endH, endM), - is_approved: Math.random() < 0.5, - }, - }); + // Bank code du jour + const codeToday = (di === specialDayIdx) ? specialCode : 'G1'; + const bank_code_id = bcMap.get(codeToday)!; + + // Durée aléatoire entre 4 et 10 heures + const duration = rndInt(4, 10); + + // Variation jour+semaine pour casser les patterns (décalage 0..2h) + const dayWeekOffset = (di + wi + (ei % 3)) % 3; // 0,1,2 + const startH = Math.min(12, baseStartHour + dayWeekOffset); // borne supérieure prudente + const startM = baseStartMinute; + + const endH = startH + duration; // <= 22 en pratique + const endM = startM; + + const ts = tss[(di + wi) % tss.length]; + + await prisma.shifts.create({ + data: { + timesheet_id: ts.id, + bank_code_id, + description: `Shift ${di + 1} (semaine du ${monday.toISOString().slice(0,10)}) emp ${e.id} — ${codeToday}`, + date, // Date du jour (UTC minuit) + start_time: timeAt(startH, startM), + end_time: timeAt(endH, endM), + is_approved: Math.random() < 0.5, + }, + }); + created++; + } } } const total = await prisma.shifts.count(); - console.log(`✓ Shifts: ${total} total rows (semaine courante L→V)`); + console.log(`✓ Shifts: ${created} nouvelles lignes, ${total} total rows (${INCLUDE_CURRENT ? 'courante +' : ''}${PREVIOUS_WEEKS} semaines précédentes, L→V)`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/11-shifts-archive.ts b/prisma/mock-seeds-scripts/11-shifts-archive.ts index fa22ddb..110031e 100644 --- a/prisma/mock-seeds-scripts/11-shifts-archive.ts +++ b/prisma/mock-seeds-scripts/11-shifts-archive.ts @@ -21,7 +21,7 @@ async function main() { if (!tss.length) continue; const createdShiftIds: number[] = []; - for (let i = 0; i < 30; i++) { + for (let i = 0; i < 8; i++) { const ts = tss[i % tss.length]; const bc = bankCodes[i % bankCodes.length]; const date = daysAgo(200 + i); // bien dans le passé diff --git a/prisma/mock-seeds-scripts/13-expenses-archive.ts b/prisma/mock-seeds-scripts/13-expenses-archive.ts index 3d87908..01ba953 100644 --- a/prisma/mock-seeds-scripts/13-expenses-archive.ts +++ b/prisma/mock-seeds-scripts/13-expenses-archive.ts @@ -17,7 +17,7 @@ async function main() { // ✅ typer pour éviter never[] const created: Expenses[] = []; - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 4; i++) { const ts = timesheets[i % timesheets.length]; const bc = expenseCodes[i % expenseCodes.length]; diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index e383b08..cd98926 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -1,11 +1,13 @@ export class ShiftDto { start: string; end : string; + bank_code: string; is_approved: boolean; } export class ExpenseDto { amount: number; + bank_code: string; is_approved: boolean; } diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index cb6f67f..59af51f 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -53,7 +53,13 @@ export class TimesheetsQueryService { timesheet: { is: { employee_id: employee.id } }, date: { gte: from, lte: to }, }, - select: { date: true,start_time: true, end_time: true, is_approved: true }, + select: { + date: true, + start_time: true, + end_time: true, + is_approved: true, + bank_code: { select: { bank_code: true } }, + }, orderBy: [{date: 'asc'}, { start_time: 'asc' }], }), this.prisma.expenses.findMany({ @@ -61,8 +67,14 @@ export class TimesheetsQueryService { timesheet: { is: { employee_id: employee.id } }, date: { gte: from, lte: to }, }, - select: { date: true, amount: true, is_approved: true, bank_code: { - select: { type: true } }, + select: { + date: true, + amount: true, + is_approved: true, + bank_code: { select: { + type: true, + bank_code: true, + } }, }, orderBy: { date: 'asc' }, }), @@ -73,6 +85,7 @@ export class TimesheetsQueryService { date: shift.date, start_time: shift.start_time, end_time: shift.end_time, + bank_code: shift.bank_code?.bank_code ?? '', is_approved: shift.is_approved ?? true, })); @@ -81,6 +94,7 @@ export class TimesheetsQueryService { amount: typeof (expense.amount as any)?.toNumber() === 'function' ? (expense.amount as any).toNumber() : Number(expense.amount), type: expense.bank_code?.type ?? 'CASH', + bank_code: expense.bank_code?.bank_code ?? '', is_approved: expense.is_approved ?? true, })); diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 7e32901..29e7a1a 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -5,8 +5,8 @@ export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; export type DayKey = typeof DAY_KEYS[number]; //DB line types -export type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean }; -export type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean }; +export type ShiftRow = { date: Date; start_time: Date; end_time: Date; bank_code: string; is_approved?: boolean }; +export type ExpenseRow = { date: Date; amount: number; type: string; bank_code: string; is_approved?: boolean }; export function dayKeyFromDate(date: Date, useUTC = true): DayKey { const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday @@ -119,8 +119,9 @@ export function buildWeek( week.shifts[key].shifts.push({ start: toTimeString(shift.start_time), end : toTimeString(shift.end_time), + bank_code: shift.bank_code, is_approved: shift.is_approved ?? true, - } as ShiftDto); + }); all_approved = all_approved && (shift.is_approved ?? true); } @@ -133,6 +134,7 @@ export function buildWeek( week.expenses[key][bucket].push({ amount: round2(expense.amount), is_approved: expense.is_approved ?? true, + bank_code: expense.bank_code, }); all_approved = all_approved && (expense.is_approved ?? true); } From 0516736fa28aa7da4b234c01e4aba9c4f044d4a5 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 29 Aug 2025 08:28:47 -0400 Subject: [PATCH 09/69] refactor(timesheet): added shift types and expenses type data --- prisma/mock-seeds-scripts/01-bankCodes.ts | 24 +-- prisma/mock-seeds-scripts/12-expenses.ts | 2 +- .../timesheets/dtos/timesheet-period.dto.ts | 11 +- .../services/timesheets-query.service.ts | 71 +++---- .../timesheets/utils/timesheet.helpers.ts | 195 +++++++++++++----- 5 files changed, 194 insertions(+), 109 deletions(-) diff --git a/prisma/mock-seeds-scripts/01-bankCodes.ts b/prisma/mock-seeds-scripts/01-bankCodes.ts index 14d552f..320db5d 100644 --- a/prisma/mock-seeds-scripts/01-bankCodes.ts +++ b/prisma/mock-seeds-scripts/01-bankCodes.ts @@ -5,18 +5,18 @@ const prisma = new PrismaClient(); async function main() { const presets = [ // type, categorie, modifier, bank_code - ['REGULAR' ,'SHIFT', 1.0 , 'G1'], - ['EVENING' ,'SHIFT', 1.25, 'G43'], - ['Emergency','SHIFT', 2 , 'G48'], - ['HOLIDAY' ,'SHIFT', 2.0 , 'G700'], - - - ['EXPENSES','EXPENSE', 1.0 , 'G517'], - ['MILEAGE' ,'EXPENSE', 0.72, 'G57'], - ['PER_DIEM','EXPENSE', 1.0 , 'G502'], - - ['SICK' ,'LEAVE', 1.0, 'G105'], - ['VACATION' ,'LEAVE', 1.0, 'G305'], + ['REGULAR' ,'SHIFT' , 1.0 , 'G1' ], + ['OVERTIME' ,'SHIFT' , 2 , 'G43' ], + ['EMERGENCY' ,'SHIFT' , 2 , 'G48' ], + ['EVENING' ,'SHIFT' , 1.25, 'G56' ], + ['SICK' ,'SHIFT' , 1.0 , 'G105'], + ['PRIME_DISPO','EXPENSE', 1.0 , 'G202'], + ['COMMISSION' ,'EXPENSE', 1.0 , 'G234'], + ['VACATION' ,'SHIFT' , 1.0 , 'G305'], + ['PER_DIEM' ,'EXPENSE', 1.0 , 'G502'], + ['MILEAGE' ,'EXPENSE', 0.72, 'G503'], + ['EXPENSES' ,'EXPENSE', 1.0 , 'G517'], + ['HOLIDAY' ,'SHIFT' , 2.0 , 'G700'], ]; await prisma.bankCodes.createMany({ diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 1b015c1..177e971 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -76,7 +76,7 @@ async function main() { await prisma.expenses.create({ data: { timesheet_id: ts.id, - bank_code_id: map.get('G57')!, + bank_code_id: map.get('G503')!, date, amount: km.toString(), // on stocke le nombre de km dans amount (si tu as un champ "quantity_km", remplace ici) attachement: null, diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index cd98926..d8c42a6 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -1,13 +1,13 @@ export class ShiftDto { start: string; end : string; - bank_code: string; is_approved: boolean; } export class ExpenseDto { amount: number; - bank_code: string; + total_mileage: number; + total_expense: number; is_approved: boolean; } @@ -15,7 +15,10 @@ export type DayShiftsDto = ShiftDto[]; export class DetailedShifts { shifts: DayShiftsDto; - total_hours: number; + regular_hours: number; + evening_hours: number; + overtime_hours: number; + emergency_hours: number; short_date: string; break_durations?: number; } @@ -23,7 +26,7 @@ export class DetailedShifts { export class DayExpensesDto { cash: ExpenseDto[] = []; km : ExpenseDto[] = []; - [otherType:string]: ExpenseDto[] | any; //pour si on ajoute d'autre type de dépenses + [otherType:string]: ExpenseDto[] | any; } export class WeekDto { diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 59af51f..84ad942 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -46,55 +46,52 @@ export class TimesheetsQueryService { 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, - bank_code: { select: { bank_code: true } }, - }, - orderBy: [{date: 'asc'}, { start_time: 'asc' }], - }), - 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, - bank_code: true, - } }, - }, - orderBy: { date: 'asc' }, - }), - ]); + const raw_shifts = await 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, + bank_code: { select: { type: true } }, + }, + orderBy:[ { date:'asc'}, { start_time: 'asc'} ], + }); + + const raw_expenses = await 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' }, + }); + + const to_num = (value: any) => typeof value.toNumber === 'function' ? value.toNumber() : Number(value); + // data mapping const shifts: ShiftRow[] = raw_shifts.map(shift => ({ date: shift.date, start_time: shift.start_time, end_time: shift.end_time, - bank_code: shift.bank_code?.bank_code ?? '', + type: String(shift.bank_code?.type ?? '').toUpperCase(), is_approved: shift.is_approved ?? true, })); const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ date: expense.date, - amount: typeof (expense.amount as any)?.toNumber() === 'function' ? + amount: typeof (expense.amount as any)?.toNumber === 'function' ? (expense.amount as any).toNumber() : Number(expense.amount), - type: expense.bank_code?.type ?? 'CASH', - bank_code: expense.bank_code?.bank_code ?? '', + type: String(expense.bank_code?.type ?? '').toUpperCase(), is_approved: expense.is_approved ?? true, })); diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 29e7a1a..c36a4c4 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -4,18 +4,39 @@ import { DayExpensesDto, DayShiftsDto, DetailedShifts, ShiftDto, TimesheetPeriod export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; export type DayKey = typeof DAY_KEYS[number]; -//DB line types -export type ShiftRow = { date: Date; start_time: Date; end_time: Date; bank_code: string; is_approved?: boolean }; -export type ExpenseRow = { date: Date; amount: number; type: string; bank_code: 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]; } +//Date & Format const MS_PER_DAY = 86_400_000; const MS_PER_HOUR = 3_600_000; +// Types +const SHIFT_TYPES = { + REGULAR: 'REGULAR', + EVENING: 'EVENING', + OVERTIME: 'OVERTIME', + EMERGENCY: 'EMERGENCY', + HOLIDAY: 'HOLIDAY', + VACATION: 'VACATION', + SICK: 'SICK', +} as const; + +const EXPENSE_TYPES = { + MILEAGE: 'MILEAGE', + EXPENSE: 'EXPENSES', + PER_DIEM: 'PER_DIEM', + COMMISSION: 'COMMISSION', + PRIME_DISPO: 'PRIME_DISPO', +} as const; + +//DB line types +export type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean; type: string }; +export type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean }; + +//helper functions export function toUTCDateOnly(date: Date | string): Date { const d = new Date(date); return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); @@ -45,22 +66,25 @@ export function round2(num: number) { return Math.round(num * 100) / 100; } + function shortDate(date:Date): string { const mm = String(date.getUTCMonth()+1).padStart(2,'0'); const dd = String(date.getUTCDate()).padStart(2,'0'); return `${mm}/${dd}`; } -// export function makeEmptyDayShifts(): DayShiftsDto { return []; } - +// Factories export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; } export function makeEmptyWeek(week_start: Date): WeekDto { const make_empty_shifts = (offset: number): DetailedShifts => ({ shifts: [], - total_hours: 0, + regular_hours: 0, + evening_hours: 0, + emergency_hours: 0, + overtime_hours: 0, short_date: shortDate(addDays(week_start, offset)), - break_durations: undefined, + break_durations: 0, }); return { is_approved: true, @@ -89,13 +113,6 @@ export function makeEmptyPeriod(): TimesheetPeriodDto { return { week1: makeEmptyWeek(new Date()), week2: makeEmptyWeek(new Date()) }; } -//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, @@ -105,61 +122,127 @@ export function buildWeek( const week = makeEmptyWeek(week_start); let all_approved = true; - //array of shifts per day ( to check for break_gaps and calculate daily total hours ) - const dayTimes: Record> = { - sun: [], mon: [], tue: [], wed: [],thu: [], fri: [], sat: [], - }; + //breaks + const day_times: Record> = DAY_KEYS.reduce((acc, key) => { + acc[key] = []; return acc; + }, {} as Record>); + //shifts's hour by type + type ShiftsHours = + {regular: number; evening: number; overtime: number; emergency: number; sick: number; vacation: number; holiday: number;}; + const make_hours = (): ShiftsHours => + ({ regular: 0, evening: 0, overtime: 0, emergency: 0, sick: 0, vacation: 0, holiday: 0 }); + const day_hours: Record = DAY_KEYS.reduce((acc, key) => { + acc[key] = make_hours(); return acc; + }, {} as Record); - //Shifts mapped and filtered by dates + //expenses's amount by type + type ExpensesAmount = + {mileage: number; expense: number; per_diem: number; commission: number; prime_dispo: number }; + const make_amounts = (): ExpensesAmount => + ({ mileage: 0, expense: 0, per_diem: 0, commission: 0, prime_dispo: 0 }); + const day_amounts: Record = DAY_KEYS.reduce((acc, key) => { + acc[key] = make_amounts(); return acc; + }, {} as Record); + + const dayExpenseRows: Record = DAY_KEYS.reduce((acc, key) => { + acc[key] = {km: [], cash: [] }; return acc; + }, {} as Record); + + //regroup hours per type of shifts 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); - dayTimes[key].push({start: shift.start_time, end:shift.end_time }); + for (const shift of shifts) { + const key = dayKeyFromDate(shift.date, true); week.shifts[key].shifts.push({ start: toTimeString(shift.start_time), - end : toTimeString(shift.end_time), - bank_code: shift.bank_code, + end: toTimeString(shift.end_time), is_approved: shift.is_approved ?? true, - }); - all_approved = all_approved && (shift.is_approved ?? true); + } as ShiftDto); + + day_times[key].push({ start: shift.start_time, end: shift.end_time}); + + const duration = Math.max(0, (shift.end_time.getTime() - shift.start_time.getTime())/ MS_PER_HOUR); + const type = (shift.type || '').toUpperCase(); + + if (type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration; + else if( type === SHIFT_TYPES.EVENING) day_hours[key].evening += duration; + else if( type === SHIFT_TYPES.EMERGENCY) day_hours[key].emergency += duration; + else if( type === SHIFT_TYPES.OVERTIME) day_hours[key].overtime += duration; + else if( type === SHIFT_TYPES.SICK) day_hours[key].sick += duration; + else if( type === SHIFT_TYPES.VACATION) day_hours[key].vacation += duration; + else if( type === SHIFT_TYPES.HOLIDAY) day_hours[key].holiday += duration; + + all_approved = all_approved && (shift.is_approved ?? true ); } - - //Expenses mapped and filtered by dates + + //regroupe amounts to type of expenses 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, - bank_code: expense.bank_code, - }); - all_approved = all_approved && (expense.is_approved ?? true); + const key = dayKeyFromDate(expense.date, true); + const type = (expense.type || '').toUpperCase(); + + if (type === EXPENSE_TYPES.MILEAGE) { + day_amounts[key].mileage += expense.amount; + dayExpenseRows[key].km.push(expense); + } else if (type === EXPENSE_TYPES.EXPENSE) { + day_amounts[key].expense += expense.amount; + dayExpenseRows[key].cash.push(expense) + } else if (type === EXPENSE_TYPES.PER_DIEM) { + day_amounts[key].per_diem += expense.amount; + dayExpenseRows[key].cash.push(expense) + } else if (type === EXPENSE_TYPES.COMMISSION) { + day_amounts[key].commission += expense.amount; + dayExpenseRows[key].cash.push(expense) + } else if (type === EXPENSE_TYPES.PRIME_DISPO) { + day_amounts[key].prime_dispo += expense.amount; + dayExpenseRows[key].cash.push(expense) + } + all_approved = all_approved && (expense.is_approved ?? true ); } + for (const key of DAY_KEYS) { - //sorts shifts in chronological order - const times = dayTimes[key].sort((a,b) => a.start.getTime() - b.start.getTime()); + //return exposed dto data + week.shifts[key].regular_hours = round2(day_hours[key].regular); + week.shifts[key].evening_hours = round2(day_hours[key].evening); + week.shifts[key].overtime_hours = round2(day_hours[key].overtime); + week.shifts[key].emergency_hours = round2(day_hours[key].emergency); - //daily total hours - const total = times.reduce((sum, time) => { - const duration = (time.end.getTime() - time.start.getTime()) / MS_PER_HOUR; - return sum + Math.max(0, duration); - }, 0); - week.shifts[key].total_hours = round2(total); + //calculate gaps between shifts + const times = day_times[key].sort((a,b) => a.start.getTime() - b.start.getTime()); + let gaps = 0; + for (let i = 1; i < times.length; i++) { + const gap = (times[i].start.getTime() - times[i - 1].end.getTime()) / MS_PER_HOUR; + if(gap > 0) gaps += gap; + } + week.shifts[key].break_durations = round2(gaps); - //break_duration - if (times.length >= 2) { - let break_gaps = 0; - for (let i = 1; i < times.length; i++) { - const gap = (times[i].start.getTime() - times[i-1].end.getTime()) / MS_PER_HOUR; - if(gap > 0) break_gaps += gap; - } - if(break_gaps > 0) week.shifts[key].break_durations = round2(break_gaps); + //daily totals + const totals = day_amounts[key]; + const total_mileage = totals.mileage; + const total_expense = totals.expense + totals.per_diem + totals.commission + totals.prime_dispo + totals.expense; + + //pushing mileage rows + for(const row of dayExpenseRows[key].km) { + week.expenses[key].km.push({ + amount: round2(row.amount), + total_mileage: round2(total_mileage), + total_expense: round2(total_expense), + is_approved: row.is_approved ?? true, + }); + } + + //pushing expense rows + for(const row of dayExpenseRows[key].cash) { + week.expenses[key].cash.push({ + amount: round2(row.amount), + total_mileage: round2(total_mileage), + total_expense: round2(total_expense), + is_approved: row.is_approved ?? true, + }); } } + week.is_approved = all_approved; return week; } @@ -179,4 +262,6 @@ export function buildPeriod( week1: buildWeek(week1_start, week1_end, shifts, expenses), week2: buildWeek(week2_start, week2_end, shifts, expenses), }; -} \ No newline at end of file +} + + From 4bb42ec3edde9d096a0ff989dbfc45a7b299f593 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 29 Aug 2025 09:34:30 -0400 Subject: [PATCH 10/69] fix(seeds): added larger scope of data to print --- prisma/mock-seeds-scripts/02-users.ts | 34 +++++-- prisma/mock-seeds-scripts/03-employees.ts | 58 ++++++------ prisma/mock-seeds-scripts/10-shifts.ts | 51 ++++------- prisma/mock-seeds-scripts/12-expenses.ts | 105 +++++++++++----------- 4 files changed, 127 insertions(+), 121 deletions(-) diff --git a/prisma/mock-seeds-scripts/02-users.ts b/prisma/mock-seeds-scripts/02-users.ts index 0352eb9..04ec7e4 100644 --- a/prisma/mock-seeds-scripts/02-users.ts +++ b/prisma/mock-seeds-scripts/02-users.ts @@ -24,15 +24,19 @@ async function main() { const pick = (arr: T[]) => arr[Math.floor(Math.random() * arr.length)]; + // 40 employees, avec une distribution initiale const rolesForEmployees: Roles[] = [ Roles.ADMIN, - ...Array(4).fill(Roles.SUPERVISOR), + ...Array(4).fill(Roles.SUPERVISOR), // 4 superviseurs Roles.HR, Roles.ACCOUNTING, ...Array(33).fill(Roles.EMPLOYEE), ]; - // 40 employees + // --- Normalisation : forcer user5@example.test à SUPERVISOR --- + // user5 => index 4 (i = 4) + rolesForEmployees[4] = Roles.SUPERVISOR; + for (let i = 0; i < 40; i++) { const fn = pick(firstNames); const ln = pick(lastNames); @@ -40,8 +44,7 @@ async function main() { first_name: fn, last_name: ln, email: emailFor(i), - // on concatène proprement en string - phone_number: BASE_PHONE + i.toString(), + phone_number: BASE_PHONE + i.toString(), residence: Math.random() < 0.5 ? 'QC' : 'ON', role: rolesForEmployees[i], }); @@ -61,8 +64,29 @@ async function main() { }); } + // 1) Insert (sans doublons) await prisma.users.createMany({ data: usersData, skipDuplicates: true }); - console.log('✓ Users: 50 rows (40 employees, 10 customers)'); + + // 2) Validation/Correction post-insert : + // - garantir que user5@example.test est SUPERVISOR + // - si jamais le projet avait un user avec la typo, on tente aussi de le corriger (fallback) + const targetEmails = ['user5@example.test', 'user5@examplte.tset']; + for (const email of targetEmails) { + try { + await prisma.users.update({ + where: { email }, + data: { role: Roles.SUPERVISOR }, + }); + console.log(`✓ Validation: ${email} est SUPERVISOR`); + break; // on s'arrête dès qu'on a corrigé l'un des deux + } catch { + // ignore si non trouvé, on tente l'autre + } + } + + // 3) Petite vérif : compter les superviseurs pour sanity check + const supCount = await prisma.users.count({ where: { role: Roles.SUPERVISOR } }); + console.log(`✓ Users: 50 rows (40 employees, 10 customers) — SUPERVISORS: ${supCount}`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/03-employees.ts b/prisma/mock-seeds-scripts/03-employees.ts index 3076683..7377971 100644 --- a/prisma/mock-seeds-scripts/03-employees.ts +++ b/prisma/mock-seeds-scripts/03-employees.ts @@ -12,7 +12,7 @@ function randomPastDate(yearsBack = 3) { past.setFullYear(now.getFullYear() - yearsBack); const t = randInt(past.getTime(), now.getTime()); const d = new Date(t); - d.setHours(0,0,0,0); + d.setHours(0, 0, 0, 0); return d; } @@ -29,7 +29,12 @@ const jobTitles = [ ]; function randomTitle() { - return jobTitles[randInt(0, jobTitles.length -1)]; + return jobTitles[randInt(0, jobTitles.length - 1)]; +} + +// Sélection aléatoire entre 271583 et 271585 +function randomCompanyCode() { + return Math.random() < 0.5 ? 271583 : 271585; } async function main() { @@ -38,40 +43,41 @@ async function main() { orderBy: { email: 'asc' }, }); - // Create supervisors first - const supervisorUsers = employeeUsers.filter(u => u.role === Roles.SUPERVISOR); - const supervisorEmployeeIds: number[] = []; - - for (const u of supervisorUsers) { - const emp = await prisma.employees.upsert({ - where: { user_id: u.id }, - update: {}, - create: { - user_id: u.id, - external_payroll_id: randInt(10000, 99999), - company_code: randInt(1, 5), - first_work_day: randomPastDate(3), - last_work_day: null, - job_title: randomTitle(), - is_supervisor: true, - }, - }); - supervisorEmployeeIds.push(emp.id); + // 1) Trouver le user qui sera le superviseur fixe + const supervisorUser = await prisma.users.findUnique({ + where: { email: 'user5@examplte.test' }, + }); + if (!supervisorUser) { + throw new Error("Le user 'user5@examplte.test' n'existe pas !"); } - // Create remaining employees, assign a random supervisor (admin can have none) + // 2) Créer ou récupérer son employee avec is_supervisor = true + const supervisorEmp = await prisma.employees.upsert({ + where: { user_id: supervisorUser.id }, + update: { is_supervisor: true }, + create: { + user_id: supervisorUser.id, + external_payroll_id: randInt(10000, 99999), + company_code: randomCompanyCode(), + first_work_day: randomPastDate(3), + last_work_day: null, + job_title: randomTitle(), + is_supervisor: true, + }, + }); + + // 3) Créer tous les autres employés avec ce superviseur (sauf ADMIN qui n’a pas de superviseur) for (const u of employeeUsers) { const already = await prisma.employees.findUnique({ where: { user_id: u.id } }); if (already) continue; - const supervisor_id = - u.role === Roles.ADMIN ? null : supervisorEmployeeIds[randInt(0, supervisorEmployeeIds.length - 1)]; + const supervisor_id = u.role === Roles.ADMIN ? null : supervisorEmp.id; await prisma.employees.create({ data: { user_id: u.id, external_payroll_id: randInt(10000, 99999), - company_code: randInt(1, 5), + company_code: randomCompanyCode(), first_work_day: randomPastDate(3), last_work_day: null, supervisor_id, @@ -81,7 +87,7 @@ async function main() { } const total = await prisma.employees.count(); - console.log(`✓ Employees: ${total} rows (with supervisors linked)`); + console.log(`✓ Employees: ${total} rows (supervisor = ${supervisorUser.email})`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index fc6c7a4..dd89b47 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -3,25 +3,22 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); // ====== Config ====== -const PREVIOUS_WEEKS = 5; // nombre de semaines à générer avant la semaine actuelle -const INCLUDE_CURRENT = false; // passe à true si tu veux aussi générer la semaine actuelle +const PREVIOUS_WEEKS = 5; +const INCLUDE_CURRENT = false; -// Stocker une heure (Postgres TIME) via Date (UTC 1970-01-01) function timeAt(hour: number, minute: number) { return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); } -// Lundi de la semaine (en UTC) pour la date courante function mondayOfThisWeekUTC(now = new Date()) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... - const diffToMonday = (day + 6) % 7; // 0 si lundi + const day = d.getUTCDay(); + const diffToMonday = (day + 6) % 7; d.setUTCDate(d.getUTCDate() - diffToMonday); d.setUTCHours(0, 0, 0, 0); return d; } -// Retourne les 5 dates Lundi→Vendredi (UTC, à minuit) à partir d’un lundi donné function weekDatesFromMonday(monday: Date) { return Array.from({ length: 5 }, (_, i) => { const d = new Date(monday); @@ -30,21 +27,19 @@ function weekDatesFromMonday(monday: Date) { }); } -// Lundi n semaines avant un lundi donné function mondayNWeeksBefore(monday: Date, n: number) { const d = new Date(monday); d.setUTCDate(d.getUTCDate() - n * 7); return d; } -// Random int inclusif function rndInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; } async function main() { // Bank codes utilisés - const BANKS = ['G1', 'G305', 'G105'] as const; + const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305', 'G43'] as const; const bcRows = await prisma.bankCodes.findMany({ where: { bank_code: { in: BANKS as unknown as string[] } }, select: { id: true, bank_code: true }, @@ -54,12 +49,12 @@ async function main() { if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`); } - // Employés + cache des timesheets par employé (évite un findMany dans les boucles) const employees = await prisma.employees.findMany({ select: { id: true } }); if (!employees.length) { console.log('Aucun employé — rien à insérer.'); return; } + const tsByEmp = new Map(); { const allTs = await prisma.timesheets.findMany({ @@ -72,7 +67,6 @@ async function main() { } } - // Construit la liste des semaines à insérer const mondayThisWeek = mondayOfThisWeekUTC(); const mondays: Date[] = []; @@ -83,7 +77,6 @@ async function main() { let created = 0; - // Pour chaque semaine à générer for (let wi = 0; wi < mondays.length; wi++) { const monday = mondays[wi]; const weekDays = weekDatesFromMonday(monday); @@ -93,34 +86,22 @@ async function main() { const tss = tsByEmp.get(e.id) ?? []; if (!tss.length) continue; - // Base horaire spécifique à l’employé (garantit la diversité) - // Heures: 6..10 (selon l'index employé) - const baseStartHour = 6 + (ei % 5); // 6,7,8,9,10 - // Minutes: 0, 15, 30, 45 (selon l'index employé) - const baseStartMinute = (ei * 15) % 60; // 0,15,30,45 (répète) + const baseStartHour = 6 + (ei % 5); + const baseStartMinute = (ei * 15) % 60; - // 1 employé sur 5 a un jour spécial (G305/G105) par semaine - const isSpecial = (ei % 5) === 0; - const specialDayIdx = isSpecial ? ((ei + wi) % 5) : -1; - const specialCode = isSpecial ? ((ei + wi) % 2 === 0 ? 'G305' : 'G105') : 'G1'; - - // 5 jours (lun→ven) for (let di = 0; di < weekDays.length; di++) { const date = weekDays[di]; - // Bank code du jour - const codeToday = (di === specialDayIdx) ? specialCode : 'G1'; - const bank_code_id = bcMap.get(codeToday)!; + // Tirage aléatoire du bank_code + const randomCode = BANKS[Math.floor(Math.random() * BANKS.length)]; + const bank_code_id = bcMap.get(randomCode)!; - // Durée aléatoire entre 4 et 10 heures const duration = rndInt(4, 10); - // Variation jour+semaine pour casser les patterns (décalage 0..2h) - const dayWeekOffset = (di + wi + (ei % 3)) % 3; // 0,1,2 - const startH = Math.min(12, baseStartHour + dayWeekOffset); // borne supérieure prudente + const dayWeekOffset = (di + wi + (ei % 3)) % 3; + const startH = Math.min(12, baseStartHour + dayWeekOffset); const startM = baseStartMinute; - - const endH = startH + duration; // <= 22 en pratique + const endH = startH + duration; const endM = startM; const ts = tss[(di + wi) % tss.length]; @@ -129,8 +110,8 @@ async function main() { data: { timesheet_id: ts.id, bank_code_id, - description: `Shift ${di + 1} (semaine du ${monday.toISOString().slice(0,10)}) emp ${e.id} — ${codeToday}`, - date, // Date du jour (UTC minuit) + description: `Shift ${di + 1} (semaine du ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${randomCode}`, + date, start_time: timeAt(startH, startM), end_time: timeAt(endH, endM), is_approved: Math.random() < 0.5, diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 177e971..c6fd430 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -// Lundi de la semaine (en UTC) pour la date courante +// Lundi (UTC) de la semaine courante function mondayOfThisWeekUTC(now = new Date()) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... @@ -12,7 +12,7 @@ function mondayOfThisWeekUTC(now = new Date()) { return d; } -// Retourne les 5 dates Lundi→Vendredi (UTC, à minuit) +// Dates Lundi→Vendredi (UTC minuit) function currentWeekDates() { const monday = mondayOfThisWeekUTC(); return Array.from({ length: 5 }, (_, i) => { @@ -27,19 +27,19 @@ function rndInt(min: number, max: number) { } function rndAmount(minCents: number, maxCents: number) { const cents = rndInt(minCents, maxCents); - return (cents / 100).toFixed(2); // string (ex: "123.45") + return (cents / 100).toFixed(2); // string "123.45" } async function main() { - // On veut explicitement G503 (mileage) et G517 (remboursement) - const wanted = ['G57', 'G517'] as const; - const codes = await prisma.bankCodes.findMany({ - where: { bank_code: { in: wanted as unknown as string[] } }, + // Codes autorisés (aléatoires à chaque dépense) + const BANKS = ['G517', 'G57', 'G502', 'G202', 'G234'] as const; + const bcRows = await prisma.bankCodes.findMany({ + where: { bank_code: { in: BANKS as unknown as string[] } }, select: { id: true, bank_code: true }, }); - const map = new Map(codes.map(c => [c.bank_code, c.id])); - for (const c of wanted) { - if (!map.has(c)) throw new Error(`Bank code manquant: ${c}`); + const bcMap = new Map(bcRows.map(c => [c.bank_code, c.id])); + for (const c of BANKS) { + if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`); } const employees = await prisma.employees.findMany({ select: { id: true } }); @@ -49,64 +49,59 @@ async function main() { } const weekDays = currentWeekDates(); + const monday = weekDays[0]; + const friday = weekDays[4]; - // Règles: - // - (index % 5) === 0 -> mileage G503 (km) - // - (index % 5) === 1 -> remboursement G517 ($) - // Les autres: pas de dépense - // On met la dépense un des jours de la semaine (déterministe mais varié). let created = 0; - for (let ei = 0; ei < employees.length; ei++) { - const e = employees[ei]; - + for (const e of employees) { + // Choisir un timesheet (le plus ancien, ou change 'asc'→'desc' si tu préfères le plus récent) const ts = await prisma.timesheets.findFirst({ where: { employee_id: e.id }, select: { id: true }, - orderBy: { id: 'asc' }, // ajuste si tu préfères par date + orderBy: { id: 'asc' }, }); if (!ts) continue; - const dayIdx = ei % 5; // 0..4 -> répartit sur la semaine - const date = weekDays[dayIdx]; + // Si l’employé a déjà une dépense cette semaine, on n’en recrée pas (≥1 garanti) + const already = await prisma.expenses.findFirst({ + where: { + timesheet_id: ts.id, + date: { gte: monday, lte: friday }, + }, + select: { id: true }, + }); + if (already) continue; - if (ei % 5 === 0) { - // Mileage (G503) — amount = km - const km = rndInt(10, 180); // 10..180 km - await prisma.expenses.create({ - data: { - timesheet_id: ts.id, - bank_code_id: map.get('G503')!, - date, - amount: km.toString(), // on stocke le nombre de km dans amount (si tu as un champ "quantity_km", remplace ici) - attachement: null, - description: `Mileage ${km} km (emp ${e.id})`, - is_approved: Math.random() < 0.6, - supervisor_comment: Math.random() < 0.2 ? 'OK' : null, - }, - }); - created++; - } else if (ei % 5 === 1) { - // Remboursement (G517) — amount = $ - const dollars = rndAmount(2000, 25000); // 20.00$..250.00$ - await prisma.expenses.create({ - data: { - timesheet_id: ts.id, - bank_code_id: map.get('G517')!, - date, - amount: dollars, - attachement: null, - description: `Remboursement ${dollars}$ (emp ${e.id})`, - is_approved: Math.random() < 0.6, - supervisor_comment: Math.random() < 0.2 ? 'OK' : null, - }, - }); - created++; - } + // Choix aléatoire du code + jour + const randomCode = BANKS[Math.floor(Math.random() * BANKS.length)]; + const bank_code_id = bcMap.get(randomCode)!; + const date = weekDays[Math.floor(Math.random() * weekDays.length)]; + + // Montant aléatoire (ranges par défaut en $ — ajuste au besoin) + // (ex.: G57 plus petit, G517 remboursement plus large) + const amount = + randomCode === 'G57' + ? rndAmount(1000, 7500) // 10.00..75.00 + : rndAmount(2000, 25000); // 20.00..250.00 pour les autres + + await prisma.expenses.create({ + data: { + timesheet_id: ts.id, + bank_code_id, + date, + amount, // stocké en string + attachement: null, // garde le champ tel quel si typo volontaire + description: `Expense ${randomCode} ${amount}$ (emp ${e.id})`, + is_approved: Math.random() < 0.6, + supervisor_comment: Math.random() < 0.2 ? 'OK' : null, + }, + }); + created++; } const total = await prisma.expenses.count(); - console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (semaine courante)`); + console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (≥1 expense/employee pour la semaine courante)`); } main().finally(() => prisma.$disconnect()); From 3fad5326853ac46e976a81549a648c4f825bbbb1 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 29 Aug 2025 09:36:13 -0400 Subject: [PATCH 11/69] fix(seeds): small typo to seed employees --- prisma/mock-seeds-scripts/03-employees.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/prisma/mock-seeds-scripts/03-employees.ts b/prisma/mock-seeds-scripts/03-employees.ts index 7377971..a0267fd 100644 --- a/prisma/mock-seeds-scripts/03-employees.ts +++ b/prisma/mock-seeds-scripts/03-employees.ts @@ -45,10 +45,10 @@ async function main() { // 1) Trouver le user qui sera le superviseur fixe const supervisorUser = await prisma.users.findUnique({ - where: { email: 'user5@examplte.test' }, + where: { email: 'user5@example.test' }, }); if (!supervisorUser) { - throw new Error("Le user 'user5@examplte.test' n'existe pas !"); + throw new Error("Le user 'user5@example.test' n'existe pas !"); } // 2) Créer ou récupérer son employee avec is_supervisor = true From 770ed8cf6422d9c42be37f9ffaa7a7f110221fc0 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 29 Aug 2025 09:57:12 -0400 Subject: [PATCH 12/69] fix(seeds): many fixes to match data needed to print --- .../05-employees-archive.ts | 5 ++ .../06-customers-archive.ts | 6 +++ .../07-leave-requests-future.ts | 5 ++ .../08-leave-requests-archive.ts | 48 +++++++++++++------ .../mock-seeds-scripts/11-shifts-archive.ts | 5 ++ prisma/mock-seeds-scripts/12-expenses.ts | 4 +- .../mock-seeds-scripts/13-expenses-archive.ts | 5 ++ 7 files changed, 62 insertions(+), 16 deletions(-) diff --git a/prisma/mock-seeds-scripts/05-employees-archive.ts b/prisma/mock-seeds-scripts/05-employees-archive.ts index 08e457c..1687b23 100644 --- a/prisma/mock-seeds-scripts/05-employees-archive.ts +++ b/prisma/mock-seeds-scripts/05-employees-archive.ts @@ -1,5 +1,10 @@ import { PrismaClient } from '@prisma/client'; +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log("â­ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + process.exit(0); +} + const prisma = new PrismaClient(); function daysAgo(n: number) { diff --git a/prisma/mock-seeds-scripts/06-customers-archive.ts b/prisma/mock-seeds-scripts/06-customers-archive.ts index 2f73067..31a7d01 100644 --- a/prisma/mock-seeds-scripts/06-customers-archive.ts +++ b/prisma/mock-seeds-scripts/06-customers-archive.ts @@ -1,5 +1,11 @@ // prisma/mock-seeds-scripts/06-customers-archive.ts import { PrismaClient } from '@prisma/client'; + +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log("â­ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + process.exit(0); +} + const prisma = new PrismaClient(); async function main() { diff --git a/prisma/mock-seeds-scripts/07-leave-requests-future.ts b/prisma/mock-seeds-scripts/07-leave-requests-future.ts index 54f14b8..c5dc5e2 100644 --- a/prisma/mock-seeds-scripts/07-leave-requests-future.ts +++ b/prisma/mock-seeds-scripts/07-leave-requests-future.ts @@ -1,5 +1,10 @@ import { PrismaClient, Prisma, LeaveTypes, LeaveApprovalStatus } from '@prisma/client'; +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log("â­ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + process.exit(0); +} + const prisma = new PrismaClient(); function dateOn(y: number, m: number, d: number) { diff --git a/prisma/mock-seeds-scripts/08-leave-requests-archive.ts b/prisma/mock-seeds-scripts/08-leave-requests-archive.ts index 1ef2554..d92a51f 100644 --- a/prisma/mock-seeds-scripts/08-leave-requests-archive.ts +++ b/prisma/mock-seeds-scripts/08-leave-requests-archive.ts @@ -1,44 +1,64 @@ -import { PrismaClient, LeaveTypes, LeaveApprovalStatus, LeaveRequests } from '@prisma/client'; +import { PrismaClient, LeaveApprovalStatus, LeaveRequests } from '@prisma/client'; + +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log("â­ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + process.exit(0); +} const prisma = new PrismaClient(); -function daysAgo(n:number) { +function daysAgo(n: number) { const d = new Date(); d.setUTCDate(d.getUTCDate() - n); - d.setUTCHours(0,0,0,0); + d.setUTCHours(0, 0, 0, 0); return d; } async function main() { + // 1) Récupère tous les employés const employees = await prisma.employees.findMany({ select: { id: true } }); - const bankCodes = await prisma.bankCodes.findMany({ select: { id: true }, where: { categorie: 'LEAVE' } }); + if (!employees.length) { + throw new Error('Aucun employé trouvé. Exécute le seed employees avant celui-ci.'); + } + + // 2) Va chercher les bank codes dont le type est SICK, VACATION ou HOLIDAY + const leaveCodes = await prisma.bankCodes.findMany({ + where: { type: { in: ['SICK', 'VACATION'] } }, + select: { id: true, type: true, bank_code: true }, + }); + if (!leaveCodes.length) { + throw new Error("Aucun bank code trouvé avec type in ('SICK','VACATION','HOLIDAY'). Vérifie ta table bank_codes."); + } - const types = Object.values(LeaveTypes); const statuses = Object.values(LeaveApprovalStatus); - const created: LeaveRequests[] = []; - for (let i = 0; i < 10; i++) { + // 3) Crée quelques leave requests + const COUNT = 12; + for (let i = 0; i < COUNT; i++) { const emp = employees[i % employees.length]; - const bc = bankCodes[i % bankCodes.length]; - const start = daysAgo(120 - i * 3); // tous avant aujourd'hui - const end = Math.random() < 0.4 ? null : daysAgo(119 - i * 3); + const leaveCode = leaveCodes[Math.floor(Math.random() * leaveCodes.length)]; + + const start = daysAgo(120 - i * 3); + const end = Math.random() < 0.6 ? daysAgo(119 - i * 3) : null; const lr = await prisma.leaveRequests.create({ data: { employee_id: emp.id, - bank_code_id: bc.id, - leave_type: types[i % types.length], + bank_code_id: leaveCode.id, + // on stocke le "type" tel qu’il est défini dans bank_codes + leave_type: leaveCode.type as any, start_date_time: start, end_date_time: end, - comment: `Past leave #${i+1}`, - approval_status: statuses[(i+2) % statuses.length], + comment: `Past leave #${i + 1} (${leaveCode.type})`, + approval_status: statuses[(i + 2) % statuses.length], }, }); created.push(lr); } + // 4) Archive for (const lr of created) { await prisma.leaveRequestsArchive.create({ data: { diff --git a/prisma/mock-seeds-scripts/11-shifts-archive.ts b/prisma/mock-seeds-scripts/11-shifts-archive.ts index 110031e..a6a78f8 100644 --- a/prisma/mock-seeds-scripts/11-shifts-archive.ts +++ b/prisma/mock-seeds-scripts/11-shifts-archive.ts @@ -1,5 +1,10 @@ import { PrismaClient } from '@prisma/client'; +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log("â­ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + process.exit(0); +} + const prisma = new PrismaClient(); function timeAt(h:number,m:number) { diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index c6fd430..3b4534f 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -32,7 +32,7 @@ function rndAmount(minCents: number, maxCents: number) { async function main() { // Codes autorisés (aléatoires à chaque dépense) - const BANKS = ['G517', 'G57', 'G502', 'G202', 'G234'] as const; + const BANKS = ['G517', 'G56', 'G502', 'G202', 'G234'] as const; const bcRows = await prisma.bankCodes.findMany({ where: { bank_code: { in: BANKS as unknown as string[] } }, select: { id: true, bank_code: true }, @@ -81,7 +81,7 @@ async function main() { // Montant aléatoire (ranges par défaut en $ — ajuste au besoin) // (ex.: G57 plus petit, G517 remboursement plus large) const amount = - randomCode === 'G57' + randomCode === 'G56' ? rndAmount(1000, 7500) // 10.00..75.00 : rndAmount(2000, 25000); // 20.00..250.00 pour les autres diff --git a/prisma/mock-seeds-scripts/13-expenses-archive.ts b/prisma/mock-seeds-scripts/13-expenses-archive.ts index 01ba953..8d9cc54 100644 --- a/prisma/mock-seeds-scripts/13-expenses-archive.ts +++ b/prisma/mock-seeds-scripts/13-expenses-archive.ts @@ -1,6 +1,11 @@ // 13-expenses-archive.ts import { PrismaClient, Expenses } from '@prisma/client'; +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log("â­ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + process.exit(0); +} + const prisma = new PrismaClient(); function daysAgo(n:number) { From eefe82153fa9dfd0683b48e6d4b55b0037097d7b Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 29 Aug 2025 11:03:59 -0400 Subject: [PATCH 13/69] fix(findAll): fix loop for week_shifts --- src/modules/timesheets/utils/timesheet.helpers.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index c36a4c4..c94d64d 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -1,4 +1,4 @@ -import { DayExpensesDto, DayShiftsDto, DetailedShifts, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto"; +import { DayExpensesDto, DetailedShifts, 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; @@ -151,7 +151,7 @@ export function buildWeek( //regroup hours per type of shifts const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end)); - for (const shift of shifts) { + for (const shift of week_shifts) { const key = dayKeyFromDate(shift.date, true); week.shifts[key].shifts.push({ start: toTimeString(shift.start_time), @@ -164,7 +164,7 @@ export function buildWeek( const duration = Math.max(0, (shift.end_time.getTime() - shift.start_time.getTime())/ MS_PER_HOUR); const type = (shift.type || '').toUpperCase(); - if (type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration; + if ( type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration; else if( type === SHIFT_TYPES.EVENING) day_hours[key].evening += duration; else if( type === SHIFT_TYPES.EMERGENCY) day_hours[key].emergency += duration; else if( type === SHIFT_TYPES.OVERTIME) day_hours[key].overtime += duration; @@ -220,7 +220,7 @@ export function buildWeek( //daily totals const totals = day_amounts[key]; const total_mileage = totals.mileage; - const total_expense = totals.expense + totals.per_diem + totals.commission + totals.prime_dispo + totals.expense; + const total_expense = totals.expense + totals.per_diem + totals.commission + totals.prime_dispo; //pushing mileage rows for(const row of dayExpenseRows[key].km) { From 18c1ce38bef88171f9119ee1d9dc29ba538d4e3a Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 29 Aug 2025 11:17:24 -0400 Subject: [PATCH 14/69] fix(timesheets): small typo to_num function --- src/modules/timesheets/services/timesheets-query.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 84ad942..88b725a 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -89,8 +89,8 @@ export class TimesheetsQueryService { 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), + amount: typeof (expense.amount as any)?.to_num === 'function' ? + (expense.amount as any).to_num() : Number(expense.amount), type: String(expense.bank_code?.type ?? '').toUpperCase(), is_approved: expense.is_approved ?? true, })); From c52de6ecb881baf2251b90696f12dead81bb3416 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 29 Aug 2025 11:44:04 -0400 Subject: [PATCH 15/69] fix(seeds): fix timesheet seeds --- docs/swagger/swagger-spec.json | 38 -------------- .../migration.sql | 12 +++++ prisma/mock-seeds-scripts/09-timesheets.ts | 49 ++++++++++++++++--- prisma/mock-seeds-scripts/10-shifts.ts | 37 +++++++------- prisma/mock-seeds-scripts/12-expenses.ts | 46 ++++++++--------- prisma/schema.prisma | 2 + .../services/pay-periods-query.service.ts | 3 +- .../controllers/timesheets.controller.ts | 16 +++--- .../services/timesheets-query.service.ts | 20 ++++---- 9 files changed, 116 insertions(+), 107 deletions(-) create mode 100644 prisma/migrations/20250829152939_timesheet_week_unique/migration.sql diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 41fa85a..430ea23 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -425,44 +425,6 @@ } }, "/timesheets": { - "post": { - "operationId": "TimesheetsController_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTimesheetDto" - } - } - } - }, - "responses": { - "201": { - "description": "Timesheet created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTimesheetDto" - } - } - } - }, - "400": { - "description": "Incomplete task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create timesheet", - "tags": [ - "Timesheets" - ] - }, "get": { "operationId": "TimesheetsController_getPeriodByQuery", "parameters": [ diff --git a/prisma/migrations/20250829152939_timesheet_week_unique/migration.sql b/prisma/migrations/20250829152939_timesheet_week_unique/migration.sql new file mode 100644 index 0000000..8073704 --- /dev/null +++ b/prisma/migrations/20250829152939_timesheet_week_unique/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[employee_id,start_date]` on the table `timesheets` will be added. If there are existing duplicate values, this will fail. + - Added the required column `start_date` to the `timesheets` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "public"."timesheets" ADD COLUMN "start_date" DATE NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "timesheets_employee_id_start_date_key" ON "public"."timesheets"("employee_id", "start_date"); diff --git a/prisma/mock-seeds-scripts/09-timesheets.ts b/prisma/mock-seeds-scripts/09-timesheets.ts index 1d05345..f926fb2 100644 --- a/prisma/mock-seeds-scripts/09-timesheets.ts +++ b/prisma/mock-seeds-scripts/09-timesheets.ts @@ -2,26 +2,59 @@ import { PrismaClient, Prisma } from '@prisma/client'; const prisma = new PrismaClient(); +// ====== Config ====== +const PREVIOUS_WEEKS = 16; // nombre de semaines à créer (passé) +const INCLUDE_CURRENT = false; // true si tu veux aussi la semaine courante + +// Lundi (UTC) de la semaine courante +function mondayOfThisWeekUTC(now = new Date()) { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... + const diffToMonday = (day + 6) % 7; // 0 si lundi + d.setUTCDate(d.getUTCDate() - diffToMonday); + d.setUTCHours(0, 0, 0, 0); + return d; +} +function mondayNWeeksBefore(monday: Date, n: number) { + const d = new Date(monday); + d.setUTCDate(d.getUTCDate() - n * 7); + return d; +} + async function main() { const employees = await prisma.employees.findMany({ select: { id: true } }); + if (!employees.length) { + console.warn('Aucun employé — rien à insérer.'); + return; + } - // ✅ typer rows pour éviter never[] + // Construit la liste des lundis (1 par semaine) + const mondays: Date[] = []; + const mondayThisWeek = mondayOfThisWeekUTC(); + if (INCLUDE_CURRENT) mondays.push(mondayThisWeek); + for (let n = 1; n <= PREVIOUS_WEEKS; n++) { + mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); + } + + // Prépare les lignes (1 timesheet / employé / semaine) const rows: Prisma.TimesheetsCreateManyInput[] = []; - - // 8 timesheets / employee for (const e of employees) { - for (let i = 0; i < 16; i++) { - const is_approved = Math.random() < 0.3; - rows.push({ employee_id: e.id, is_approved }); + for (const monday of mondays) { + rows.push({ + employee_id: e.id, + start_date: monday, + is_approved: Math.random() < 0.3, + } as Prisma.TimesheetsCreateManyInput); } } + // Insert en bulk et ignore les doublons si déjà présents if (rows.length) { - await prisma.timesheets.createMany({ data: rows }); + await prisma.timesheets.createMany({ data: rows, skipDuplicates: true }); } const total = await prisma.timesheets.count(); - console.log(`✓ Timesheets: ${total} rows (added ${rows.length})`); + console.log(`✓ Timesheets: ${total} rows (ajout potentiel: ${rows.length}, ${INCLUDE_CURRENT ? 'courante +' : ''}${PREVIOUS_WEEKS} semaines)`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index dd89b47..9175030 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -6,10 +6,12 @@ const prisma = new PrismaClient(); const PREVIOUS_WEEKS = 5; const INCLUDE_CURRENT = false; +// Times-only via Date (UTC 1970-01-01) function timeAt(hour: number, minute: number) { return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); } +// Lundi (UTC) de la date fournie function mondayOfThisWeekUTC(now = new Date()) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const day = d.getUTCDay(); @@ -37,6 +39,16 @@ function rndInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; } +// Helper: garantit le timesheet de la semaine (upsert) +async function getOrCreateTimesheet(employee_id: number, start_date: Date) { + return prisma.timesheets.upsert({ + where: { employee_id_start_date: { employee_id, start_date } }, + update: {}, + create: { employee_id, start_date, is_approved: Math.random() < 0.3 }, + select: { id: true }, + }); +} + async function main() { // Bank codes utilisés const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305', 'G43'] as const; @@ -55,21 +67,8 @@ async function main() { return; } - const tsByEmp = new Map(); - { - const allTs = await prisma.timesheets.findMany({ - where: { employee_id: { in: employees.map(e => e.id) } }, - select: { id: true, employee_id: true }, - orderBy: { id: 'asc' }, - }); - for (const e of employees) { - tsByEmp.set(e.id, allTs.filter(t => t.employee_id === e.id).map(t => ({ id: t.id }))); - } - } - const mondayThisWeek = mondayOfThisWeekUTC(); const mondays: Date[] = []; - if (INCLUDE_CURRENT) mondays.push(mondayThisWeek); for (let n = 1; n <= PREVIOUS_WEEKS; n++) { mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); @@ -83,8 +82,6 @@ async function main() { for (let ei = 0; ei < employees.length; ei++) { const e = employees[ei]; - const tss = tsByEmp.get(e.id) ?? []; - if (!tss.length) continue; const baseStartHour = 6 + (ei % 5); const baseStartMinute = (ei * 15) % 60; @@ -92,20 +89,22 @@ async function main() { for (let di = 0; di < weekDays.length; di++) { const date = weekDays[di]; - // Tirage aléatoire du bank_code + // 1) Trouver/Créer le timesheet de CETTE semaine pour CET employé + const weekStart = mondayOfThisWeekUTC(date); + const ts = await getOrCreateTimesheet(e.id, weekStart); + + // 2) Tirage aléatoire du bank_code const randomCode = BANKS[Math.floor(Math.random() * BANKS.length)]; const bank_code_id = bcMap.get(randomCode)!; + // 3) Horaire const duration = rndInt(4, 10); - const dayWeekOffset = (di + wi + (ei % 3)) % 3; const startH = Math.min(12, baseStartHour + dayWeekOffset); const startM = baseStartMinute; const endH = startH + duration; const endM = startM; - const ts = tss[(di + wi) % tss.length]; - await prisma.shifts.create({ data: { timesheet_id: ts.id, diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 3b4534f..848daae 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -2,11 +2,11 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -// Lundi (UTC) de la semaine courante +// Lundi (UTC) de la date fournie function mondayOfThisWeekUTC(now = new Date()) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... - const diffToMonday = (day + 6) % 7; // 0 si lundi + const day = d.getUTCDay(); + const diffToMonday = (day + 6) % 7; d.setUTCDate(d.getUTCDate() - diffToMonday); d.setUTCHours(0, 0, 0, 0); return d; @@ -27,7 +27,17 @@ function rndInt(min: number, max: number) { } function rndAmount(minCents: number, maxCents: number) { const cents = rndInt(minCents, maxCents); - return (cents / 100).toFixed(2); // string "123.45" + return (cents / 100).toFixed(2); +} + +// Helper: garantit le timesheet de la semaine (upsert) +async function getOrCreateTimesheet(employee_id: number, start_date: Date) { + return prisma.timesheets.upsert({ + where: { employee_id_start_date: { employee_id, start_date } }, + update: {}, + create: { employee_id, start_date, is_approved: Math.random() < 0.3 }, + select: { id: true }, + }); } async function main() { @@ -55,43 +65,35 @@ async function main() { let created = 0; for (const e of employees) { - // Choisir un timesheet (le plus ancien, ou change 'asc'→'desc' si tu préfères le plus récent) - const ts = await prisma.timesheets.findFirst({ - where: { employee_id: e.id }, - select: { id: true }, - orderBy: { id: 'asc' }, - }); - if (!ts) continue; + // 1) Semaine courante → assurer le timesheet de la semaine + const weekStart = mondayOfThisWeekUTC(); + const ts = await getOrCreateTimesheet(e.id, weekStart); - // Si l’employé a déjà une dépense cette semaine, on n’en recrée pas (≥1 garanti) + // 2) Skip si l’employé a déjà une dépense cette semaine (on garantit ≥1) const already = await prisma.expenses.findFirst({ - where: { - timesheet_id: ts.id, - date: { gte: monday, lte: friday }, - }, + where: { timesheet_id: ts.id, date: { gte: monday, lte: friday } }, select: { id: true }, }); if (already) continue; - // Choix aléatoire du code + jour + // 3) Choix aléatoire du code + jour const randomCode = BANKS[Math.floor(Math.random() * BANKS.length)]; const bank_code_id = bcMap.get(randomCode)!; const date = weekDays[Math.floor(Math.random() * weekDays.length)]; - // Montant aléatoire (ranges par défaut en $ — ajuste au besoin) - // (ex.: G57 plus petit, G517 remboursement plus large) + // 4) Montant varié const amount = randomCode === 'G56' ? rndAmount(1000, 7500) // 10.00..75.00 - : rndAmount(2000, 25000); // 20.00..250.00 pour les autres + : rndAmount(2000, 25000); // 20.00..250.00 await prisma.expenses.create({ data: { timesheet_id: ts.id, bank_code_id, date, - amount, // stocké en string - attachement: null, // garde le champ tel quel si typo volontaire + amount, + attachement: null, description: `Expense ${randomCode} ${amount}$ (emp ${e.id})`, is_approved: Math.random() < 0.6, supervisor_comment: Math.random() < 0.2 ? 'OK' : null, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 613be4a..8d9e700 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -149,12 +149,14 @@ model Timesheets { id Int @id @default(autoincrement()) employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id]) employee_id Int + start_date DateTime @db.Date is_approved Boolean @default(false) shift Shifts[] @relation("ShiftTimesheet") expense Expenses[] @relation("ExpensesTimesheet") archive TimesheetsArchive[] @relation("TimesheetsToArchive") + @@unique([employee_id, start_date], name: "employee_id_start_date") @@map("timesheets") } diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index 9e7cb9b..f0f306a 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -233,8 +233,7 @@ export class PayPeriodsQueryService { const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase(); switch (categorie) { case "EVENING": record.evening_hours += hours; break; - case "EMERGENCY": - case "URGENT": record.emergency_hours += hours; break; + case "EMERGENCY": record.emergency_hours += hours; break; case "OVERTIME": record.overtime_hours += hours; break; default: record.regular_hours += hours; break; } diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index 7280c3c..d575d9b 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -20,14 +20,14 @@ export class TimesheetsController { private readonly timesheetsCommand: TimesheetsCommandService, ) {} - @Post() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Create timesheet' }) - @ApiResponse({ status: 201, description: 'Timesheet created', type: CreateTimesheetDto }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateTimesheetDto): Promise { - return this.timesheetsQuery.create(dto); - } + // @Post() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Create timesheet' }) + // @ApiResponse({ status: 201, description: 'Timesheet created', type: CreateTimesheetDto }) + // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) + // create(@Body() dto: CreateTimesheetDto): Promise { + // return this.timesheetsQuery.create(dto); + // } @Get() //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 88b725a..c28c8fd 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -17,16 +17,16 @@ export class TimesheetsQueryService { private readonly overtime: OvertimeService, ) {} - async create(dto : CreateTimesheetDto): Promise { - const { employee_id, is_approved } = dto; - return this.prisma.timesheets.create({ - data: { employee_id, is_approved: is_approved ?? false }, - include: { - employee: { include: { user: true } - }, - }, - }); - } + // async create(dto : CreateTimesheetDto): Promise { + // const { employee_id, is_approved } = dto; + // return this.prisma.timesheets.create({ + // data: { employee_id, is_approved: is_approved ?? false }, + // include: { + // employee: { include: { user: true } + // }, + // }, + // }); + // } async findAll(year: number, period_no: number, email: string): Promise { //finds the employee From c170481f3b8e4551cacf9132bc009bcf7e104c3b Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 29 Aug 2025 13:16:58 -0400 Subject: [PATCH 16/69] fix(pay-period): switch filters from categorie to type --- .../pay-periods/services/pay-periods-query.service.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index f0f306a..e6e18e6 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -40,6 +40,7 @@ export class PayPeriodsQueryService { } as any); } + //find crew member associated with supervisor private async resolveCrew(supervisor_id: number, include_subtree: boolean): Promise> { const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = []; @@ -69,6 +70,7 @@ export class PayPeriodsQueryService { return result; } + //fetchs crew emails async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise> { const crew = await this.resolveCrew(supervisor_id, include_subtree); return new Set(crew.map(crew_member => crew_member.email).filter(Boolean)); @@ -161,7 +163,7 @@ export class PayPeriodsQueryService { } }, }, }, - bank_code: { select: { categorie: true } }, + bank_code: { select: { categorie: true, type: true } }, }, }); @@ -184,7 +186,7 @@ export class PayPeriodsQueryService { } }, } }, } }, - bank_code: { select: { categorie: true, modifier: true } }, + bank_code: { select: { categorie: true, modifier: true, type: true } }, }, }); @@ -230,12 +232,12 @@ export class PayPeriodsQueryService { const record = ensure(employee.id, name, employee.user.email); const hours = computeHours(shift.start_time, shift.end_time); - const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase(); + const categorie = (shift.bank_code?.type).toUpperCase(); switch (categorie) { case "EVENING": record.evening_hours += hours; break; case "EMERGENCY": record.emergency_hours += hours; break; case "OVERTIME": record.overtime_hours += hours; break; - default: record.regular_hours += hours; break; + case "REGULAR" : record.regular_hours += hours; break; } record.is_approved = record.is_approved && shift.timesheet.is_approved; } From 93cf2d571bcefd1c74bf98db20356c47a88cd892 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 2 Sep 2025 14:29:00 -0400 Subject: [PATCH 17/69] feat(timesheet): added getTimesheetByEmail --- docs/swagger/swagger-spec.json | 36 +++++++ src/common/utils/date-utils.ts | 7 ++ .../controllers/timesheets.controller.ts | 13 ++- .../timesheets/dtos/overview-timesheet.dto.ts | 27 +++++ .../services/timesheets-query.service.ts | 100 +++++++++++++++++- 5 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 src/modules/timesheets/dtos/overview-timesheet.dto.ts diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 430ea23..d35159e 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -468,6 +468,42 @@ ] } }, + "/timesheets/{email}": { + "get": { + "operationId": "TimesheetsController_getByEmail", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "access-token": [] + } + ], + "tags": [ + "Timesheets" + ] + } + }, "/timesheets/{id}": { "get": { "operationId": "TimesheetsController_findOne", diff --git a/src/common/utils/date-utils.ts b/src/common/utils/date-utils.ts index e383f98..5d85548 100644 --- a/src/common/utils/date-utils.ts +++ b/src/common/utils/date-utils.ts @@ -49,6 +49,13 @@ export function getYearStart(date:Date): Date { return new Date(date.getFullYear(),0,1,0,0,0,0); } +export function getCurrentWeek(): { start_date_week: Date; end_date_week: Date } { + const now = new Date(); + const start_date_week = getWeekStart(now, 0); + const end_date_week = getWeekEnd(start_date_week); + return { start_date_week, end_date_week }; +} + //cloning methods (helps with notify for overtime in a single day) // export function toDateOnly(day: Date): Date { // const d = new Date(day); diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index d575d9b..865f43d 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -1,4 +1,4 @@ -import { BadRequestException, 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, Query } from '@nestjs/common'; import { TimesheetsQueryService } from '../services/timesheets-query.service'; import { CreateTimesheetDto } from '../dtos/create-timesheet.dto'; import { Timesheets } from '@prisma/client'; @@ -7,8 +7,8 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators"; 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'; +import { TimesheetDto } from '../dtos/overview-timesheet.dto'; @ApiTags('Timesheets') @ApiBearerAuth('access-token') @@ -39,6 +39,15 @@ export class TimesheetsController { if(!email || !(email = email.trim())) throw new BadRequestException('Query param "email" is mandatory for this route.'); return this.timesheetsQuery.findAll(year, period_no, email); } + + @Get('/:email') + async getByEmail( + @Param('email') email: string, + @Query('offset') offset?: string, + ): Promise { + const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0; + return this.timesheetsQuery.getTimesheetByEmail(email, week_offset); + } @Get(':id') //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) diff --git a/src/modules/timesheets/dtos/overview-timesheet.dto.ts b/src/modules/timesheets/dtos/overview-timesheet.dto.ts new file mode 100644 index 0000000..956a7a4 --- /dev/null +++ b/src/modules/timesheets/dtos/overview-timesheet.dto.ts @@ -0,0 +1,27 @@ +export class TimesheetDto { + is_approved: boolean; + start_day: string; + end_day: string; + label: string; + shifts: ShiftsDto[]; + expenses: ExpensesDto[] +} + +export class ShiftsDto { + bank_type: string; + date: string; + start_time: string; + end_time: string; + description: string; + is_approved: boolean; +} + +export class ExpensesDto { + bank_type: string; + date: string; + amount: number; + km: number; + description: string; + supervisor_comment: string; + is_approved: boolean; +} \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index c28c8fd..5937dbc 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -1,13 +1,13 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; -import { CreateTimesheetDto } from '../dtos/create-timesheet.dto'; import { Timesheets, TimesheetsArchive } from '@prisma/client'; import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto'; import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; -import { computeHours } from 'src/common/utils/date-utils'; +import { computeHours, formatDateISO, getCurrentWeek, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers'; +import { TimesheetDto } from '../dtos/overview-timesheet.dto'; @Injectable() @@ -98,6 +98,102 @@ export class TimesheetsQueryService { return buildPeriod(period.period_start, period.period_end, shifts , expenses); } + async getTimesheetByEmail(email: string, week_offset = 0): Promise { + + //fetch user related to email + const user = await this.prisma.users.findUnique({ + where: { email }, + select: { id: true }, + }); + if(!user) throw new NotFoundException(`user with email ${email} not found`); + + //fetch employee_id matching the email + const employee = await this.prisma.employees.findFirst({ + where: { user_id: user.id }, + select: { id: true }, + }); + if(!employee) throw new NotFoundException(`Employee with email: ${email} not found`); + + //sets current week Sunday -> Saturday + const base = new Date(); + const offset = new Date(base); + offset.setDate(offset.getDate() + (week_offset * 7)); + + const start_date_week = getWeekStart(offset, 0); + const end_date_week = getWeekEnd(start_date_week); + const start_day = formatDateISO(start_date_week); + const end_day = formatDateISO(end_date_week); + + //build the label MM/DD/YYYY.MM/DD.YYYY + const mm_dd = (date: Date) => `${String(date.getFullYear())}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2,'0')}`; + const label = `${mm_dd(start_date_week)}.${mm_dd(end_date_week)}`; + + //fetch timesheet shifts and expenses + const timesheet = await this.prisma.timesheets.findUnique({ + where: { + employee_id_start_date: { + employee_id: employee.id, + start_date: start_date_week, + }, + }, + include: { + shift: { + include: { bank_code: true }, + orderBy: [{ date: 'asc'}, { start_time: 'asc'}], + }, + expense: { + include: { bank_code: true }, + orderBy: [{date: 'asc'}], + }, + }, + }); + + //returns an empty timesheet if not found + if(!timesheet) { + return { + is_approved: false, + start_day, + end_day, + label, + shifts:[], + expenses: [], + } as TimesheetDto; + } + + //small helper to format hours:minutes + const to_HH_mm = (date: Date) => date.toISOString().slice(11, 16); + + //maps all shifts of selected timesheet + const shifts = timesheet.shift.map((sft) => ({ + bank_type: sft.bank_code?.type ?? '', + date: formatDateISO(sft.date), + start_time: to_HH_mm(sft.start_time), + end_time: to_HH_mm(sft.end_time), + description: sft.description ?? '', + is_approved: sft.is_approved ?? false, + })); + + //maps all expenses of selected timsheet + const expenses = timesheet.expense.map((exp) => ({ + bank_type: exp.bank_code?.type ?? '', + date: formatDateISO(exp.date), + amount: Number(exp.amount) || 0, + km: 0, + description: exp.description ?? '', + supervisor_comment: exp.supervisor_comment ?? '', + is_approved: exp.is_approved ?? false, + })); + + return { + is_approved: timesheet.is_approved, + start_day, + end_day, + label, + shifts, + expenses, + } as TimesheetDto; + } + async findOne(id: number): Promise { const timesheet = await this.prisma.timesheets.findUnique({ where: { id }, From 5063c1dfec9a11d979503a1ce3c34b6a85777f12 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 2 Sep 2025 15:16:03 -0400 Subject: [PATCH 18/69] fix(seeder): fix bank_codes for expenses seeder --- prisma/mock-seeds-scripts/12-expenses.ts | 4 ++-- src/modules/timesheets/services/timesheets-query.service.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 848daae..f9a63d1 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -42,7 +42,7 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) { async function main() { // Codes autorisés (aléatoires à chaque dépense) - const BANKS = ['G517', 'G56', 'G502', 'G202', 'G234'] as const; + const BANKS = ['G517', 'G503', 'G502', 'G202', 'G234'] as const; const bcRows = await prisma.bankCodes.findMany({ where: { bank_code: { in: BANKS as unknown as string[] } }, select: { id: true, bank_code: true }, @@ -83,7 +83,7 @@ async function main() { // 4) Montant varié const amount = - randomCode === 'G56' + randomCode === 'G503' ? rndAmount(1000, 7500) // 10.00..75.00 : rndAmount(2000, 25000); // 20.00..250.00 diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 5937dbc..697a739 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -73,10 +73,7 @@ export class TimesheetsQueryService { bank_code: { select: { type: true } }, }, orderBy: { date: 'asc' }, - }); - - const to_num = (value: any) => typeof value.toNumber === 'function' ? value.toNumber() : Number(value); - + }); // data mapping const shifts: ShiftRow[] = raw_shifts.map(shift => ({ From 4f7563ce9b825113cd13bd8fa740dfc465eeeda8 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 4 Sep 2025 15:14:48 -0400 Subject: [PATCH 19/69] feat(timesheet): added Post function to create a new shifts inside a timesheet --- docs/swagger/swagger-spec.json | 90 +----------------- .../services/pay-periods-query.service.ts | 6 +- .../controllers/timesheets.controller.ts | 37 +++----- .../timesheets/dtos/create-timesheet.dto.ts | 49 +++++----- .../services/timesheets-command.service.ts | 91 ++++++++++++++++++- .../services/timesheets-query.service.ts | 28 +++--- 6 files changed, 148 insertions(+), 153 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index d35159e..212a779 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -542,53 +542,6 @@ "Timesheets" ] }, - "patch": { - "operationId": "TimesheetsController_update", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateTimesheetDto" - } - } - } - }, - "responses": { - "201": { - "description": "Timesheet updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTimesheetDto" - } - } - } - }, - "400": { - "description": "Timesheet not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Update timesheet", - "tags": [ - "Timesheets" - ] - }, "delete": { "operationId": "TimesheetsController_remove", "parameters": [ @@ -2408,48 +2361,7 @@ }, "CreateTimesheetDto": { "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "timesheet`s unique ID (auto-generated)" - }, - "employee_id": { - "type": "number", - "example": 426433, - "description": "employee`s ID number of linked timsheet" - }, - "is_approved": { - "type": "boolean", - "example": true, - "description": "Timesheet`s status approval" - } - }, - "required": [ - "id", - "employee_id", - "is_approved" - ] - }, - "UpdateTimesheetDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "timesheet`s unique ID (auto-generated)" - }, - "employee_id": { - "type": "number", - "example": 426433, - "description": "employee`s ID number of linked timsheet" - }, - "is_approved": { - "type": "boolean", - "example": true, - "description": "Timesheet`s status approval" - } - } + "properties": {} }, "CreateExpenseDto": { "type": "object", diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index e6e18e6..c681d50 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -250,10 +250,10 @@ export class PayPeriodsQueryService { const amount = toMoney(expense.amount); record.expenses += amount; - const categorie = (expense.bank_code?.categorie || "").toUpperCase(); + const type = (expense.bank_code?.type || "").toUpperCase(); const rate = expense.bank_code?.modifier ?? 0; - if (categorie === "MILEAGE" && rate > 0) { - record.mileage += amount / rate; + if (type === "MILEAGE" && rate > 0) { + record.mileage += Math.round((amount / rate)/100)*100; } record.is_approved = record.is_approved && expense.timesheet.is_approved; } diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index 865f43d..2dff5b4 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -1,8 +1,7 @@ -import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query } from '@nestjs/common'; import { TimesheetsQueryService } from '../services/timesheets-query.service'; -import { CreateTimesheetDto } from '../dtos/create-timesheet.dto'; +import { CreateTimesheetDto, CreateWeekShiftsDto } from '../dtos/create-timesheet.dto'; import { Timesheets } from '@prisma/client'; -import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto'; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { Roles as RoleEnum } from '.prisma/client'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; @@ -20,15 +19,6 @@ export class TimesheetsController { private readonly timesheetsCommand: TimesheetsCommandService, ) {} - // @Post() - // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - // @ApiOperation({ summary: 'Create timesheet' }) - // @ApiResponse({ status: 201, description: 'Timesheet created', type: CreateTimesheetDto }) - // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - // create(@Body() dto: CreateTimesheetDto): Promise { - // return this.timesheetsQuery.create(dto); - // } - @Get() //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) async getPeriodByQuery( @@ -49,6 +39,17 @@ export class TimesheetsController { return this.timesheetsQuery.getTimesheetByEmail(email, week_offset); } + @Post('shifts/:email') + async createTimesheetShifts( + @Param('email') email: string, + @Body() dto: CreateWeekShiftsDto, + @Query('offset') offset?: string, + ): Promise { + const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0; + return this.timesheetsCommand.createWeekShiftsAndReturnOverview(email, dto.shifts, week_offset); + } + + @Get(':id') //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) @ApiOperation({ summary: 'Find timesheet' }) @@ -58,18 +59,6 @@ export class TimesheetsController { return this.timesheetsQuery.findOne(id); } - @Patch(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Update timesheet' }) - @ApiResponse({ status: 201, description: 'Timesheet updated', type: CreateTimesheetDto }) - @ApiResponse({ status: 400, description: 'Timesheet not found' }) - update( - @Param('id', ParseIntPipe) id:number, - @Body() dto: UpdateTimesheetDto, - ): Promise { - return this.timesheetsQuery.update(id, dto); - } - @Delete(':id') // @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) @ApiOperation({ summary: 'Delete timesheet' }) diff --git a/src/modules/timesheets/dtos/create-timesheet.dto.ts b/src/modules/timesheets/dtos/create-timesheet.dto.ts index 6a1ace2..2e1c62a 100644 --- a/src/modules/timesheets/dtos/create-timesheet.dto.ts +++ b/src/modules/timesheets/dtos/create-timesheet.dto.ts @@ -1,28 +1,33 @@ -import { ApiProperty } from "@nestjs/swagger"; import { Type } from "class-transformer"; -import { Allow, IsBoolean, IsInt, IsOptional } from "class-validator"; +import { IsArray, IsOptional, IsString, Length, Matches, ValidateNested } from "class-validator"; export class CreateTimesheetDto { - @ApiProperty({ - example: 1, - description: 'timesheet`s unique ID (auto-generated)', - }) - @Allow() - id?: number; - @ApiProperty({ - example: 426433, - description: 'employee`s ID number of linked timsheet', - }) - @Type(() => Number) - @IsInt() - employee_id: number; + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + date!: string; - @ApiProperty({ - example: true, - description: 'Timesheet`s status approval', - }) - @IsOptional() - @IsBoolean() - is_approved?: boolean; + @IsString() + @Length(1,64) + type!: string; + + @IsString() + @Matches(/^\d{2}:\d{2}$/) + start_time!: string; + + @IsString() + @Matches(/^\d{2}:\d{2}$/) + end_time!: string; + + @IsOptional() + @IsString() + @Length(0,512) + description?: string; +} + +export class CreateWeekShiftsDto { + @IsArray() + @ValidateNested({each:true}) + @Type(()=> CreateTimesheetDto) + shifts!: CreateTimesheetDto[]; } diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index abce079..5fdbec6 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -1,16 +1,24 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { Prisma, Timesheets } from "@prisma/client"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { PrismaService } from "src/prisma/prisma.service"; +import { TimesheetsQueryService } from "./timesheets-query.service"; +import { CreateTimesheetDto } from "../dtos/create-timesheet.dto"; +import { TimesheetDto } from "../dtos/overview-timesheet.dto"; +import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils"; @Injectable() export class TimesheetsCommandService extends BaseApprovalService{ - constructor(prisma: PrismaService) {super(prisma);} + constructor( + prisma: PrismaService, + private readonly query: TimesheetsQueryService, + ) {super(prisma);} protected get delegate() { return this.prisma.timesheets; } + protected delegateFor(transaction: Prisma.TransactionClient) { return transaction.timesheets; } @@ -37,4 +45,83 @@ export class TimesheetsCommandService extends BaseApprovalService{ return timesheet; } + + //create shifts within timesheet's week - employee overview functions + private parseISODate(iso: string): Date { + const [ y, m, d ] = iso.split('-').map(Number); + return new Date(y, (m ?? 1) - 1, d ?? 1); + } + + private parseHHmm(t: string): Date { + const [ hh, mm ] = t.split(':').map(Number); + return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0); + } + + async createWeekShiftsAndReturnOverview( + email:string, + shifts: CreateTimesheetDto[], + week_offset = 0, + ): Promise { + + //match user's email with email + const user = await this.prisma.users.findUnique({ + where: { email }, + select: { id: true }, + }); + if(!user) throw new NotFoundException(`user with email ${email} not found`); + + //fetchs employee matchint user's email + const employee = await this.prisma.employees.findFirst({ + where: { user_id: user?.id }, + select: { id: true }, + }); + if(!employee) throw new NotFoundException(`employee for ${ email } not found`); + + //insure that the week starts on sunday and finishes on saturday + const base = new Date(); + base.setDate(base.getDate() + week_offset * 7); + const start_week = getWeekStart(base, 0); + const end_week = getWeekEnd(start_week); + + const timesheet = await this.prisma.timesheets.upsert({ + where: { + employee_id_start_date: { + employee_id: employee.id, + start_date: start_week, + }, + }, + create: { + employee_id: employee.id, + start_date: start_week, + is_approved: false, + }, + update: {}, + select: { id: true }, + }); + + //validations and insertions + for(const shift of shifts) { + const date = this.parseISODate(shift.date); + if (date < start_week || date > end_week) throw new BadRequestException(`date ${shift.date} not in current week`); + + const bank_code = await this.prisma.bankCodes.findFirst({ + where: { type: shift.type }, + select: { id: true }, + }); + if(!bank_code) throw new BadRequestException(`Invalid bank_code type: ${shift.type}`); + + await this.prisma.shifts.create({ + data: { + timesheet_id: timesheet.id, + bank_code_id: bank_code.id, + date: date, + start_time: this.parseHHmm(shift.start_time), + end_time: this.parseHHmm(shift.end_time), + description: shift.description ?? null, + is_approved: false, + }, + }); + } + return this.query.getTimesheetByEmail(email, week_offset); + } } \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 697a739..b7cde71 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -8,6 +8,7 @@ import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers'; import { TimesheetDto } from '../dtos/overview-timesheet.dto'; +import { CreateWeekShiftsDto } from '../dtos/create-timesheet.dto'; @Injectable() @@ -219,19 +220,20 @@ export class TimesheetsQueryService { return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours }; } - async update(id: number, dto:UpdateTimesheetDto): Promise { - await this.findOne(id); - const { employee_id, is_approved } = dto; - return this.prisma.timesheets.update({ - where: { id }, - data: { - ...(employee_id !== undefined && { employee_id }), - ...(is_approved !== undefined && { is_approved }), - }, - include: { employee: { include: { user: true } }, - }, - }); - } + //deprecated + // async update(id: number, dto:UpdateTimesheetDto): Promise { + // await this.findOne(id); + // const { employee_id, is_approved } = dto; + // return this.prisma.timesheets.update({ + // where: { id }, + // data: { + // ...(employee_id !== undefined && { employee_id }), + // ...(is_approved !== undefined && { is_approved }), + // }, + // include: { employee: { include: { user: true } }, + // }, + // }); + // } async remove(id: number): Promise { await this.findOne(id); From a73ed4b6206d135a7807ffcaff5147bd69397d7b Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 8 Sep 2025 09:43:46 -0400 Subject: [PATCH 20/69] refactor(seeders): added complexity to shifts and expenses seeders --- docs/swagger/swagger-spec.json | 50 +++++++ prisma/mock-seeds-scripts/10-shifts.ts | 174 ++++++++++++++++++----- prisma/mock-seeds-scripts/12-expenses.ts | 144 +++++++++++++------ 3 files changed, 288 insertions(+), 80 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 212a779..fe0a963 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -504,6 +504,52 @@ ] } }, + "/timesheets/shifts/{email}": { + "post": { + "operationId": "TimesheetsController_createTimesheetShifts", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWeekShiftsDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "access-token": [] + } + ], + "tags": [ + "Timesheets" + ] + } + }, "/timesheets/{id}": { "get": { "operationId": "TimesheetsController_findOne", @@ -2359,6 +2405,10 @@ } } }, + "CreateWeekShiftsDto": { + "type": "object", + "properties": {} + }, "CreateTimesheetDto": { "type": "object", "properties": {} diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index 9175030..ebe8a2b 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -4,14 +4,16 @@ const prisma = new PrismaClient(); // ====== Config ====== const PREVIOUS_WEEKS = 5; -const INCLUDE_CURRENT = false; +const INCLUDE_CURRENT = true; +const INCR = 15; // incrément ferme de 15 minutes (0.25 h) +const DAY_MIN = 5 * 60; // 5h +const DAY_MAX = 11 * 60; // 11h +const HARD_END = 19 * 60 + 30; // 19:30 -// Times-only via Date (UTC 1970-01-01) +// ====== Helpers temps ====== function timeAt(hour: number, minute: number) { return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); } - -// Lundi (UTC) de la date fournie function mondayOfThisWeekUTC(now = new Date()) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const day = d.getUTCDay(); @@ -20,7 +22,6 @@ function mondayOfThisWeekUTC(now = new Date()) { d.setUTCHours(0, 0, 0, 0); return d; } - function weekDatesFromMonday(monday: Date) { return Array.from({ length: 5 }, (_, i) => { const d = new Date(monday); @@ -28,16 +29,35 @@ function weekDatesFromMonday(monday: Date) { return d; }); } - function mondayNWeeksBefore(monday: Date, n: number) { const d = new Date(monday); d.setUTCDate(d.getUTCDate() - n * 7); return d; } - function rndInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; } +function clamp(n: number, min: number, max: number) { + return Math.min(max, Math.max(min, n)); +} +function addMinutes(h: number, m: number, delta: number) { + const total = h * 60 + m + delta; + const hh = Math.floor(total / 60); + const mm = ((total % 60) + 60) % 60; + return { h: hh, m: mm }; +} +// Aligne vers le multiple de INCR le plus proche +function quantize(mins: number): number { + const q = Math.round(mins / INCR) * INCR; + return q; +} +// Tire un multiple de INCR dans [min,max] (inclus), supposés entiers minutes +function rndQuantized(min: number, max: number): number { + const qmin = Math.ceil(min / INCR); + const qmax = Math.floor(max / INCR); + const q = rndInt(qmin, qmax); + return q * INCR; +} // Helper: garantit le timesheet de la semaine (upsert) async function getOrCreateTimesheet(employee_id: number, start_date: Date) { @@ -50,8 +70,13 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) { } async function main() { - // Bank codes utilisés + // --- Bank codes (pondérés: surtout G1 = régulier) --- const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305', 'G43'] as const; + const WEIGHTED_CODES = [ + 'G1','G1','G1','G1','G1','G1','G1','G1', // 8x régulier + 'G56','G48','G700','G105','G305','G43' + ] as const; + const bcRows = await prisma.bankCodes.findMany({ where: { bank_code: { in: BANKS as unknown as string[] } }, select: { id: true, bank_code: true }, @@ -70,59 +95,140 @@ async function main() { const mondayThisWeek = mondayOfThisWeekUTC(); const mondays: Date[] = []; if (INCLUDE_CURRENT) mondays.push(mondayThisWeek); - for (let n = 1; n <= PREVIOUS_WEEKS; n++) { - mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); - } + for (let n = 1; n <= PREVIOUS_WEEKS; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); let created = 0; for (let wi = 0; wi < mondays.length; wi++) { const monday = mondays[wi]; - const weekDays = weekDatesFromMonday(monday); + const days = weekDatesFromMonday(monday); for (let ei = 0; ei < employees.length; ei++) { const e = employees[ei]; - const baseStartHour = 6 + (ei % 5); - const baseStartMinute = (ei * 15) % 60; + // Cible hebdo 35–45h, multiple de 15 min + const weeklyTargetMin = rndQuantized(35 * 60, 45 * 60); - for (let di = 0; di < weekDays.length; di++) { - const date = weekDays[di]; + // Start de base (7:00, 7:15, 7:30, 7:45, 8:00, 8:15, 8:30, 8:45, 9:00 ...) + const baseStartH = 7 + (ei % 3); // 7,8,9 + const baseStartM = ( (ei * 15) % 60 ); // aligné 15 min - // 1) Trouver/Créer le timesheet de CETTE semaine pour CET employé - const weekStart = mondayOfThisWeekUTC(date); - const ts = await getOrCreateTimesheet(e.id, weekStart); + // Planification journalière (5 jours) ~8h ± 45 min, quantisée 15 min + const plannedDaily: number[] = []; + for (let d = 0; d < 5; d++) { + const jitter = rndInt(-3, 3) * INCR; // -45..+45 par pas de 15 + const base = 8 * 60 + jitter; + plannedDaily.push(quantize(clamp(base, DAY_MIN, DAY_MAX))); + } - // 2) Tirage aléatoire du bank_code - const randomCode = BANKS[Math.floor(Math.random() * BANKS.length)]; - const bank_code_id = bcMap.get(randomCode)!; + // Ajuster le 5e jour pour atteindre la cible hebdo exactement (par pas de 15) + const sumFirst4 = plannedDaily.slice(0, 4).reduce((a, b) => a + b, 0); + plannedDaily[4] = quantize(clamp(weeklyTargetMin - sumFirst4, DAY_MIN, DAY_MAX)); - // 3) Horaire - const duration = rndInt(4, 10); - const dayWeekOffset = (di + wi + (ei % 3)) % 3; - const startH = Math.min(12, baseStartHour + dayWeekOffset); - const startM = baseStartMinute; - const endH = startH + duration; - const endM = startM; + // Corriger le petit écart restant (devrait être multiple de 15) en redistribuant ±15 + let diff = weeklyTargetMin - plannedDaily.reduce((a, b) => a + b, 0); + const step = diff > 0 ? INCR : -INCR; + let guard = 100; // anti-boucle + while (diff !== 0 && guard-- > 0) { + for (let d = 0; d < 5 && diff !== 0; d++) { + const next = plannedDaily[d] + step; + if (next >= DAY_MIN && next <= DAY_MAX) { + plannedDaily[d] = next; + diff -= step; + } + } + } + // Upsert du timesheet (semaine) + const ts = await getOrCreateTimesheet(e.id, mondayOfThisWeekUTC(days[0])); + + for (let di = 0; di < 5; di++) { + const date = days[di]; + const targetWorkMin = plannedDaily[di]; // multiple de 15 + + // Départ ~ base + jitter (par pas de 15 min aussi) + const startJitter = rndInt(-1, 2) * INCR; // -15,0,+15,+30 + const { h: startH, m: startM } = addMinutes(baseStartH, baseStartM, startJitter); + + // Pause: entre 11:00 et 14:00, mais pas avant start+3h ni après start+6h (le tout quantisé 15) + const earliestLunch = Math.max((startH * 60 + startM) + 3 * 60, 11 * 60); + const latestLunch = Math.min((startH * 60 + startM) + 6 * 60, 14 * 60); + const lunchStartMin = rndQuantized(earliestLunch, latestLunch); + const lunchDur = rndQuantized(30, 120); // 30..120 min en pas de 15 + const lunchEndMin = lunchStartMin + lunchDur; + + // Travail = (lunchStart - start) + (end - lunchEnd) + const morningWork = Math.max(0, lunchStartMin - (startH * 60 + startM)); // multiple de 15 + let afternoonWork = Math.max(60, targetWorkMin - morningWork); // multiple de 15 (diff de deux multiples de 15) + if (afternoonWork % INCR !== 0) { + // sécurité (ne devrait pas arriver) + afternoonWork = quantize(afternoonWork); + } + + // Fin de journée (quantisée par construction) + const endMinRaw = lunchEndMin + afternoonWork; + const endMin = Math.min(endMinRaw, HARD_END); + + // Bank codes variés + const bcMorningCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)]; + const bcAfternoonCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)]; + const bcMorningId = bcMap.get(bcMorningCode)!; + const bcAfternoonId = bcMap.get(bcAfternoonCode)!; + + // Shift matin + const lunchStartHM = { h: Math.floor(lunchStartMin / 60), m: lunchStartMin % 60 }; await prisma.shifts.create({ data: { timesheet_id: ts.id, - bank_code_id, - description: `Shift ${di + 1} (semaine du ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${randomCode}`, + bank_code_id: bcMorningId, + description: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcMorningCode}`, date, start_time: timeAt(startH, startM), - end_time: timeAt(endH, endM), - is_approved: Math.random() < 0.5, + end_time: timeAt(lunchStartHM.h, lunchStartHM.m), + is_approved: Math.random() < 0.6, }, }); created++; + + // Shift après-midi (si >= 30 min — sera de toute façon multiple de 15) + const pmDuration = endMin - lunchEndMin; + if (pmDuration >= 30) { + const lunchEndHM = { h: Math.floor(lunchEndMin / 60), m: lunchEndMin % 60 }; + const finalEndHM = { h: Math.floor(endMin / 60), m: endMin % 60 }; + await prisma.shifts.create({ + data: { + timesheet_id: ts.id, + bank_code_id: bcAfternoonId, + description: `Après-midi J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcAfternoonCode}`, + date, + start_time: timeAt(lunchEndHM.h, lunchEndHM.m), + end_time: timeAt(finalEndHM.h, finalEndHM.m), + is_approved: Math.random() < 0.6, + }, + }); + created++; + } else { + // Fallback très rare : un seul shift couvrant la journée (tout en multiples de 15) + const fallbackEnd = addMinutes(startH, startM, targetWorkMin + lunchDur); + await prisma.shifts.create({ + data: { + timesheet_id: ts.id, + bank_code_id: bcMap.get('G1')!, + description: `Fallback J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — G1`, + date, + start_time: timeAt(startH, startM), + end_time: timeAt(fallbackEnd.h, fallbackEnd.m), + is_approved: Math.random() < 0.6, + }, + }); + created++; + } } } } const total = await prisma.shifts.count(); - console.log(`✓ Shifts: ${created} nouvelles lignes, ${total} total rows (${INCLUDE_CURRENT ? 'courante +' : ''}${PREVIOUS_WEEKS} semaines précédentes, L→V)`); + console.log(`✓ Shifts créés: ${created} | total en DB: ${total} (${INCLUDE_CURRENT ? 'inclut semaine courante, ' : ''}${PREVIOUS_WEEKS} sem passées, L→V, 2 shifts/jour, pas de décimaux foireux})`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index f9a63d1..4318871 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -2,7 +2,12 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -// Lundi (UTC) de la date fournie +// ====== Config ====== +const WEEKS_BACK = 4; // 4 semaines avant + semaine courante +const INCLUDE_CURRENT = true; // inclure la semaine courante +const STEP_CENTS = 25; // montants en quarts de dollar (.00/.25/.50/.75) + +// ====== Helpers dates ====== function mondayOfThisWeekUTC(now = new Date()) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const day = d.getUTCDay(); @@ -11,10 +16,13 @@ function mondayOfThisWeekUTC(now = new Date()) { d.setUTCHours(0, 0, 0, 0); return d; } - -// Dates Lundi→Vendredi (UTC minuit) -function currentWeekDates() { - const monday = mondayOfThisWeekUTC(); +function mondayNWeeksBefore(monday: Date, n: number) { + const d = new Date(monday); + d.setUTCDate(monday.getUTCDate() - n * 7); + return d; +} +// L→V (UTC minuit) +function weekDatesMonToFri(monday: Date) { return Array.from({ length: 5 }, (_, i) => { const d = new Date(monday); d.setUTCDate(monday.getUTCDate() + i); @@ -22,15 +30,30 @@ function currentWeekDates() { }); } +// ====== Helpers random / amount ====== function rndInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; } -function rndAmount(minCents: number, maxCents: number) { - const cents = rndInt(minCents, maxCents); - return (cents / 100).toFixed(2); +// String "xx.yy" à partir de cents ENTiers (jamais de float) +function centsToAmountString(cents: number): string { + const sign = cents < 0 ? '-' : ''; + const abs = Math.abs(cents); + const dollars = Math.floor(abs / 100); + const c = abs % 100; + return `${sign}${dollars}.${c.toString().padStart(2, '0')}`; +} +// Tire un multiple de STEP_CENTS entre minCents et maxCents (inclus) +function rndQuantizedCents(minCents: number, maxCents: number, step = STEP_CENTS): number { + const qmin = Math.ceil(minCents / step); + const qmax = Math.floor(maxCents / step); + const q = rndInt(qmin, qmax); + return q * step; +} +function rndAmount(minCents: number, maxCents: number): string { + return centsToAmountString(rndQuantizedCents(minCents, maxCents)); } -// Helper: garantit le timesheet de la semaine (upsert) +// ====== Timesheet upsert ====== async function getOrCreateTimesheet(employee_id: number, start_date: Date) { return prisma.timesheets.upsert({ where: { employee_id_start_date: { employee_id, start_date } }, @@ -41,8 +64,10 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) { } async function main() { - // Codes autorisés (aléatoires à chaque dépense) + // Codes d'EXPENSES (exemples) const BANKS = ['G517', 'G503', 'G502', 'G202', 'G234'] as const; + + // Précharger les bank codes const bcRows = await prisma.bankCodes.findMany({ where: { bank_code: { in: BANKS as unknown as string[] } }, select: { id: true, bank_code: true }, @@ -52,58 +77,85 @@ async function main() { if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`); } + // Employés const employees = await prisma.employees.findMany({ select: { id: true } }); if (!employees.length) { console.warn('Aucun employé — rien à insérer.'); return; } - const weekDays = currentWeekDates(); - const monday = weekDays[0]; - const friday = weekDays[4]; + // Liste des lundis (courant + 4 précédents) + const mondayThisWeek = mondayOfThisWeekUTC(); + const mondays: Date[] = []; + if (INCLUDE_CURRENT) mondays.push(mondayThisWeek); + for (let n = 1; n <= WEEKS_BACK; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); let created = 0; - for (const e of employees) { - // 1) Semaine courante → assurer le timesheet de la semaine - const weekStart = mondayOfThisWeekUTC(); - const ts = await getOrCreateTimesheet(e.id, weekStart); + for (const monday of mondays) { + const weekDays = weekDatesMonToFri(monday); + const friday = weekDays[4]; - // 2) Skip si l’employé a déjà une dépense cette semaine (on garantit ≥1) - const already = await prisma.expenses.findFirst({ - where: { timesheet_id: ts.id, date: { gte: monday, lte: friday } }, - select: { id: true }, - }); - if (already) continue; + for (const e of employees) { + // Upsert timesheet pour CETTE semaine/employee + const ts = await getOrCreateTimesheet(e.id, monday); - // 3) Choix aléatoire du code + jour - const randomCode = BANKS[Math.floor(Math.random() * BANKS.length)]; - const bank_code_id = bcMap.get(randomCode)!; - const date = weekDays[Math.floor(Math.random() * weekDays.length)]; + // Idempotence: si déjà au moins une expense L→V, on skip la semaine + const already = await prisma.expenses.findFirst({ + where: { timesheet_id: ts.id, date: { gte: monday, lte: friday } }, + select: { id: true }, + }); + if (already) continue; - // 4) Montant varié - const amount = - randomCode === 'G503' - ? rndAmount(1000, 7500) // 10.00..75.00 - : rndAmount(2000, 25000); // 20.00..250.00 + // 1 à 3 expenses (jours distincts) + const count = rndInt(1, 3); + const dayIndexes = [0, 1, 2, 3, 4].sort(() => Math.random() - 0.5).slice(0, count); - await prisma.expenses.create({ - data: { - timesheet_id: ts.id, - bank_code_id, - date, - amount, - attachement: null, - description: `Expense ${randomCode} ${amount}$ (emp ${e.id})`, - is_approved: Math.random() < 0.6, - supervisor_comment: Math.random() < 0.2 ? 'OK' : null, - }, - }); - created++; + for (const idx of dayIndexes) { + const date = weekDays[idx]; + const code = BANKS[rndInt(0, BANKS.length - 1)]; + const bank_code_id = bcMap.get(code)!; + + // Montants (cents) quantisés à 25¢ => aucun flottant binaire plus tard + let amount: string; + switch (code) { + case 'G503': // petites fournitures + amount = rndAmount(1000, 7500); // 10.00 à 75.00 + break; + case 'G502': // repas + amount = rndAmount(1500, 3000); // 15.00 à 30.00 + break; + case 'G202': // essence + amount = rndAmount(2000, 15000); // 20.00 à 150.00 + break; + case 'G234': // hébergement + amount = rndAmount(6000, 25000); // 60.00 à 250.00 + break; + case 'G517': // péages / divers + default: + amount = rndAmount(500, 5000); // 5.00 à 50.00 + break; + } + + await prisma.expenses.create({ + data: { + timesheet_id: ts.id, + bank_code_id, + date, + amount, // string "xx.yy" (2 décimales exactes) + attachement: null, + description: `Expense ${code} ${amount}$ (emp ${e.id})`, + is_approved: Math.random() < 0.65, + supervisor_comment: Math.random() < 0.25 ? 'OK' : null, + }, + }); + created++; + } + } } const total = await prisma.expenses.count(); - console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (≥1 expense/employee pour la semaine courante)`); + console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (sem courante + ${WEEKS_BACK} précédentes, L→V uniquement, montants en quarts de dollar)`); } main().finally(() => prisma.$disconnect()); From dac53c67802b8ab3f0675913c253bbd7dc9cf4c9 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 8 Sep 2025 10:14:14 -0400 Subject: [PATCH 21/69] fix(timesheets): fix query to use helper instead of library function --- .../timesheets/services/timesheets-query.service.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index b7cde71..77fbd1b 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -74,7 +74,11 @@ export class TimesheetsQueryService { bank_code: { select: { type: true } }, }, orderBy: { date: 'asc' }, - }); + }); + + const to_num = (value: any) => value && typeof (value as any).toNumber === 'function' + ? (value as any).toNumber() + : Number(value); // data mapping const shifts: ShiftRow[] = raw_shifts.map(shift => ({ @@ -87,8 +91,7 @@ export class TimesheetsQueryService { const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ date: expense.date, - amount: typeof (expense.amount as any)?.to_num === 'function' ? - (expense.amount as any).to_num() : Number(expense.amount), + amount: to_num(expense.amount), type: String(expense.bank_code?.type ?? '').toUpperCase(), is_approved: expense.is_approved ?? true, })); From 954411d995b0fc1f6855ef503eaf250475d2e234 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 8 Sep 2025 15:25:09 -0400 Subject: [PATCH 22/69] refactor(shifts): added is_remote to track work from home shifts --- prisma/schema.prisma | 1 + .../dtos/overview-employee-period.dto.ts | 2 ++ .../services/pay-periods-command.service.ts | 34 ------------------- .../services/pay-periods-query.service.ts | 3 ++ .../shifts/controllers/shifts.controller.ts | 2 +- .../shifts/services/shifts-query.service.ts | 6 ++-- .../timesheets/dtos/overview-timesheet.dto.ts | 1 + .../timesheets/dtos/timesheet-period.dto.ts | 1 + .../services/timesheets-command.service.ts | 1 + .../services/timesheets-query.service.ts | 30 +++------------- 10 files changed, 17 insertions(+), 64 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d9e700..e9ef52c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -182,6 +182,7 @@ model Shifts { start_time DateTime @db.Time(0) end_time DateTime @db.Time(0) is_approved Boolean @default(false) + is_remote Boolean @default(false) archive ShiftsArchive[] @relation("ShiftsToArchive") diff --git a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts index 8213be9..01119e8 100644 --- a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts +++ b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts @@ -41,4 +41,6 @@ export class EmployeePeriodOverviewDto { description: 'Tous les timesheets de la période sont approuvés pour cet employé', }) is_approved: boolean; + + is_remote: boolean; } diff --git a/src/modules/pay-periods/services/pay-periods-command.service.ts b/src/modules/pay-periods/services/pay-periods-command.service.ts index 8cffe2c..df9bfed 100644 --- a/src/modules/pay-periods/services/pay-periods-command.service.ts +++ b/src/modules/pay-periods/services/pay-periods-command.service.ts @@ -68,38 +68,4 @@ export class PayPeriodsCommandService { }); return {updated}; } - - //function to approve a single pay-period of a single employee (deprecated) - // async approvalPayPeriod(pay_year: number , period_no: number): Promise { - // const period = await this.prisma.payPeriods.findFirst({ - // where: { pay_year, pay_period_no: period_no}, - // }); - // if (!period) throw new NotFoundException(`PayPeriod #${pay_year}-${period_no} not found`); - - // //fetches timesheet of selected period if the timesheet has atleast 1 shift or 1 expense - // const timesheet_ist = await this.prisma.timesheets.findMany({ - // where: { - // OR: [ - // { shift: {some: { date: { gte: period.period_start, - // lte: period.period_end, - // }, - // }}, - // }, - // { expense: { some: { date: { gte: period.period_start, - // lte: period.period_end, - // }, - // }}, - // }, - // ], - // }, - // select: { id: true }, - // }); - - // //approval of both timesheet (cascading to the approval of related shifts and expenses) - // await this.prisma.$transaction(async (transaction)=> { - // for(const {id} of timesheet_ist) { - // await this.timesheets_approval.updateApprovalWithTransaction(transaction,id, true); - // } - // }) - // } } \ No newline at end of file diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index c681d50..56080d7 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -151,6 +151,7 @@ export class PayPeriodsQueryService { select: { start_time: true, end_time: true, + is_remote: true, timesheet: { select: { is_approved: true, employee: { select: { @@ -205,6 +206,7 @@ export class PayPeriodsQueryService { expenses: 0, mileage: 0, is_approved: true, + is_remote: true, }); } } @@ -221,6 +223,7 @@ export class PayPeriodsQueryService { expenses: 0, mileage: 0, is_approved: true, + is_remote: true, }); } return by_employee.get(id)!; diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index e1c4292..3a33170 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -108,7 +108,7 @@ export class ShiftsController { r.total_overtime_hrs.toFixed(2), r.total_expenses.toFixed(2), r.total_mileage.toFixed(2), - r.is_validated, + r.is_approved, ].join(','); }).join('\n'); diff --git a/src/modules/shifts/services/shifts-query.service.ts b/src/modules/shifts/services/shifts-query.service.ts index f1bb5f7..0fb38ef 100644 --- a/src/modules/shifts/services/shifts-query.service.ts +++ b/src/modules/shifts/services/shifts-query.service.ts @@ -18,7 +18,7 @@ export interface OverviewRow { total_overtime_hrs: number; total_expenses: number; total_mileage: number; - is_validated: boolean; + is_approved: boolean; } @Injectable() @@ -168,7 +168,7 @@ export class ShiftsQueryService { total_overtime_hrs: 0, total_expenses: 0, total_mileage: 0, - is_validated: false, + is_approved: false, }; } const hours = computeHours(shift.start_time, shift.end_time); @@ -200,7 +200,7 @@ export class ShiftsQueryService { total_overtime_hrs: 0, total_expenses: 0, total_mileage: 0, - is_validated: false, + is_approved: false, }; } const amount = Number(exp.amount); diff --git a/src/modules/timesheets/dtos/overview-timesheet.dto.ts b/src/modules/timesheets/dtos/overview-timesheet.dto.ts index 956a7a4..aaa7a95 100644 --- a/src/modules/timesheets/dtos/overview-timesheet.dto.ts +++ b/src/modules/timesheets/dtos/overview-timesheet.dto.ts @@ -14,6 +14,7 @@ export class ShiftsDto { end_time: string; description: string; is_approved: boolean; + is_remote: boolean; } export class ExpensesDto { diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index d8c42a6..a8a74bf 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -2,6 +2,7 @@ export class ShiftDto { start: string; end : string; is_approved: boolean; + is_remote: boolean; } export class ExpenseDto { diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 5fdbec6..5b58fa4 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -119,6 +119,7 @@ export class TimesheetsCommandService extends BaseApprovalService{ end_time: this.parseHHmm(shift.end_time), description: shift.description ?? null, is_approved: false, + is_remote: false, }, }); } diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 77fbd1b..0a545c8 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -18,17 +18,6 @@ export class TimesheetsQueryService { private readonly overtime: OvertimeService, ) {} - // async create(dto : CreateTimesheetDto): Promise { - // const { employee_id, is_approved } = dto; - // return this.prisma.timesheets.create({ - // data: { employee_id, is_approved: is_approved ?? false }, - // include: { - // employee: { include: { user: true } - // }, - // }, - // }); - // } - async findAll(year: number, period_no: number, email: string): Promise { //finds the employee const employee = await this.prisma.employees.findFirst({ @@ -57,6 +46,7 @@ export class TimesheetsQueryService { start_time: true, end_time: true, is_approved: true, + is_remote: true, bank_code: { select: { type: true } }, }, orderBy:[ { date:'asc'}, { start_time: 'asc'} ], @@ -87,6 +77,7 @@ export class TimesheetsQueryService { end_time: shift.end_time, type: String(shift.bank_code?.type ?? '').toUpperCase(), is_approved: shift.is_approved ?? true, + is_remote: shift.is_remote ?? true, })); const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ @@ -153,6 +144,7 @@ export class TimesheetsQueryService { if(!timesheet) { return { is_approved: false, + is_remote: false, start_day, end_day, label, @@ -172,6 +164,7 @@ export class TimesheetsQueryService { end_time: to_HH_mm(sft.end_time), description: sft.description ?? '', is_approved: sft.is_approved ?? false, + is_remote: sft.is_remote ?? false, })); //maps all expenses of selected timsheet @@ -223,21 +216,6 @@ export class TimesheetsQueryService { return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours }; } - //deprecated - // async update(id: number, dto:UpdateTimesheetDto): Promise { - // await this.findOne(id); - // const { employee_id, is_approved } = dto; - // return this.prisma.timesheets.update({ - // where: { id }, - // data: { - // ...(employee_id !== undefined && { employee_id }), - // ...(is_approved !== undefined && { is_approved }), - // }, - // include: { employee: { include: { user: true } }, - // }, - // }); - // } - async remove(id: number): Promise { await this.findOne(id); return this.prisma.timesheets.delete({ where: { id } }); From 557aed645d604397ed342ba72b08c1d22ca5f2b8 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 8 Sep 2025 15:26:12 -0400 Subject: [PATCH 23/69] fix(DB): DB migration --- .../20250908192545_added_is_remote_to_shifts/migration.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 prisma/migrations/20250908192545_added_is_remote_to_shifts/migration.sql diff --git a/prisma/migrations/20250908192545_added_is_remote_to_shifts/migration.sql b/prisma/migrations/20250908192545_added_is_remote_to_shifts/migration.sql new file mode 100644 index 0000000..50adfce --- /dev/null +++ b/prisma/migrations/20250908192545_added_is_remote_to_shifts/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."shifts" ADD COLUMN "is_remote" BOOLEAN NOT NULL DEFAULT false; From 0fb6465c270b948cd8640576b12aa334421744da Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 9 Sep 2025 08:19:50 -0400 Subject: [PATCH 24/69] fix(timesheet): added is_remote --- src/modules/timesheets/dtos/timesheet-period.dto.ts | 6 ++++-- .../timesheets/services/timesheets-query.service.ts | 1 - src/modules/timesheets/utils/timesheet.helpers.ts | 12 ++++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index a8a74bf..b948f4d 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -1,6 +1,8 @@ export class ShiftDto { - start: string; - end : string; + date: string; + type: string; + start_time: string; + end_time : string; is_approved: boolean; is_remote: boolean; } diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 0a545c8..1849e16 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -144,7 +144,6 @@ export class TimesheetsQueryService { if(!timesheet) { return { is_approved: false, - is_remote: false, start_day, end_day, label, diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index c94d64d..eadae40 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -1,3 +1,4 @@ +import { toDateString } from "src/modules/pay-periods/utils/pay-year.util"; import { DayExpensesDto, DetailedShifts, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto"; //makes the strings indexes for arrays @@ -33,8 +34,8 @@ const EXPENSE_TYPES = { } as const; //DB line types -export type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean; type: string }; -export type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean }; +export type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean; is_remote: boolean; type: string }; +export type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean; }; //helper functions export function toUTCDateOnly(date: Date | string): Date { @@ -154,9 +155,12 @@ export function buildWeek( for (const shift of week_shifts) { const key = dayKeyFromDate(shift.date, true); week.shifts[key].shifts.push({ - start: toTimeString(shift.start_time), - end: toTimeString(shift.end_time), + date: toDateString(shift.date), + type: shift.type, + start_time: toTimeString(shift.start_time), + end_time: toTimeString(shift.end_time), is_approved: shift.is_approved ?? true, + is_remote: shift.is_remote, } as ShiftDto); day_times[key].push({ start: shift.start_time, end: shift.end_time}); From d45feb1aa01eb0db04185d2fee08b4cf248746ab Mon Sep 17 00:00:00 2001 From: Nicolas Drolet Date: Tue, 9 Sep 2025 08:21:27 -0400 Subject: [PATCH 25/69] fix(payperiod): add type to payload. --- .../services/pay-periods-query.service.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index f0f306a..d5ac7ad 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -161,7 +161,9 @@ export class PayPeriodsQueryService { } }, }, }, - bank_code: { select: { categorie: true } }, + bank_code: { select: { + type: true, + categorie: true } }, }, }); @@ -184,7 +186,10 @@ export class PayPeriodsQueryService { } }, } }, } }, - bank_code: { select: { categorie: true, modifier: true } }, + bank_code: { select: { + type: true, + categorie: true, + modifier: true } }, }, }); @@ -230,13 +235,14 @@ export class PayPeriodsQueryService { const record = ensure(employee.id, name, employee.user.email); const hours = computeHours(shift.start_time, shift.end_time); - const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase(); - switch (categorie) { + const type = (shift.bank_code?.type).toUpperCase(); + switch (type) { case "EVENING": record.evening_hours += hours; break; case "EMERGENCY": record.emergency_hours += hours; break; case "OVERTIME": record.overtime_hours += hours; break; - default: record.regular_hours += hours; break; + case "REGULAR": record.regular_hours += hours; break; } + record.is_approved = record.is_approved && shift.timesheet.is_approved; } @@ -248,10 +254,10 @@ export class PayPeriodsQueryService { const amount = toMoney(expense.amount); record.expenses += amount; - const categorie = (expense.bank_code?.categorie || "").toUpperCase(); + const type = (expense.bank_code?.type || "").toUpperCase(); const rate = expense.bank_code?.modifier ?? 0; - if (categorie === "MILEAGE" && rate > 0) { - record.mileage += amount / rate; + if (type === "MILEAGE" && rate > 0) { + record.mileage += Math.round((amount / rate) * 100) / 100; } record.is_approved = record.is_approved && expense.timesheet.is_approved; } From 8ef5c0ac11efe93c34a363ff7c23afcf01e48ab8 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 9 Sep 2025 09:01:33 -0400 Subject: [PATCH 26/69] feat(pay-periods): added total_hours and is_remote to payload --- .../dtos/overview-employee-period.dto.ts | 2 ++ .../services/pay-periods-query.service.ts | 14 +++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts index 01119e8..861c783 100644 --- a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts +++ b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts @@ -30,6 +30,8 @@ export class EmployeePeriodOverviewDto { @ApiProperty({ example: 2, description: 'pay-period`s overtime hours' }) overtime_hours: number; + total_hours: number; + @ApiProperty({ example: 420.69, description: 'pay-period`s total expenses ($)' }) expenses: number; diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index 56080d7..7cde4de 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -203,6 +203,7 @@ export class PayPeriodsQueryService { evening_hours: 0, emergency_hours: 0, overtime_hours: 0, + total_hours: 0, expenses: 0, mileage: 0, is_approved: true, @@ -220,6 +221,7 @@ export class PayPeriodsQueryService { evening_hours: 0, emergency_hours: 0, overtime_hours: 0, + total_hours: 0, expenses: 0, mileage: 0, is_approved: true, @@ -235,14 +237,16 @@ export class PayPeriodsQueryService { const record = ensure(employee.id, name, employee.user.email); const hours = computeHours(shift.start_time, shift.end_time); - const categorie = (shift.bank_code?.type).toUpperCase(); - switch (categorie) { + const type = (shift.bank_code?.type ?? '').toUpperCase(); + switch (type) { case "EVENING": record.evening_hours += hours; break; case "EMERGENCY": record.emergency_hours += hours; break; case "OVERTIME": record.overtime_hours += hours; break; case "REGULAR" : record.regular_hours += hours; break; } record.is_approved = record.is_approved && shift.timesheet.is_approved; + record.total_hours += hours; + record.is_remote = record.is_remote || !!shift.is_remote; } for (const expense of expenses) { @@ -259,10 +263,10 @@ export class PayPeriodsQueryService { record.mileage += Math.round((amount / rate)/100)*100; } record.is_approved = record.is_approved && expense.timesheet.is_approved; - } + } - const employees_overview = Array.from(by_employee.values()).sort((a, b) => - a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), + const employees_overview = Array.from(by_employee.values()).sort((a, b) => + a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), ); return { From 6e1f5ce9a2d0e005374b9ab55d6184269a35cae8 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 9 Sep 2025 09:24:54 -0400 Subject: [PATCH 27/69] refactor(shifts_expenses): refactor description to comment to match frontend --- docs/swagger/swagger-spec.json | 6 +++--- prisma/mock-seeds-scripts/10-shifts.ts | 6 +++--- prisma/mock-seeds-scripts/11-shifts-archive.ts | 4 ++-- prisma/mock-seeds-scripts/12-expenses.ts | 2 +- prisma/mock-seeds-scripts/13-expenses-archive.ts | 4 ++-- prisma/schema.prisma | 8 ++++---- src/modules/expenses/dtos/create-expense.dto.ts | 2 +- src/modules/expenses/dtos/search-expense.dto.ts | 2 +- .../expenses/services/expenses-command.service.ts | 13 ------------- .../expenses/services/expenses-query.service.ts | 10 +++++----- src/modules/shifts/dtos/create-shift.dto.ts | 2 +- src/modules/shifts/dtos/search-shift.dto.ts | 2 +- .../shifts/services/shifts-command.service.ts | 13 ------------- src/modules/shifts/services/shifts-query.service.ts | 10 +++++----- src/modules/timesheets/dtos/create-timesheet.dto.ts | 2 +- .../timesheets/dtos/overview-timesheet.dto.ts | 4 ++-- .../services/timesheets-command.service.ts | 2 +- .../timesheets/services/timesheets-query.service.ts | 4 ++-- 18 files changed, 35 insertions(+), 61 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index fe0a963..133f2d5 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -2441,7 +2441,7 @@ "example": 17.82, "description": "amount in $ for a refund" }, - "description": { + "comment": { "type": "string", "example": "Spent for mileage between A and B", "description": "explain`s why the expense was made" @@ -2463,7 +2463,7 @@ "bank_code_id", "date", "amount", - "description", + "comment", "is_approved", "supervisor_comment" ] @@ -2496,7 +2496,7 @@ "example": 17.82, "description": "amount in $ for a refund" }, - "description": { + "comment": { "type": "string", "example": "Spent for mileage between A and B", "description": "explain`s why the expense was made" diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index ebe8a2b..ab10340 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -181,7 +181,7 @@ async function main() { data: { timesheet_id: ts.id, bank_code_id: bcMorningId, - description: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcMorningCode}`, + comment: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcMorningCode}`, date, start_time: timeAt(startH, startM), end_time: timeAt(lunchStartHM.h, lunchStartHM.m), @@ -199,7 +199,7 @@ async function main() { data: { timesheet_id: ts.id, bank_code_id: bcAfternoonId, - description: `Après-midi J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcAfternoonCode}`, + comment: `Après-midi J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcAfternoonCode}`, date, start_time: timeAt(lunchEndHM.h, lunchEndHM.m), end_time: timeAt(finalEndHM.h, finalEndHM.m), @@ -214,7 +214,7 @@ async function main() { data: { timesheet_id: ts.id, bank_code_id: bcMap.get('G1')!, - description: `Fallback J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — G1`, + comment: `Fallback J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — G1`, date, start_time: timeAt(startH, startM), end_time: timeAt(fallbackEnd.h, fallbackEnd.m), diff --git a/prisma/mock-seeds-scripts/11-shifts-archive.ts b/prisma/mock-seeds-scripts/11-shifts-archive.ts index a6a78f8..1c3f5e8 100644 --- a/prisma/mock-seeds-scripts/11-shifts-archive.ts +++ b/prisma/mock-seeds-scripts/11-shifts-archive.ts @@ -37,7 +37,7 @@ async function main() { data: { timesheet_id: ts.id, bank_code_id: bc.id, - description: `Archived-era shift ${i + 1} for emp ${e.id}`, + comment: `Archived-era shift ${i + 1} for emp ${e.id}`, date, start_time: timeAt(startH, 0), end_time: timeAt(endH, 0), @@ -55,7 +55,7 @@ async function main() { shift_id: s.id, timesheet_id: s.timesheet_id, bank_code_id: s.bank_code_id, - description: s.description, + comment: s.comment, date: s.date, start_time: s.start_time, end_time: s.end_time, diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 4318871..c0641a8 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -144,7 +144,7 @@ async function main() { date, amount, // string "xx.yy" (2 décimales exactes) attachement: null, - description: `Expense ${code} ${amount}$ (emp ${e.id})`, + comment: `Expense ${code} ${amount}$ (emp ${e.id})`, is_approved: Math.random() < 0.65, supervisor_comment: Math.random() < 0.25 ? 'OK' : null, }, diff --git a/prisma/mock-seeds-scripts/13-expenses-archive.ts b/prisma/mock-seeds-scripts/13-expenses-archive.ts index 8d9cc54..86d2c3b 100644 --- a/prisma/mock-seeds-scripts/13-expenses-archive.ts +++ b/prisma/mock-seeds-scripts/13-expenses-archive.ts @@ -33,7 +33,7 @@ async function main() { date: daysAgo(60 + i), amount: (20 + i * 3.5).toFixed(2), // ok: Decimal accepte string attachement: null, - description: `Old expense #${i + 1}`, + comment: `Old expense #${i + 1}`, is_approved: true, supervisor_comment: null, }, @@ -51,7 +51,7 @@ async function main() { date: e.date, amount: e.amount, attachement: e.attachement, - description: e.description, + comment: e.comment, is_approved: e.is_approved, supervisor_comment: e.supervisor_comment, }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9ef52c..d2c344c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -177,7 +177,7 @@ model Shifts { timesheet_id Int bank_code BankCodes @relation("ShiftBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int - description String? + comment String? date DateTime @db.Date start_time DateTime @db.Time(0) end_time DateTime @db.Time(0) @@ -196,7 +196,7 @@ model ShiftsArchive { archive_at DateTime @default(now()) timesheet_id Int bank_code_id Int - description String? + comment String? date DateTime @db.Date start_time DateTime @db.Time(0) end_time DateTime @db.Time(0) @@ -227,7 +227,7 @@ model Expenses { date DateTime @db.Date amount Decimal @db.Money attachement String? - description String? + comment String? is_approved Boolean @default(false) supervisor_comment String? @@ -246,7 +246,7 @@ model ExpensesArchive { date DateTime @db.Date amount Decimal @db.Money attachement String? - description String? + comment String? is_approved Boolean supervisor_comment String? diff --git a/src/modules/expenses/dtos/create-expense.dto.ts b/src/modules/expenses/dtos/create-expense.dto.ts index 2958e88..b840a14 100644 --- a/src/modules/expenses/dtos/create-expense.dto.ts +++ b/src/modules/expenses/dtos/create-expense.dto.ts @@ -46,7 +46,7 @@ export class CreateExpenseDto { description:'explain`s why the expense was made' }) @IsString() - description?: string; + comment?: string; @ApiProperty({ example: 'DENIED, APPROUVED, PENDING, etc...', diff --git a/src/modules/expenses/dtos/search-expense.dto.ts b/src/modules/expenses/dtos/search-expense.dto.ts index 5058167..3eb7758 100644 --- a/src/modules/expenses/dtos/search-expense.dto.ts +++ b/src/modules/expenses/dtos/search-expense.dto.ts @@ -14,7 +14,7 @@ export class SearchExpensesDto { @IsOptional() @IsString() - description_contains?: string; + comment_contains?: string; @IsOptional() @IsDateString() diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 2fc8777..7a8f722 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -22,17 +22,4 @@ export class ExpensesCommandService extends BaseApprovalService { this.updateApprovalWithTransaction(transaction, id, isApproved), ); } - - // deprecated since batch transaction are made with timesheets - // async updateManyWithTx( - // tx: Prisma.TransactionClient, - // ids: number[], - // isApproved: boolean, - // ): Promise { - // const { count } = await tx.expenses.updateMany({ - // where: { id: { in: ids } }, - // data: { is_approved: isApproved }, - // }); - // return count; - // } } \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index b0d6191..42c9679 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -16,7 +16,7 @@ export class ExpensesQueryService { async create(dto: CreateExpenseDto): Promise { const { timesheet_id, bank_code_id, date, amount:rawAmount, - description, is_approved,supervisor_comment} = dto; + comment, is_approved,supervisor_comment} = dto; //fetches type and modifier @@ -37,7 +37,7 @@ export class ExpensesQueryService { } return this.prisma.expenses.create({ - data: { timesheet_id, bank_code_id, date, amount: final_amount, description, is_approved, supervisor_comment}, + data: { timesheet_id, bank_code_id, date, amount: final_amount, comment, is_approved, supervisor_comment}, include: { timesheet: { include: { employee: { include: { user: true }}}}, bank_code: true, }, @@ -66,7 +66,7 @@ export class ExpensesQueryService { async update(id: number, dto: UpdateExpenseDto): Promise { await this.findOne(id); const { timesheet_id, bank_code_id, date, amount, - description, is_approved, supervisor_comment} = dto; + comment, is_approved, supervisor_comment} = dto; return this.prisma.expenses.update({ where: { id }, data: { @@ -74,7 +74,7 @@ export class ExpensesQueryService { ...(bank_code_id !== undefined && { bank_code_id }), ...(date !== undefined && { date }), ...(amount !== undefined && { amount }), - ...(description !== undefined && { description }), + ...(comment !== undefined && { comment }), ...(is_approved !== undefined && { is_approved }), ...(supervisor_comment !== undefined && { supervisor_comment }), }, @@ -122,7 +122,7 @@ export class ExpensesQueryService { date: exp.date, amount: exp.amount, attachement: exp.attachement, - description: exp.description, + comment: exp.comment, is_approved: exp.is_approved, supervisor_comment: exp.supervisor_comment, })), diff --git a/src/modules/shifts/dtos/create-shift.dto.ts b/src/modules/shifts/dtos/create-shift.dto.ts index c3451d2..0fa93ab 100644 --- a/src/modules/shifts/dtos/create-shift.dto.ts +++ b/src/modules/shifts/dtos/create-shift.dto.ts @@ -48,5 +48,5 @@ export class CreateShiftDto { end_time: string; @IsString() - description: string; + comment: string; } diff --git a/src/modules/shifts/dtos/search-shift.dto.ts b/src/modules/shifts/dtos/search-shift.dto.ts index 4693a51..233a9b6 100644 --- a/src/modules/shifts/dtos/search-shift.dto.ts +++ b/src/modules/shifts/dtos/search-shift.dto.ts @@ -14,7 +14,7 @@ export class SearchShiftsDto { @IsOptional() @IsString() - description_contains?: string; + comment_contains?: string; @IsOptional() @IsDateString() diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index c3de439..0de643c 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -20,17 +20,4 @@ export class ShiftsCommandService extends BaseApprovalService { this.updateApprovalWithTransaction(transaction, id, is_approved), ); } - - // deprecated since batch transaction are made with timesheets - // async updateManyWithTx( - // tx: Prisma.TransactionClient, - // ids: number[], - // isApproved: boolean, - // ): Promise { - // const { count } = await tx.shifts.updateMany({ - // where: { id: { in: ids } }, - // data: { is_approved: isApproved }, - // }); - // return count; - // } } \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-query.service.ts b/src/modules/shifts/services/shifts-query.service.ts index 0fb38ef..7bc6efe 100644 --- a/src/modules/shifts/services/shifts-query.service.ts +++ b/src/modules/shifts/services/shifts-query.service.ts @@ -29,11 +29,11 @@ export class ShiftsQueryService { ) {} async create(dto: CreateShiftDto): Promise { - const { timesheet_id, bank_code_id, date, start_time, end_time, description } = dto; + const { timesheet_id, bank_code_id, date, start_time, end_time, comment } = dto; //shift creation const shift = await this.prisma.shifts.create({ - data: { timesheet_id, bank_code_id, date, start_time, end_time, description }, + data: { timesheet_id, bank_code_id, date, start_time, end_time, comment }, include: { timesheet: { include: { employee: { include: { user: true } } } }, bank_code: true, }, @@ -93,7 +93,7 @@ export class ShiftsQueryService { async update(id: number, dto: UpdateShiftsDto): Promise { await this.findOne(id); - const { timesheet_id, bank_code_id, date,start_time,end_time, description} = dto; + const { timesheet_id, bank_code_id, date,start_time,end_time, comment} = dto; return this.prisma.shifts.update({ where: { id }, data: { @@ -102,7 +102,7 @@ export class ShiftsQueryService { ...(date !== undefined && { date }), ...(start_time !== undefined && { start_time }), ...(end_time !== undefined && { end_time }), - ...(description !== undefined && { description }), + ...(comment !== undefined && { comment }), }, include: { timesheet: { include: { employee: { include: { user: true } } } }, bank_code: true, @@ -243,7 +243,7 @@ export class ShiftsQueryService { shift_id: shift.id, timesheet_id: shift.timesheet_id, bank_code_id: shift.bank_code_id, - description: shift.description ?? undefined, + comment: shift.comment ?? undefined, date: shift.date, start_time: shift.start_time, end_time: shift.end_time, diff --git a/src/modules/timesheets/dtos/create-timesheet.dto.ts b/src/modules/timesheets/dtos/create-timesheet.dto.ts index 2e1c62a..1a53a08 100644 --- a/src/modules/timesheets/dtos/create-timesheet.dto.ts +++ b/src/modules/timesheets/dtos/create-timesheet.dto.ts @@ -22,7 +22,7 @@ export class CreateTimesheetDto { @IsOptional() @IsString() @Length(0,512) - description?: string; + comment?: string; } export class CreateWeekShiftsDto { diff --git a/src/modules/timesheets/dtos/overview-timesheet.dto.ts b/src/modules/timesheets/dtos/overview-timesheet.dto.ts index aaa7a95..95aad54 100644 --- a/src/modules/timesheets/dtos/overview-timesheet.dto.ts +++ b/src/modules/timesheets/dtos/overview-timesheet.dto.ts @@ -12,7 +12,7 @@ export class ShiftsDto { date: string; start_time: string; end_time: string; - description: string; + comment: string; is_approved: boolean; is_remote: boolean; } @@ -22,7 +22,7 @@ export class ExpensesDto { date: string; amount: number; km: number; - description: string; + comment: string; supervisor_comment: string; is_approved: boolean; } \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 5b58fa4..142fced 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -117,7 +117,7 @@ export class TimesheetsCommandService extends BaseApprovalService{ date: date, start_time: this.parseHHmm(shift.start_time), end_time: this.parseHHmm(shift.end_time), - description: shift.description ?? null, + comment: shift.comment ?? null, is_approved: false, is_remote: false, }, diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 1849e16..e98a62c 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -161,7 +161,7 @@ export class TimesheetsQueryService { date: formatDateISO(sft.date), start_time: to_HH_mm(sft.start_time), end_time: to_HH_mm(sft.end_time), - description: sft.description ?? '', + comment: sft.comment ?? '', is_approved: sft.is_approved ?? false, is_remote: sft.is_remote ?? false, })); @@ -172,7 +172,7 @@ export class TimesheetsQueryService { date: formatDateISO(exp.date), amount: Number(exp.amount) || 0, km: 0, - description: exp.description ?? '', + comment: exp.comment ?? '', supervisor_comment: exp.supervisor_comment ?? '', is_approved: exp.is_approved ?? false, })); From 4a6eed1185b86a31c4b20e853e9f6744bb0aba6d Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 9 Sep 2025 09:27:14 -0400 Subject: [PATCH 28/69] feat(migration): migration of comment/description change --- .../migration.sql | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 prisma/migrations/20250909132655_refactor_description_to_comment_for_shifts_and_expenses/migration.sql diff --git a/prisma/migrations/20250909132655_refactor_description_to_comment_for_shifts_and_expenses/migration.sql b/prisma/migrations/20250909132655_refactor_description_to_comment_for_shifts_and_expenses/migration.sql new file mode 100644 index 0000000..7082aa6 --- /dev/null +++ b/prisma/migrations/20250909132655_refactor_description_to_comment_for_shifts_and_expenses/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `description` on the `expenses` table. All the data in the column will be lost. + - You are about to drop the column `description` on the `expenses_archive` table. All the data in the column will be lost. + - You are about to drop the column `description` on the `shifts` table. All the data in the column will be lost. + - You are about to drop the column `description` on the `shifts_archive` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "public"."expenses" DROP COLUMN "description", +ADD COLUMN "comment" TEXT; + +-- AlterTable +ALTER TABLE "public"."expenses_archive" DROP COLUMN "description", +ADD COLUMN "comment" TEXT; + +-- AlterTable +ALTER TABLE "public"."shifts" DROP COLUMN "description", +ADD COLUMN "comment" TEXT; + +-- AlterTable +ALTER TABLE "public"."shifts_archive" DROP COLUMN "description", +ADD COLUMN "comment" TEXT; From a343ace0b724f04ac271b2fd1ef0270113f5bcdd Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 9 Sep 2025 10:24:53 -0400 Subject: [PATCH 29/69] fix(pay-period): fix total_hours calculation --- .../services/pay-periods-query.service.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index 7cde4de..308caa7 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -239,13 +239,20 @@ export class PayPeriodsQueryService { const hours = computeHours(shift.start_time, shift.end_time); const type = (shift.bank_code?.type ?? '').toUpperCase(); switch (type) { - case "EVENING": record.evening_hours += hours; break; - case "EMERGENCY": record.emergency_hours += hours; break; - case "OVERTIME": record.overtime_hours += hours; break; - case "REGULAR" : record.regular_hours += hours; break; + case "EVENING": record.evening_hours += hours; + record.total_hours += hours; + break; + case "EMERGENCY": record.emergency_hours += hours; + record.total_hours += hours; + break; + case "OVERTIME": record.overtime_hours += hours; + record.total_hours += hours; + break; + case "REGULAR" : record.regular_hours += hours; + record.total_hours += hours; + break; } record.is_approved = record.is_approved && shift.timesheet.is_approved; - record.total_hours += hours; record.is_remote = record.is_remote || !!shift.is_remote; } From 125f443ec0e1536ac560170fb35f5b2ad0d5e5f9 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 10 Sep 2025 16:20:37 -0400 Subject: [PATCH 30/69] feat(timesheet): added comment and supervisor comment to payload of findAll --- .../timesheets/dtos/timesheet-period.dto.ts | 4 ++ .../services/timesheets-query.service.ts | 27 ++++---- .../timesheets/utils/timesheet.helpers.ts | 66 ++++++++++++++++--- 3 files changed, 76 insertions(+), 21 deletions(-) diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index b948f4d..cfd0194 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -3,12 +3,15 @@ export class ShiftDto { type: string; start_time: string; end_time : string; + comment: string; is_approved: boolean; is_remote: boolean; } export class ExpenseDto { amount: number; + comment: string; + supervisor_comment: string; total_mileage: number; total_expense: number; is_approved: boolean; @@ -22,6 +25,7 @@ export class DetailedShifts { evening_hours: number; overtime_hours: number; emergency_hours: number; + comment: string; short_date: string; break_durations?: number; } diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index e98a62c..f4517e2 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -1,14 +1,12 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { Timesheets, TimesheetsArchive } from '@prisma/client'; -import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto'; import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; -import { computeHours, formatDateISO, getCurrentWeek, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; +import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers'; import { TimesheetDto } from '../dtos/overview-timesheet.dto'; -import { CreateWeekShiftsDto } from '../dtos/create-timesheet.dto'; @Injectable() @@ -21,14 +19,14 @@ export class TimesheetsQueryService { async findAll(year: number, period_no: number, email: string): Promise { //finds the employee const employee = await this.prisma.employees.findFirst({ - where: { user: { is: { email } } }, + where: { user: { is: { email } } }, select: { id: true }, }); if(!employee) throw new NotFoundException(`no employee with email ${email} found`); //finds the period const period = await this.prisma.payPeriods.findFirst({ - where: { pay_year: year, pay_period_no: period_no }, + 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`); @@ -45,6 +43,7 @@ export class TimesheetsQueryService { date: true, start_time: true, end_time: true, + comment: true, is_approved: true, is_remote: true, bank_code: { select: { type: true } }, @@ -60,31 +59,37 @@ export class TimesheetsQueryService { select: { date: true, amount: true, + comment: true, + supervisor_comment: true, is_approved: true, bank_code: { select: { type: true } }, }, orderBy: { date: 'asc' }, }); - const to_num = (value: any) => value && typeof (value as any).toNumber === 'function' - ? (value as any).toNumber() - : Number(value); + const to_num = (value: any) => + value && typeof value.toNumber === 'function' ? value.toNumber() : + typeof value === 'number' ? value : + value ? Number(value) : 0; // data mapping const shifts: ShiftRow[] = raw_shifts.map(shift => ({ date: shift.date, start_time: shift.start_time, end_time: shift.end_time, - type: String(shift.bank_code?.type ?? '').toUpperCase(), + comment: shift.comment ?? '', is_approved: shift.is_approved ?? true, is_remote: shift.is_remote ?? true, + type: String(shift.bank_code?.type ?? '').toUpperCase(), })); const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ date: expense.date, amount: to_num(expense.amount), - type: String(expense.bank_code?.type ?? '').toUpperCase(), + comment: expense.comment ?? '', + supervisor_comment: expense.supervisor_comment ?? '', is_approved: expense.is_approved ?? true, + type: String(expense.bank_code?.type ?? '').toUpperCase(), })); return buildPeriod(period.period_start, period.period_end, shifts , expenses); @@ -231,7 +236,7 @@ export class TimesheetsQueryService { await this.prisma.$transaction(async transaction => { //fetches all timesheets to cutoff const oldSheets = await transaction.timesheets.findMany({ - where: { shift: { every: { date: { lt: cutoff } } }, + where: { shift: { some: { date: { lt: cutoff } } }, }, select: { id: true, diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index eadae40..95ee5f3 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -34,8 +34,23 @@ const EXPENSE_TYPES = { } as const; //DB line types -export type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean; is_remote: boolean; type: string }; -export type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean; }; +export type ShiftRow = { + date: Date; + start_time: Date; + end_time: Date; + comment: string; + is_approved?: boolean; + is_remote: boolean; + type: string +}; +export type ExpenseRow = { + date: Date; + amount: number; + comment: string; + supervisor_comment: string; + is_approved?: boolean; + type: string; +}; //helper functions export function toUTCDateOnly(date: Date | string): Date { @@ -84,6 +99,7 @@ export function makeEmptyWeek(week_start: Date): WeekDto { evening_hours: 0, emergency_hours: 0, overtime_hours: 0, + comment: '', short_date: shortDate(addDays(week_start, offset)), break_durations: 0, }); @@ -129,19 +145,44 @@ export function buildWeek( }, {} as Record>); //shifts's hour by type - type ShiftsHours = - {regular: number; evening: number; overtime: number; emergency: number; sick: number; vacation: number; holiday: number;}; - const make_hours = (): ShiftsHours => - ({ regular: 0, evening: 0, overtime: 0, emergency: 0, sick: 0, vacation: 0, holiday: 0 }); + type ShiftsHours = { + regular: number; + evening: number; + overtime: number; + emergency: number; + sick: number; + vacation: number; + holiday: number; + }; + const make_hours = (): ShiftsHours => ({ + regular: 0, + evening: 0, + overtime: 0, + emergency: 0, + sick: 0, + vacation: 0, + holiday: 0 + }); const day_hours: Record = DAY_KEYS.reduce((acc, key) => { acc[key] = make_hours(); return acc; }, {} as Record); //expenses's amount by type - type ExpensesAmount = - {mileage: number; expense: number; per_diem: number; commission: number; prime_dispo: number }; - const make_amounts = (): ExpensesAmount => - ({ mileage: 0, expense: 0, per_diem: 0, commission: 0, prime_dispo: 0 }); + type ExpensesAmount = { + mileage: number; + expense: number; + per_diem: number; + commission: number; + prime_dispo: number + }; + + const make_amounts = (): ExpensesAmount => ({ + mileage: 0, + expense: 0, + per_diem: 0, + commission: 0, + prime_dispo: 0 + }); const day_amounts: Record = DAY_KEYS.reduce((acc, key) => { acc[key] = make_amounts(); return acc; }, {} as Record); @@ -159,6 +200,7 @@ export function buildWeek( type: shift.type, start_time: toTimeString(shift.start_time), end_time: toTimeString(shift.end_time), + comment: shift.comment, is_approved: shift.is_approved ?? true, is_remote: shift.is_remote, } as ShiftDto); @@ -230,6 +272,8 @@ export function buildWeek( for(const row of dayExpenseRows[key].km) { week.expenses[key].km.push({ amount: round2(row.amount), + comment: row.comment, + supervisor_comment: row.supervisor_comment, total_mileage: round2(total_mileage), total_expense: round2(total_expense), is_approved: row.is_approved ?? true, @@ -240,6 +284,8 @@ export function buildWeek( for(const row of dayExpenseRows[key].cash) { week.expenses[key].cash.push({ amount: round2(row.amount), + comment: row.comment, + supervisor_comment: row.supervisor_comment, total_mileage: round2(total_mileage), total_expense: round2(total_expense), is_approved: row.is_approved ?? true, From ef4f6340d26487c0b2a9154169eaedc0aec53a82 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 11 Sep 2025 15:22:57 -0400 Subject: [PATCH 31/69] feat(shifts): added a master function to create/update/delete a single shift --- .../shifts/controllers/shifts.controller.ts | 18 +- src/modules/shifts/dtos/upsert-shift.dto.ts | 29 ++ .../helpers/shifts-date-time-helpers.ts | 19 ++ .../shifts/services/shifts-command.service.ts | 283 +++++++++++++++++- 4 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 src/modules/shifts/dtos/upsert-shift.dto.ts create mode 100644 src/modules/shifts/helpers/shifts-date-time-helpers.ts diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index 3a33170..b245a82 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; import { Shifts } from "@prisma/client"; import { CreateShiftDto } from "../dtos/create-shift.dto"; import { UpdateShiftsDto } from "../dtos/update-shift.dto"; @@ -9,6 +9,7 @@ import { ShiftsCommandService } from "../services/shifts-command.service"; import { SearchShiftsDto } from "../dtos/search-shift.dto"; import { OverviewRow, ShiftsQueryService } from "../services/shifts-query.service"; import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto"; +import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; @ApiTags('Shifts') @ApiBearerAuth('access-token') @@ -17,9 +18,20 @@ import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto"; export class ShiftsController { constructor( private readonly shiftsService: ShiftsQueryService, - private readonly shiftsApprovalService: ShiftsCommandService, + private readonly shiftsCommandService: ShiftsCommandService, private readonly shiftsValidationService: ShiftsQueryService, ){} + + @Put('upsert/:email/:date') + async upsert_by_date( + @Param('email') email_param: string, + @Param('date') date_param: string, + @Body() payload: UpsertShiftDto, + ) { + const email = decodeURIComponent(email_param); + const date = date_param; + return this.shiftsCommandService.upsertShfitsByDate(email, date, payload); + } @Post() //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) @@ -70,7 +82,7 @@ export class ShiftsController { @Patch('approval/:id') //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { - return this.shiftsApprovalService.updateApproval(id, isApproved); + return this.shiftsCommandService.updateApproval(id, isApproved); } @Get('summary') diff --git a/src/modules/shifts/dtos/upsert-shift.dto.ts b/src/modules/shifts/dtos/upsert-shift.dto.ts new file mode 100644 index 0000000..4af789c --- /dev/null +++ b/src/modules/shifts/dtos/upsert-shift.dto.ts @@ -0,0 +1,29 @@ +import { IsBoolean, IsOptional, IsString, Matches, MaxLength } from "class-validator"; + +export const COMMENT_MAX_LENGTH = 512; + +export class ShiftPayloadDto { + + @Matches(/^\d{2}:\d{2}$/, {message: 'start_time must be HH:mm' }) + start_time!: string; + + @Matches(/^\d{2}:\d{2}$/, {message: 'start_time must be HH:mm' }) + end_time!: string; + + @IsString() + type!: string; + + @IsBoolean() + is_remote!: boolean; + + @IsOptional() + @IsString() + @MaxLength(COMMENT_MAX_LENGTH) + comment?: string; +}; + +export class UpsertShiftDto { + + old_shift?: ShiftPayloadDto; + new_shift?: ShiftPayloadDto; +}; \ No newline at end of file diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts new file mode 100644 index 0000000..3cf3683 --- /dev/null +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -0,0 +1,19 @@ +export function timeFromHHMMUTC(hhmm: string): Date { + const [hour, min] = hhmm.split(':').map(Number); + return new Date(Date.UTC(1970,0,1,hour, min,0)); +} + +export function weekStartMondayUTC(date: Date): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const day = d.getUTCDay(); + const diff = (day + 6) % 7; + d.setUTCDate(d.getUTCDate() - diff); + d.setUTCHours(0,0,0,0); + return d; +} + +export function toDateOnlyUTC(input: string | Date): Date { + const date = new Date(input); + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 0de643c..a864ace 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,12 +1,280 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable, NotFoundException, ParseUUIDPipe, UnprocessableEntityException } from "@nestjs/common"; import { Prisma, Shifts } from "@prisma/client"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { PrismaService } from "src/prisma/prisma.service"; +import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto"; +import { timeFromHHMMUTC, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers"; +import { error, time } from "console"; + +type DayShiftResponse = { + start_time: string; + end_time: string; + type: string; + is_remote: boolean; + comment: string | null; +} + +type UpsertAction = 'created' | 'updated' | 'deleted'; + + + @Injectable() export class ShiftsCommandService extends BaseApprovalService { constructor(prisma: PrismaService) { super(prisma); } + +//create/update/delete master method +async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto): + Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { + if(!dto.old_shift && !dto.new_shift) { + throw new BadRequestException('At least one of old or new shift must be provided'); + } + + const date_only = toDateOnlyUTC(date_string); + + //Resolve employee by email + const employee = await this.prisma.employees.findFirst({ + where: { user: {email } }, + select: { id: true }, + }); + if(!employee) throw new NotFoundException(`Employee not found for email : ${ email }`); + + //making sure a timesheet exist in selected week + const start_of_week = weekStartMondayUTC(date_only); + let timesheet = await this.prisma.timesheets.findFirst({ + where: { + employee_id: employee.id, + start_date: start_of_week + }, + select: { + id: true + }, + }); + if(!timesheet) { + timesheet = await this.prisma.timesheets.create({ + data: { + employee_id: employee.id, + start_date: start_of_week + }, + select: { + id: true + }, + }); + } + + //normalization of data to ensure a valid comparison between DB and payload + const old_norm = dto.old_shift + ? this.normalize_shift_payload(dto.old_shift) + : undefined; + const new_norm = dto.new_shift + ? this.normalize_shift_payload(dto.new_shift) + : undefined; + + if (old_norm && old_norm.end_time.getTime() <= old_norm.start_time.getTime()) { + throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); + } + if (new_norm && new_norm.end_time.getTime() <= new_norm.start_time.getTime()) { + throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); + } + + //Resolve bank_code_id with type + const old_bank_code_id = old_norm + ? await this.lookup_bank_code_id_or_throw(old_norm.type) + : undefined; + const new_bank_code_id = new_norm + ? await this.lookup_bank_code_id_or_throw(new_norm.type) + : undefined; + + //fetch all shifts in a single day + const day_shifts = await this.prisma.shifts.findMany({ + where: { + timesheet_id: timesheet.id, + date: date_only + }, + include: { + bank_code: true + }, + orderBy: { + start_time: 'asc' + }, + }); + + const result = await this.prisma.$transaction(async (transaction)=> { + let action: UpsertAction; + + const find_exact_old_shift = async ()=> { + if(!old_norm || old_bank_code_id === undefined) return undefined; + const old_comment = old_norm.comment ?? null; + + return transaction.shifts.findFirst({ + where: { + timesheet_id: timesheet.id, + date: date_only, + start_time: old_norm.start_time, + end_time: old_norm.end_time, + is_remote: old_norm.is_remote, + comment: old_comment, + bank_code_id: old_bank_code_id, + }, + select: { id: true }, + }); + }; + + //checks for overlaping shifts + const assert_no_overlap = (exclude_shift_id?: number)=> { + if (!new_norm) return; + + const overlap_with = day_shifts.filter((shift)=> { + if(exclude_shift_id && shift.id === exclude_shift_id) return false; + return this.overlaps( + new_norm.start_time.getTime(), + new_norm.end_time.getTime(), + shift.start_time.getTime(), + shift.end_time.getTime(), + ); + }); + + if(overlap_with.length > 0) { + const conflicts = overlap_with.map((shift)=> ({ + start_time: this.format_hhmm(shift.start_time), + end_time: this.format_hhmm(shift.end_time), + type: shift.bank_code?.type ?? 'UNKNOWN', + })); + throw new ConflictException({ + error_code: 'SHIFT_OVERLAP', + message: 'New shift overlaps with existing shift(s)', + conflicts, + }); + } + }; + + // DELETE + if ( dto.old_shift && !dto.new_shift ) { + const existing = await find_exact_old_shift(); + if(!existing) { + throw new NotFoundException({ + error_code: 'SHIFT_STALE', + message: 'The shift was modified or deleted by someone else', + }); + } + await transaction.shifts.delete({ where: { id: existing.id } } ); + action = 'deleted'; + } + // CREATE + else if (!dto.old_shift && dto.new_shift) { + assert_no_overlap(); + await transaction.shifts.create({ + data: { + timesheet_id: timesheet.id, + date: date_only, + start_time: new_norm!.start_time, + end_time: new_norm!.end_time, + is_remote: new_norm!.is_remote, + comment: new_norm!.comment ?? null, + bank_code_id: new_bank_code_id!, + }, + }); + action = 'created'; + } + //UPDATE + else { + const existing = await find_exact_old_shift(); + if(!existing) { + throw new NotFoundException({ + error_code: 'SHIFT_STALE', + message: 'The shift was modified or deleted by someone else', + }); + } + assert_no_overlap(existing.id); + await transaction.shifts.update({ + where: { + id: existing.id + }, + data: { + start_time: new_norm!.start_time, + end_time: new_norm!.end_time, + is_remote: new_norm!.is_remote, + comment: new_norm!.comment ?? null, + bank_code_id: new_bank_code_id, + }, + }); + action = 'updated'; + } + + //Reload the day (truth source) + const fresh_day = await transaction.shifts.findMany({ + where: { + timesheet_id: timesheet.id, + date: date_only, + }, + include: { + bank_code: true + }, + orderBy: { + start_time: 'asc' + }, + }); + + return { + action, + day: fresh_day.map((shift)=> ({ + start_time: this.format_hhmm(shift.start_time), + end_time: this.format_hhmm(shift.end_time), + type: shift.bank_code?.type ?? 'UNKNOWN', + is_remote: shift.is_remote, + comment: shift.comment ?? null, + })), + }; + }); + return result; + } + + + private normalize_shift_payload(payload: ShiftPayloadDto) { + //normalize shift's infos + const start_time = timeFromHHMMUTC(payload.start_time); + const end_time = timeFromHHMMUTC(payload.end_time ); + const type = (payload.type || '').trim().toUpperCase(); + const is_remote = payload.is_remote === true; + //normalize comment + const raw_comment = payload.comment ?? null; + const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null; + const comment = trimmed && trimmed.length > 0 ? trimmed: null; + + return { start_time, end_time, type, is_remote, comment }; + } + + private async lookup_bank_code_id_or_throw(type: string): Promise { + const bank = await this.prisma.bankCodes.findFirst({ + where: { type }, + select: { id: true }, + }); + if (!bank) { + throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` }); + } + return bank.id; + } + + private overlaps( + a_start_ms: number, + a_end_ms: number, + b_start_ms: number, + b_end_ms: number, + ): boolean { + return a_start_ms < b_end_ms && b_start_ms < a_end_ms; + } + + private format_hhmm(time: Date): string { + const hh = String(time.getUTCHours()).padStart(2,'0'); + const mm = String(time.getUTCMinutes()).padStart(2,'0'); + return `${hh}:${mm}`; + } + + + + //approval methods + protected get delegate() { return this.prisma.shifts; } @@ -20,4 +288,17 @@ export class ShiftsCommandService extends BaseApprovalService { this.updateApprovalWithTransaction(transaction, id, is_approved), ); } + + + + + + /* + old without new = delete + new without old = post + old with new = patch old with new + */ + async upsertShift(old_shift?: UpsertShiftDto, new_shift?: UpsertShiftDto) { + + } } \ No newline at end of file From f9931f99c8c4046d35775cb0854f6d274041d56d Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 11 Sep 2025 16:48:05 -0400 Subject: [PATCH 32/69] feat(validationPipe): Global Exception Filter basic setup using APP_FILTER and APP_PIPE --- docs/swagger/swagger-spec.json | 50 +++++++++++++++++++ src/app.module.ts | 30 ++++++++++- src/common/filters/http-exception.filter.ts | 24 +++++++++ src/main.ts | 5 +- .../helpers/shifts-date-time-helpers.ts | 1 - .../shifts/services/shifts-command.service.ts | 21 -------- 6 files changed, 103 insertions(+), 28 deletions(-) create mode 100644 src/common/filters/http-exception.filter.ts diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 133f2d5..99f16ad 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -876,6 +876,52 @@ ] } }, + "/shifts/upsert/{email}/{date}": { + "put": { + "operationId": "ShiftsController_upsert_by_date", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "date", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertShiftDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "access-token": [] + } + ], + "tags": [ + "Shifts" + ] + } + }, "/shifts": { "post": { "operationId": "ShiftsController_create", @@ -2513,6 +2559,10 @@ } } }, + "UpsertShiftDto": { + "type": "object", + "properties": {} + }, "CreateShiftDto": { "type": "object", "properties": { diff --git a/src/app.module.ts b/src/app.module.ts index 7a4aadf..ebd5c5d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { BadRequestException, Module, ValidationPipe } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ArchivalModule } from './modules/archival/archival.module'; @@ -22,6 +22,9 @@ import { ShiftsModule } from './modules/shifts/shifts.module'; import { TimesheetsModule } from './modules/timesheets/timesheets.module'; import { UsersModule } from './modules/users-management/users.module'; import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER, APP_PIPE } from '@nestjs/core'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { ValidationError } from 'class-validator'; @Module({ imports: [ @@ -46,6 +49,29 @@ import { ConfigModule } from '@nestjs/config'; UsersModule, ], controllers: [AppController, HealthController], - providers: [AppService, OvertimeService], + providers: [ + AppService, + OvertimeService, + { + provide: APP_FILTER, + useClass: HttpExceptionFilter + }, + { + provide: APP_PIPE, + useValue: new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + exceptionFactory: (errors: ValidationError[] = [])=> { + const messages = errors.flatMap((e)=> Object.values(e.constraints ?? {})); + return new BadRequestException({ + statusCode: 400, + error: 'Bad Request', + message: messages.length ? messages : errors, + }); + }, + }), + }, + ], }) export class AppModule {} diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..a44c4c9 --- /dev/null +++ b/src/common/filters/http-exception.filter.ts @@ -0,0 +1,24 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/common"; +import { Request, Response } from 'express'; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: HttpException, host: ArgumentsHost) { + const http_context = host.switchToHttp(); + const response = http_context.getResponse(); + const request = http_context.getRequest(); + const http_status = exception.getStatus(); + + const exception_response = exception.getResponse(); + const normalized = typeof exception_response === 'string' + ? { message: exception_response } + : (exception_response as Record); + const response_body = { + statusCode: http_status, + timestamp: new Date().toISOString(), + path: request.url, + ...normalized, + }; + response.status(http_status).json(response_body); + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 504ffe1..88237b6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,6 @@ import { ATT_TMP_DIR } from './config/attachment.config'; // log to be removed p import { ModuleRef, NestFactory, Reflector } from '@nestjs/core'; import { AppModule } from './app.module'; -import { ValidationPipe } from '@nestjs/common'; // import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; import { OwnershipGuard } from './common/guards/ownership.guard'; @@ -25,13 +24,11 @@ async function bootstrap() { const reflector = app.get(Reflector); //setup Reflector for Roles() - app.useGlobalPipes( - new ValidationPipe({ whitelist: true, transform: true})); app.useGlobalGuards( // new JwtAuthGuard(reflector), //Authentification JWT new RolesGuard(reflector), //deny-by-default and Role-based Access Control new OwnershipGuard(reflector, app.get(ModuleRef)), //Global use of OwnershipGuard, not implemented yet - ); + ); // Authentication and session app.use(session({ diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts index 3cf3683..94ecf5e 100644 --- a/src/modules/shifts/helpers/shifts-date-time-helpers.ts +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -16,4 +16,3 @@ export function toDateOnlyUTC(input: string | Date): Date { const date = new Date(input); return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); } - diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index a864ace..451c189 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -4,7 +4,6 @@ import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { PrismaService } from "src/prisma/prisma.service"; import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto"; import { timeFromHHMMUTC, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers"; -import { error, time } from "console"; type DayShiftResponse = { start_time: string; @@ -16,14 +15,10 @@ type DayShiftResponse = { type UpsertAction = 'created' | 'updated' | 'deleted'; - - - @Injectable() export class ShiftsCommandService extends BaseApprovalService { constructor(prisma: PrismaService) { super(prisma); } - //create/update/delete master method async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto): Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { @@ -230,7 +225,6 @@ async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto) return result; } - private normalize_shift_payload(payload: ShiftPayloadDto) { //normalize shift's infos const start_time = timeFromHHMMUTC(payload.start_time); @@ -271,8 +265,6 @@ async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto) return `${hh}:${mm}`; } - - //approval methods protected get delegate() { @@ -288,17 +280,4 @@ async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto) this.updateApprovalWithTransaction(transaction, id, is_approved), ); } - - - - - - /* - old without new = delete - new without old = post - old with new = patch old with new - */ - async upsertShift(old_shift?: UpsertShiftDto, new_shift?: UpsertShiftDto) { - - } } \ No newline at end of file From cc48584b998bb7a9fd9e869b7cc1907067daa452 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 16 Sep 2025 09:17:59 -0400 Subject: [PATCH 33/69] feat(shifts): ajusted validations for create/update/delete shifts --- .../shifts/controllers/shifts.controller.ts | 4 +--- src/modules/shifts/dtos/upsert-shift.dto.ts | 14 +++++++++++--- .../shifts/services/shifts-command.service.ts | 12 ++++++++---- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index b245a82..b323988 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -28,9 +28,7 @@ export class ShiftsController { @Param('date') date_param: string, @Body() payload: UpsertShiftDto, ) { - const email = decodeURIComponent(email_param); - const date = date_param; - return this.shiftsCommandService.upsertShfitsByDate(email, date, payload); + return this.shiftsCommandService.upsertShfitsByDate(email_param, date_param, payload); } @Post() diff --git a/src/modules/shifts/dtos/upsert-shift.dto.ts b/src/modules/shifts/dtos/upsert-shift.dto.ts index 4af789c..83900dd 100644 --- a/src/modules/shifts/dtos/upsert-shift.dto.ts +++ b/src/modules/shifts/dtos/upsert-shift.dto.ts @@ -1,13 +1,14 @@ -import { IsBoolean, IsOptional, IsString, Matches, MaxLength } from "class-validator"; +import { Type } from "class-transformer"; +import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator"; export const COMMENT_MAX_LENGTH = 512; export class ShiftPayloadDto { - @Matches(/^\d{2}:\d{2}$/, {message: 'start_time must be HH:mm' }) + @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/) start_time!: string; - @Matches(/^\d{2}:\d{2}$/, {message: 'start_time must be HH:mm' }) + @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/) end_time!: string; @IsString() @@ -24,6 +25,13 @@ export class ShiftPayloadDto { export class UpsertShiftDto { + @IsOptional() + @ValidateNested() + @Type(()=> ShiftPayloadDto) old_shift?: ShiftPayloadDto; + + @IsOptional() + @ValidateNested() + @Type(()=> ShiftPayloadDto) new_shift?: ShiftPayloadDto; }; \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 451c189..e0c55af 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException, ParseUUIDPipe, UnprocessableEntityException } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; import { Prisma, Shifts } from "@prisma/client"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { PrismaService } from "src/prisma/prisma.service"; @@ -22,6 +22,8 @@ export class ShiftsCommandService extends BaseApprovalService { //create/update/delete master method async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto): Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { + const { old_shift, new_shift } = dto; + if(!dto.old_shift && !dto.new_shift) { throw new BadRequestException('At least one of old or new shift must be provided'); } @@ -145,7 +147,7 @@ async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto) }; // DELETE - if ( dto.old_shift && !dto.new_shift ) { + if ( old_shift && !new_shift ) { const existing = await find_exact_old_shift(); if(!existing) { throw new NotFoundException({ @@ -157,7 +159,7 @@ async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto) action = 'deleted'; } // CREATE - else if (!dto.old_shift && dto.new_shift) { + else if (!old_shift && new_shift) { assert_no_overlap(); await transaction.shifts.create({ data: { @@ -173,7 +175,7 @@ async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto) action = 'created'; } //UPDATE - else { + else if (old_shift && new_shift){ const existing = await find_exact_old_shift(); if(!existing) { throw new NotFoundException({ @@ -195,6 +197,8 @@ async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto) }, }); action = 'updated'; + } else { + throw new BadRequestException('At least one of old_shift or new_shift must be provided'); } //Reload the day (truth source) From bcf73927bd5e49d3b2ad242c07a460e6c3249b4b Mon Sep 17 00:00:00 2001 From: Nicolas Drolet Date: Tue, 16 Sep 2025 11:20:50 -0400 Subject: [PATCH 34/69] fix(payperiod): add minor fix to total hours calculated method --- .../pay-periods/services/pay-periods-query.service.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index c921fd3..15932b3 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -239,14 +239,13 @@ export class PayPeriodsQueryService { const hours = computeHours(shift.start_time, shift.end_time); const type = (shift.bank_code?.type ?? '').toUpperCase(); switch (type) { - case "EVENING": record.evening_hours += hours; break; - case "EMERGENCY": record.emergency_hours += hours; break; - case "OVERTIME": record.overtime_hours += hours; break; - case "REGULAR" : record.regular_hours += hours; break; + case "EVENING": record.evening_hours += hours; record.total_hours += hours; break; + case "EMERGENCY": record.emergency_hours += hours; record.total_hours += hours; break; + case "OVERTIME": record.overtime_hours += hours; record.total_hours += hours; break; + case "REGULAR" : record.regular_hours += hours; record.total_hours += hours; break; } record.is_approved = record.is_approved && shift.timesheet.is_approved; - record.total_hours += hours; record.is_remote = record.is_remote || !!shift.is_remote; } From 46deae63bccc52f04d9073105e56f63c39617846 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 16 Sep 2025 14:47:35 -0400 Subject: [PATCH 35/69] fix(holiday): minor valid_codes fix --- src/modules/business-logics/services/holiday.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index 4fce9e0..c0c8393 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -32,7 +32,7 @@ export class HolidayService { //sets the start of the window to 28 days ( 4 completed weeks ) before the week with the holiday const window_start = new Date(window_end.getTime() - 28 * 24 * 60 * 60000 + 1 ) - const valid_codes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700']; + const valid_codes = ['G1', 'G43', 'G56', 'G104', 'G105', 'G700']; //fetches all shift of the employee in said window ( 4 previous completed weeks ) const shifts = await this.prisma.shifts.findMany({ where: { timesheet: { employee_id: employee_id } , From 1c797c348a5a5ae848db7e36248ac25838abb7f7 Mon Sep 17 00:00:00 2001 From: Nicolas Drolet Date: Fri, 26 Sep 2025 11:40:04 -0400 Subject: [PATCH 36/69] "refactor(timesheet): add full employee name to return data to display in timesheet approval employee details dialog header" --- .../timesheets/dtos/timesheet-period.dto.ts | 1 + .../services/timesheets-query.service.ts | 17 +++++++++++++++-- .../timesheets/utils/timesheet.helpers.ts | 6 ++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index cfd0194..6da4551 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -61,4 +61,5 @@ export class WeekDto { export class TimesheetPeriodDto { week1: WeekDto; week2: WeekDto; + employee_full_name: string; } diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index f4517e2..67ed8e2 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -20,9 +20,22 @@ export class TimesheetsQueryService { //finds the employee const employee = await this.prisma.employees.findFirst({ where: { user: { is: { email } } }, - select: { id: true }, + select: { + id: true, + user_id: true, + }, }); if(!employee) throw new NotFoundException(`no employee with email ${email} found`); + + //gets the employee's full name + const user = await this.prisma.users.findFirst({ + where: { id: employee.user_id }, + select: { + first_name: true, + last_name: true, + } + }); + const employee_full_name: string = ( user?.first_name + " " + user?.last_name ) || " "; //finds the period const period = await this.prisma.payPeriods.findFirst({ @@ -92,7 +105,7 @@ export class TimesheetsQueryService { type: String(expense.bank_code?.type ?? '').toUpperCase(), })); - return buildPeriod(period.period_start, period.period_end, shifts , expenses); + return buildPeriod(period.period_start, period.period_end, shifts , expenses, employee_full_name); } async getTimesheetByEmail(email: string, week_offset = 0): Promise { diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 95ee5f3..05cfc38 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -127,7 +127,7 @@ export function makeEmptyWeek(week_start: Date): WeekDto { } export function makeEmptyPeriod(): TimesheetPeriodDto { - return { week1: makeEmptyWeek(new Date()), week2: makeEmptyWeek(new Date()) }; + return { week1: makeEmptyWeek(new Date()), week2: makeEmptyWeek(new Date()), employee_full_name: " " }; } export function buildWeek( @@ -301,7 +301,8 @@ export function buildPeriod( period_start: Date, period_end: Date, shifts: ShiftRow[], - expenses: ExpenseRow[] + expenses: ExpenseRow[], + employee_full_name: string, ): TimesheetPeriodDto { const week1_start = toUTCDateOnly(period_start); const week1_end = endOfDayUTC(addDays(week1_start, 6)); @@ -311,6 +312,7 @@ export function buildPeriod( return { week1: buildWeek(week1_start, week1_end, shifts, expenses), week2: buildWeek(week2_start, week2_end, shifts, expenses), + employee_full_name, }; } From 52114deb337265c474830ca3b38625daa4167ff4 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 30 Sep 2025 10:43:48 -0400 Subject: [PATCH 37/69] feat(expenses): upsert function for expenses --- prisma/schema.prisma | 12 +- src/app.module.ts | 30 +-- .../controllers/expenses.controller.ts | 29 ++- .../expenses/dtos/create-expense.dto.ts | 2 +- .../expenses/dtos/upsert-expense.dto.ts | 42 ++++ src/modules/expenses/expenses.module.ts | 11 +- src/modules/expenses/repos/bank-codes.repo.ts | 34 +++ src/modules/expenses/repos/employee.repo.ts | 32 +++ src/modules/expenses/repos/timesheets.repo.ts | 42 ++++ .../services/expenses-command.service.ts | 216 +++++++++++++++++- .../services/expenses-query.service.ts | 2 +- .../expenses.types.interfaces.ts | 14 ++ src/modules/expenses/utils/expenses.utils.ts | 65 ++++++ .../shifts/controllers/shifts.controller.ts | 2 +- src/modules/shifts/dtos/upsert-shift.dto.ts | 2 +- .../shifts/services/shifts-command.service.ts | 2 +- .../services/timesheets-query.service.ts | 28 +-- 17 files changed, 512 insertions(+), 53 deletions(-) create mode 100644 src/modules/expenses/dtos/upsert-expense.dto.ts create mode 100644 src/modules/expenses/repos/bank-codes.repo.ts create mode 100644 src/modules/expenses/repos/employee.repo.ts create mode 100644 src/modules/expenses/repos/timesheets.repo.ts create mode 100644 src/modules/expenses/types and interfaces/expenses.types.interfaces.ts create mode 100644 src/modules/expenses/utils/expenses.utils.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d2c344c..9e56a04 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -225,9 +225,10 @@ model Expenses { bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int date DateTime @db.Date - amount Decimal @db.Money - attachement String? - comment String? + amount Decimal @db.Money + mileage Decimal? + attachment String? + comment String is_approved Boolean @default(false) supervisor_comment String? @@ -244,8 +245,9 @@ model ExpensesArchive { archived_at DateTime @default(now()) bank_code_id Int date DateTime @db.Date - amount Decimal @db.Money - attachement String? + amount Decimal? @db.Money + mileage Decimal? + attachment String? comment String? is_approved Boolean supervisor_comment String? diff --git a/src/app.module.ts b/src/app.module.ts index ebd5c5d..407535a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,30 +1,30 @@ import { BadRequestException, Module, ValidationPipe } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; import { ArchivalModule } from './modules/archival/archival.module'; import { AuthenticationModule } from './modules/authentication/auth.module'; -import { BankCodesModule } from './modules/bank-codes/bank-codes.module'; +import { BankCodesModule } from './modules/bank-codes/bank-codes.module'; import { BusinessLogicsModule } from './modules/business-logics/business-logics.module'; // import { CsvExportModule } from './modules/exports/csv-exports.module'; -import { CustomersModule } from './modules/customers/customers.module'; -import { EmployeesModule } from './modules/employees/employees.module'; -import { ExpensesModule } from './modules/expenses/expenses.module'; -import { HealthModule } from './health/health.module'; +import { CustomersModule } from './modules/customers/customers.module'; +import { EmployeesModule } from './modules/employees/employees.module'; +import { ExpensesModule } from './modules/expenses/expenses.module'; +import { HealthModule } from './health/health.module'; import { HealthController } from './health/health.controller'; import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module'; -import { OvertimeService } from './modules/business-logics/services/overtime.service'; +import { OvertimeService } from './modules/business-logics/services/overtime.service'; import { PayperiodsModule } from './modules/pay-periods/pay-periods.module'; -import { PrismaModule } from './prisma/prisma.module'; -import { ScheduleModule } from '@nestjs/schedule'; -import { ShiftsModule } from './modules/shifts/shifts.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ShiftsModule } from './modules/shifts/shifts.module'; import { TimesheetsModule } from './modules/timesheets/timesheets.module'; -import { UsersModule } from './modules/users-management/users.module'; -import { ConfigModule } from '@nestjs/config'; +import { UsersModule } from './modules/users-management/users.module'; +import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_PIPE } from '@nestjs/core'; -import { HttpExceptionFilter } from './common/filters/http-exception.filter'; -import { ValidationError } from 'class-validator'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { ValidationError } from 'class-validator'; @Module({ imports: [ diff --git a/src/modules/expenses/controllers/expenses.controller.ts b/src/modules/expenses/controllers/expenses.controller.ts index f4f20cb..0309397 100644 --- a/src/modules/expenses/controllers/expenses.controller.ts +++ b/src/modules/expenses/controllers/expenses.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; import { ExpensesQueryService } from "../services/expenses-query.service"; import { CreateExpenseDto } from "../dtos/create-expense.dto"; import { Expenses } from "@prisma/client"; @@ -8,6 +8,8 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagg import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { ExpensesCommandService } from "../services/expenses-command.service"; import { SearchExpensesDto } from "../dtos/search-expense.dto"; +import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; +import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces"; @ApiTags('Expenses') @ApiBearerAuth('access-token') @@ -15,17 +17,26 @@ import { SearchExpensesDto } from "../dtos/search-expense.dto"; @Controller('Expenses') export class ExpensesController { constructor( - private readonly expensesService: ExpensesQueryService, - private readonly expensesApprovalService: ExpensesCommandService, + private readonly query: ExpensesQueryService, + private readonly command: ExpensesCommandService, ) {} + @Put('upsert/:email/:date') + async upsert_by_date( + @Param('email') email: string, + @Param('date') date: string, + @Body() dto: UpsertExpenseDto, + ): Promise { + return this.command.upsertExpensesByDate(email, date, dto); + } + @Post() //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) @ApiOperation({ summary: 'Create expense' }) @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto }) @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) create(@Body() dto: CreateExpenseDto): Promise { - return this.expensesService.create(dto); + return this.query.create(dto); } @Get() @@ -35,7 +46,7 @@ export class ExpensesController { @ApiResponse({ status: 400, description: 'List of expenses not found' }) @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) findAll(@Query() filters: SearchExpensesDto): Promise { - return this.expensesService.findAll(filters); + return this.query.findAll(filters); } @Get(':id') @@ -44,7 +55,7 @@ export class ExpensesController { @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto }) @ApiResponse({ status: 400, description: 'Expense not found' }) findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.expensesService.findOne(id); + return this.query.findOne(id); } @Patch(':id') @@ -53,7 +64,7 @@ export class ExpensesController { @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto }) @ApiResponse({ status: 400, description: 'Expense not found' }) update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) { - return this.expensesService.update(id,dto); + return this.query.update(id,dto); } @Delete(':id') @@ -62,13 +73,13 @@ export class ExpensesController { @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto }) @ApiResponse({ status: 400, description: 'Expense not found' }) remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.expensesService.remove(id); + return this.query.remove(id); } @Patch('approval/:id') //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { - return this.expensesApprovalService.updateApproval(id, isApproved); + return this.command.updateApproval(id, isApproved); } } \ No newline at end of file diff --git a/src/modules/expenses/dtos/create-expense.dto.ts b/src/modules/expenses/dtos/create-expense.dto.ts index b840a14..d0e4863 100644 --- a/src/modules/expenses/dtos/create-expense.dto.ts +++ b/src/modules/expenses/dtos/create-expense.dto.ts @@ -46,7 +46,7 @@ export class CreateExpenseDto { description:'explain`s why the expense was made' }) @IsString() - comment?: string; + comment: string; @ApiProperty({ example: 'DENIED, APPROUVED, PENDING, etc...', diff --git a/src/modules/expenses/dtos/upsert-expense.dto.ts b/src/modules/expenses/dtos/upsert-expense.dto.ts new file mode 100644 index 0000000..f7975d4 --- /dev/null +++ b/src/modules/expenses/dtos/upsert-expense.dto.ts @@ -0,0 +1,42 @@ +import { Transform, Type } from "class-transformer"; +import { IsDefined, IsNumber, IsOptional, IsString, maxLength, MaxLength, Min, ValidateIf, ValidateNested } from "class-validator"; + +export class ExpensePayloadDto { + @IsString() + type!: string; + + @ValidateIf(o => (o.type ?? '').toUpperCase() !== 'MILEAGE') + @IsDefined() + @IsNumber() + @Min(0) + amount!: number; + + @ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE') + @IsDefined() + @IsNumber() + @Min(0) + mileage!: number; + + @IsString() + @MaxLength(280) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + comment!: string; + + @IsOptional() + @IsString() + @MaxLength(255) + attachment?: string; +} + + +export class UpsertExpenseDto { + @IsOptional() + @ValidateNested() + @Type(()=> ExpensePayloadDto) + old_expense?: ExpensePayloadDto; + + @IsOptional() + @ValidateNested() + @Type(()=> ExpensePayloadDto) + new_expense?: ExpensePayloadDto; +} \ No newline at end of file diff --git a/src/modules/expenses/expenses.module.ts b/src/modules/expenses/expenses.module.ts index 04c3965..2cbd302 100644 --- a/src/modules/expenses/expenses.module.ts +++ b/src/modules/expenses/expenses.module.ts @@ -3,11 +3,20 @@ import { Module } from "@nestjs/common"; import { ExpensesQueryService } from "./services/expenses-query.service"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; import { ExpensesCommandService } from "./services/expenses-command.service"; +import { BankCodesRepo } from "./repos/bank-codes.repo"; +import { TimesheetsRepo } from "./repos/timesheets.repo"; +import { EmployeesRepo } from "./repos/employee.repo"; @Module({ imports: [BusinessLogicsModule], controllers: [ExpensesController], - providers: [ExpensesQueryService, ExpensesCommandService], + providers: [ + ExpensesQueryService, + ExpensesCommandService, + BankCodesRepo, + TimesheetsRepo, + EmployeesRepo, + ], exports: [ ExpensesQueryService ], }) diff --git a/src/modules/expenses/repos/bank-codes.repo.ts b/src/modules/expenses/repos/bank-codes.repo.ts new file mode 100644 index 0000000..1de277d --- /dev/null +++ b/src/modules/expenses/repos/bank-codes.repo.ts @@ -0,0 +1,34 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class BankCodesRepo { + constructor(private readonly prisma: PrismaService) {} + + //find id and modifier by type + readonly findByType = async ( type: string, client?: Tx + ): Promise<{id:number; modifier: number }> => { + const db = client ?? this.prisma; + const bank = await db.bankCodes.findFirst({ + where: { + type, + }, + select: { + id: true, + modifier: true, + }, + }); + + if(!bank) { + throw new NotFoundException(`Unknown bank code type: ${type}`); + } + return { + id: bank.id, + modifier: bank.modifier, + }; + }; +} \ No newline at end of file diff --git a/src/modules/expenses/repos/employee.repo.ts b/src/modules/expenses/repos/employee.repo.ts new file mode 100644 index 0000000..aeefe53 --- /dev/null +++ b/src/modules/expenses/repos/employee.repo.ts @@ -0,0 +1,32 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class EmployeesRepo { + constructor(private readonly prisma: PrismaService) {} + + // find employee id by email + readonly findIdByEmail = async ( email: string, client?: Tx + ): Promise => { + const db = client ?? this.prisma; + const employee = await db.employees.findFirst({ + where: { + user: { + email, + }, + }, + select: { + id: true, + }, + }); + + if(!employee) { + throw new NotFoundException(`Employee with email: ${email} not found`); + } + return employee.id; + } +} \ No newline at end of file diff --git a/src/modules/expenses/repos/timesheets.repo.ts b/src/modules/expenses/repos/timesheets.repo.ts new file mode 100644 index 0000000..e140402 --- /dev/null +++ b/src/modules/expenses/repos/timesheets.repo.ts @@ -0,0 +1,42 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { weekStartMondayUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; +import { PrismaService } from "src/prisma/prisma.service"; + + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class TimesheetsRepo { + constructor(private readonly prisma: PrismaService) {} + + //find an existing timesheet linked to the employee + readonly ensureForDate = async (employee_id: number, date: Date, client?: Tx, + ): Promise<{id: number; start_date: Date }> => { + const db = client ?? this.prisma; + const startOfWeek = weekStartMondayUTC(date); + const existing = await db.timesheets.findFirst({ + where: { + employee_id: employee_id, + start_date: startOfWeek, + }, + select: { + id: true, + start_date: true, + }, + }); + if(existing) return existing; + + const created = await db.timesheets.create({ + data: { + employee_id: employee_id, + start_date: startOfWeek, + }, + select: { + id: true, + start_date: true, + }, + }); + return created; + } +} \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 7a8f722..41fd316 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -1,13 +1,23 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { Expenses, Prisma } from "@prisma/client"; -import { Decimal } from "@prisma/client/runtime/library"; -import { transcode } from "buffer"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { PrismaService } from "src/prisma/prisma.service"; +import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; +import { BankCodesRepo } from "../repos/bank-codes.repo"; +import { TimesheetsRepo } from "../repos/timesheets.repo"; +import { EmployeesRepo } from "../repos/employee.repo"; +import { assertAndTrimComment, computeMileageAmount, mapDbExpenseToDayResponse, normalizeType as normalizeTypeUtil } from "../utils/expenses.utils"; +import { DayExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; +import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; @Injectable() export class ExpensesCommandService extends BaseApprovalService { - constructor(prisma: PrismaService) { super(prisma); } + constructor( + prisma: PrismaService, + private readonly bankCodesRepo: BankCodesRepo, + private readonly timesheetsRepo: TimesheetsRepo, + private readonly employeesRepo: EmployeesRepo, + ) { super(prisma); } protected get delegate() { return this.prisma.expenses; @@ -22,4 +32,202 @@ export class ExpensesCommandService extends BaseApprovalService { this.updateApprovalWithTransaction(transaction, id, isApproved), ); } + + //-------------------- Master CRUD function -------------------- + readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto, + ): Promise<{ action:UpsertAction; day: DayExpenseResponse[] }> => { + + //validates if there is an existing expense, at least 1 old or new + const { old_expense, new_expense } = dto ?? {}; + if(!old_expense && !new_expense) { + throw new BadRequestException('At least one expense must be provided'); + } + + //validate date format + const dateOnly = toDateOnlyUTC(date); + if(Number.isNaN(dateOnly.getTime())) { + throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)'); + } + + //resolve employee_id by email + const employee_id = await this.resolveEmployeeIdByEmail(email); + + //make sure a timesheet existes + const timesheet_id = await this.ensureTimesheetForDate(employee_id, dateOnly); + + return this.prisma.$transaction(async (tx) => { + const loadDay = async (): Promise => { + const rows = await tx.expenses.findMany({ + where: { + timesheet_id: timesheet_id, + date: dateOnly, + }, + include: { + bank_code: { + select: { + type: true, + }, + }, + }, + orderBy: [{ date: 'asc' }, { id: 'asc' }], + }); + + return rows.map(this.mapDbToDayResponse); + }; + + const normalizePayload = async (payload: { + type: string; + amount?: number; + mileage?: number; + comment: string; + attachment?: string; + }): Promise<{ + type: string; + bank_code_id: number; + amount: Prisma.Decimal; + comment: string; + attachment: string | null; + }> => { + const type = this.normalizeType(payload.type); + const comment = this.assertAndTrimComment(payload.comment); + const attachment = payload.attachment?.trim()?.length ? payload.attachment.trim() : null; + const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type); + const amount = this.computeAmountDecimal(type, payload, modifier); + + return { + type, + bank_code_id, + amount, + comment, + attachment + }; + }; + + const findExactOld = async (norm: { + bank_code_id: number; + amount: Prisma.Decimal; + comment: string; + attachment: string | null; + }) => { + return tx.expenses.findFirst({ + where: { + timesheet_id: timesheet_id, + date: dateOnly, + bank_code_id: norm.bank_code_id, + amount: norm.amount, + comment: norm.comment, + attachment: norm.attachment, + }, + select: { id: true }, + }); + }; + + let action : UpsertAction; + //-------------------- DELETE -------------------- + if(old_expense && !new_expense) { + const oldNorm = await normalizePayload(old_expense); + const existing = await findExactOld(oldNorm); + if(!existing) { + throw new NotFoundException({ + error_code: 'EXPENSE_STALE', + message: 'The expense was modified or deleted by someone else', + }); + } + await tx.expenses.delete({where: { id: existing.id } }); + action = 'deleted'; + } + //-------------------- CREATE -------------------- + else if (!old_expense && new_expense) { + const new_exp = await normalizePayload(new_expense); + await tx.expenses.create({ + data: { + timesheet_id: timesheet_id, + date: dateOnly, + bank_code_id: new_exp.bank_code_id, + amount: new_exp.amount, + mileage: null, + comment: new_exp.comment, + attachment: new_exp.attachment, + is_approved: false, + }, + }); + action = 'created'; + } + + else if(old_expense && new_expense) { + const oldNorm = await normalizePayload(old_expense); + const existing = await findExactOld(oldNorm); + if(!existing) { + throw new NotFoundException({ + error_code: 'EXPENSE_STALE', + message: 'The expense was modified or deleted by someone else', + }); + } + + const new_exp = await normalizePayload(new_expense); + await tx.expenses.update({ + where: { id: existing.id }, + data: { + bank_code_id: new_exp.bank_code_id, + amount: new_exp.amount, + mileage: null, + comment: new_exp.comment, + attachment: new_exp.attachment, + }, + }); + action = 'updated'; + } + else { + throw new BadRequestException('Invalid upsert combination'); + } + + const day = await loadDay(); + + return { action, day }; + }); + } + + + //helpers imported from utils and repos. + private readonly normalizeType = (type: string): string => + normalizeTypeUtil(type); + + private readonly assertAndTrimComment = (comment: string): string => + assertAndTrimComment(comment); + + private readonly resolveEmployeeIdByEmail = async (email: string): Promise => + this.employeesRepo.findIdByEmail(email); + + private readonly ensureTimesheetForDate = async ( employee_id: number, date: Date + ): Promise => { + const { id } = await this.timesheetsRepo.ensureForDate(employee_id, date); + return id; + }; + + private readonly lookupBankCodeOrThrow = async ( transaction: Prisma.TransactionClient, type: string + ): Promise<{id: number; modifier: number}> => + this.bankCodesRepo.findByType(type, transaction); + + private readonly computeAmountDecimal = ( + type: string, + payload: { amount?: number; mileage?: number;}, + modifier: number, + ): Prisma.Decimal => { + if(type === 'MILEAGE') { + const km = payload.mileage ?? 0; + const amountNumber = computeMileageAmount(km, modifier); + return new Prisma.Decimal(amountNumber); + } + return new Prisma.Decimal(payload.amount!); + }; + + private readonly mapDbToDayResponse = (row: { + date: Date; + amount: Prisma.Decimal | number | string; + comment: string; + is_approved: boolean; + bank_code: { type: string } | null; + }): DayExpenseResponse => mapDbExpenseToDayResponse(row); + + } \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index 42c9679..b719a79 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -121,7 +121,7 @@ export class ExpensesQueryService { bank_code_id: exp.bank_code_id, date: exp.date, amount: exp.amount, - attachement: exp.attachement, + attachment: exp.attachment, comment: exp.comment, is_approved: exp.is_approved, supervisor_comment: exp.supervisor_comment, diff --git a/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts new file mode 100644 index 0000000..e567070 --- /dev/null +++ b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts @@ -0,0 +1,14 @@ +export type UpsertAction = 'created' | 'updated' | 'deleted'; + +export interface DayExpenseResponse { + date: string; + type: string; + amount: number; + comment: string; + is_approved: boolean; +}; + +export type UpsertExpenseResult = { + action: UpsertAction; + day: DayExpenseResponse[] +}; \ No newline at end of file diff --git a/src/modules/expenses/utils/expenses.utils.ts b/src/modules/expenses/utils/expenses.utils.ts new file mode 100644 index 0000000..9dd2497 --- /dev/null +++ b/src/modules/expenses/utils/expenses.utils.ts @@ -0,0 +1,65 @@ +import { BadRequestException } from "@nestjs/common"; +import { DayExpenseResponse } from "../types and interfaces/expenses.types.interfaces"; + +//uppercase and trim for validation +export function normalizeType(type: string): string { + return (type ?? '').trim().toUpperCase(); +}; + +//required comment after trim +export function assertAndTrimComment(comment: string): string { + const cmt = (comment ?? '').trim(); + if(cmt.length === 0) { + throw new BadRequestException('A comment is required'); + } + return cmt; +}; + +//rounding $ to 2 decimals +export function roundMoney2(num: number): number { + return Math.round((num + Number.EPSILON) * 100)/ 100; +}; + +export function computeMileageAmount(km: number, modifier: number): number { + if(km < 0) throw new BadRequestException('mileage must be positive'); + if(modifier < 0) throw new BadRequestException('modifier must be positive'); + return roundMoney2(km * modifier); +}; + +//compat. types with Prisma.Decimal. work around Prisma import in utils. +export type DecimalLike = + | number + | string + | { toNumber?: () => number } + | { toString?: () => string }; + + +//safe conversion to number +export function toNumberSafe(value: DecimalLike): number { + if(typeof value === 'number') return value; + if(value && typeof (value as any).toNumber === 'function') return (value as any).toNumber(); + return Number( + typeof (value as any)?.toString === 'function' + ? (value as any).toString() + : value, + ); +} + + +//map of a row for DayExpenseResponse +export function mapDbExpenseToDayResponse(row: { + date: Date; + amount: DecimalLike; + comment: string; + is_approved: boolean; + bank_code?: { type?: string | null } | null; +}): DayExpenseResponse { + const yyyyMmDd = row.date.toISOString().slice(0,10); + return { + date: yyyyMmDd, + type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'), + amount: toNumberSafe(row.amount), + comment: row.comment, + is_approved: row.is_approved, + }; +} \ No newline at end of file diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index b323988..89af236 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -28,7 +28,7 @@ export class ShiftsController { @Param('date') date_param: string, @Body() payload: UpsertShiftDto, ) { - return this.shiftsCommandService.upsertShfitsByDate(email_param, date_param, payload); + return this.shiftsCommandService.upsertShiftsByDate(email_param, date_param, payload); } @Post() diff --git a/src/modules/shifts/dtos/upsert-shift.dto.ts b/src/modules/shifts/dtos/upsert-shift.dto.ts index 83900dd..b82fbb5 100644 --- a/src/modules/shifts/dtos/upsert-shift.dto.ts +++ b/src/modules/shifts/dtos/upsert-shift.dto.ts @@ -1,7 +1,7 @@ import { Type } from "class-transformer"; import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator"; -export const COMMENT_MAX_LENGTH = 512; +export const COMMENT_MAX_LENGTH = 280; export class ShiftPayloadDto { diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index e0c55af..cccfbcc 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -20,7 +20,7 @@ export class ShiftsCommandService extends BaseApprovalService { constructor(prisma: PrismaService) { super(prisma); } //create/update/delete master method -async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto): +async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto): Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { const { old_shift, new_shift } = dto; diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index f4517e2..11961c9 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -1,12 +1,12 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { PrismaService } from 'src/prisma/prisma.service'; import { Timesheets, TimesheetsArchive } from '@prisma/client'; -import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; -import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; -import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; +import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; +import { TimesheetDto } from '../dtos/overview-timesheet.dto'; +import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers'; -import { TimesheetDto } from '../dtos/overview-timesheet.dto'; @Injectable() @@ -161,14 +161,14 @@ export class TimesheetsQueryService { const to_HH_mm = (date: Date) => date.toISOString().slice(11, 16); //maps all shifts of selected timesheet - const shifts = timesheet.shift.map((sft) => ({ - bank_type: sft.bank_code?.type ?? '', - date: formatDateISO(sft.date), - start_time: to_HH_mm(sft.start_time), - end_time: to_HH_mm(sft.end_time), - comment: sft.comment ?? '', - is_approved: sft.is_approved ?? false, - is_remote: sft.is_remote ?? false, + const shifts = timesheet.shift.map((shift_row) => ({ + bank_type: shift_row.bank_code?.type ?? '', + date: formatDateISO(shift_row.date), + start_time: to_HH_mm(shift_row.start_time), + end_time: to_HH_mm(shift_row.end_time), + comment: shift_row.comment ?? '', + is_approved: shift_row.is_approved ?? false, + is_remote: shift_row.is_remote ?? false, })); //maps all expenses of selected timsheet From 3b4dd9ddb5f868bff5bce9a88885840932af91ff Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 30 Sep 2025 11:12:46 -0400 Subject: [PATCH 38/69] fix(mock): small typo in mock datas --- docs/swagger/swagger-spec.json | 50 +++++++++++++++++++ prisma/mock-seeds-scripts/12-expenses.ts | 2 +- .../mock-seeds-scripts/13-expenses-archive.ts | 4 +- src/modules/expenses/expenses.module.ts | 7 ++- .../services/expenses-command.service.ts | 14 +++--- src/modules/pay-periods/pay-periods.module.ts | 7 ++- src/modules/timesheets/timesheets.module.ts | 8 ++- 7 files changed, 79 insertions(+), 13 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 99f16ad..68cdc3d 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -654,6 +654,52 @@ ] } }, + "/Expenses/upsert/{email}/{date}": { + "put": { + "operationId": "ExpensesController_upsert_by_date", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "date", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertExpenseDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "access-token": [] + } + ], + "tags": [ + "Expenses" + ] + } + }, "/Expenses": { "post": { "operationId": "ExpensesController_create", @@ -2459,6 +2505,10 @@ "type": "object", "properties": {} }, + "UpsertExpenseDto": { + "type": "object", + "properties": {} + }, "CreateExpenseDto": { "type": "object", "properties": { diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index c0641a8..00f6f0c 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -143,7 +143,7 @@ async function main() { bank_code_id, date, amount, // string "xx.yy" (2 décimales exactes) - attachement: null, + attachment: null, comment: `Expense ${code} ${amount}$ (emp ${e.id})`, is_approved: Math.random() < 0.65, supervisor_comment: Math.random() < 0.25 ? 'OK' : null, diff --git a/prisma/mock-seeds-scripts/13-expenses-archive.ts b/prisma/mock-seeds-scripts/13-expenses-archive.ts index 86d2c3b..d8e35a2 100644 --- a/prisma/mock-seeds-scripts/13-expenses-archive.ts +++ b/prisma/mock-seeds-scripts/13-expenses-archive.ts @@ -32,7 +32,7 @@ async function main() { bank_code_id: bc.id, date: daysAgo(60 + i), amount: (20 + i * 3.5).toFixed(2), // ok: Decimal accepte string - attachement: null, + attachment: null, comment: `Old expense #${i + 1}`, is_approved: true, supervisor_comment: null, @@ -50,7 +50,7 @@ async function main() { bank_code_id: e.bank_code_id, date: e.date, amount: e.amount, - attachement: e.attachement, + attachment: e.attachment, comment: e.comment, is_approved: e.is_approved, supervisor_comment: e.supervisor_comment, diff --git a/src/modules/expenses/expenses.module.ts b/src/modules/expenses/expenses.module.ts index 2cbd302..3948e70 100644 --- a/src/modules/expenses/expenses.module.ts +++ b/src/modules/expenses/expenses.module.ts @@ -17,7 +17,12 @@ import { EmployeesRepo } from "./repos/employee.repo"; TimesheetsRepo, EmployeesRepo, ], - exports: [ ExpensesQueryService ], + exports: [ + ExpensesQueryService, + BankCodesRepo, + TimesheetsRepo, + EmployeesRepo, + ], }) export class ExpensesModule {} \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 41fd316..973171b 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -1,14 +1,14 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { Expenses, Prisma } from "@prisma/client"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; -import { PrismaService } from "src/prisma/prisma.service"; -import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; -import { BankCodesRepo } from "../repos/bank-codes.repo"; -import { TimesheetsRepo } from "../repos/timesheets.repo"; -import { EmployeesRepo } from "../repos/employee.repo"; +import { Expenses, Prisma } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; +import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; +import { BankCodesRepo } from "../repos/bank-codes.repo"; +import { TimesheetsRepo } from "../repos/timesheets.repo"; +import { EmployeesRepo } from "../repos/employee.repo"; +import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; import { assertAndTrimComment, computeMileageAmount, mapDbExpenseToDayResponse, normalizeType as normalizeTypeUtil } from "../utils/expenses.utils"; import { DayExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; -import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; @Injectable() export class ExpensesCommandService extends BaseApprovalService { diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index 6772529..fd9106b 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -7,6 +7,9 @@ import { TimesheetsModule } from "../timesheets/timesheets.module"; import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service"; import { ExpensesCommandService } from "../expenses/services/expenses-command.service"; import { ShiftsCommandService } from "../shifts/services/shifts-command.service"; +import { BankCodesRepo } from "../expenses/repos/bank-codes.repo"; +import { EmployeesRepo } from "../expenses/repos/employee.repo"; +import { TimesheetsRepo } from "../expenses/repos/timesheets.repo"; @Module({ imports: [PrismaModule, TimesheetsModule], @@ -16,12 +19,14 @@ import { ShiftsCommandService } from "../shifts/services/shifts-command.service" TimesheetsCommandService, ExpensesCommandService, ShiftsCommandService, + BankCodesRepo, + TimesheetsRepo, + EmployeesRepo, ], controllers: [PayPeriodsController], exports: [ PayPeriodsQueryService, PayPeriodsCommandService, - PayPeriodsQueryService, ] }) diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index cbfc001..b957fe6 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -5,6 +5,9 @@ import { BusinessLogicsModule } from 'src/modules/business-logics/business-logic import { TimesheetsCommandService } from './services/timesheets-command.service'; import { ShiftsCommandService } from '../shifts/services/shifts-command.service'; import { ExpensesCommandService } from '../expenses/services/expenses-command.service'; +import { BankCodesRepo } from '../expenses/repos/bank-codes.repo'; +import { TimesheetsRepo } from '../expenses/repos/timesheets.repo'; +import { EmployeesRepo } from '../expenses/repos/employee.repo'; @Module({ imports: [BusinessLogicsModule], @@ -13,7 +16,10 @@ import { ExpensesCommandService } from '../expenses/services/expenses-command.se TimesheetsQueryService, TimesheetsCommandService, ShiftsCommandService, - ExpensesCommandService + ExpensesCommandService, + BankCodesRepo, + TimesheetsRepo, + EmployeesRepo, ], exports: [TimesheetsQueryService], }) From f8f4ad5a83b4eeb30919c664f37139408ef589d2 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 1 Oct 2025 10:02:52 -0400 Subject: [PATCH 39/69] fix(expenses): ajusted mileage logic --- .../expenses/dtos/upsert-expense.dto.ts | 8 +- .../services/expenses-command.service.ts | 110 ++++++++++++------ src/modules/expenses/utils/expenses.utils.ts | 20 ++-- 3 files changed, 87 insertions(+), 51 deletions(-) diff --git a/src/modules/expenses/dtos/upsert-expense.dto.ts b/src/modules/expenses/dtos/upsert-expense.dto.ts index f7975d4..c79ffd9 100644 --- a/src/modules/expenses/dtos/upsert-expense.dto.ts +++ b/src/modules/expenses/dtos/upsert-expense.dto.ts @@ -1,21 +1,19 @@ import { Transform, Type } from "class-transformer"; -import { IsDefined, IsNumber, IsOptional, IsString, maxLength, MaxLength, Min, ValidateIf, ValidateNested } from "class-validator"; +import { IsNumber, IsOptional, IsString, MaxLength, Min, ValidateIf, ValidateNested } from "class-validator"; export class ExpensePayloadDto { @IsString() type!: string; @ValidateIf(o => (o.type ?? '').toUpperCase() !== 'MILEAGE') - @IsDefined() @IsNumber() @Min(0) - amount!: number; + amount?: number; @ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE') - @IsDefined() @IsNumber() @Min(0) - mileage!: number; + mileage?: number; @IsString() @MaxLength(280) diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 973171b..dea5bea 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -72,32 +72,60 @@ export class ExpensesCommandService extends BaseApprovalService { orderBy: [{ date: 'asc' }, { id: 'asc' }], }); - return rows.map(this.mapDbToDayResponse); + return rows.map((r) => + this.mapDbToDayResponse({ + date: r.date, + amount: r.amount ?? 0, + mileage: r.mileage ?? 0, + comment: r.comment, + is_approved: r.is_approved, + bank_code: r.bank_code, + })); }; const normalizePayload = async (payload: { - type: string; - amount?: number; - mileage?: number; - comment: string; + type: string; + amount?: number; + mileage?: number; + comment: string; attachment?: string; }): Promise<{ - type: string; + type: string; bank_code_id: number; - amount: Prisma.Decimal; - comment: string; - attachment: string | null; + amount: Prisma.Decimal; + mileage: number | null; + comment: string; + attachment: string | null; }> => { - const type = this.normalizeType(payload.type); - const comment = this.assertAndTrimComment(payload.comment); + const type = this.normalizeType(payload.type); + const comment = this.assertAndTrimComment(payload.comment); const attachment = payload.attachment?.trim()?.length ? payload.attachment.trim() : null; + const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type); - const amount = this.computeAmountDecimal(type, payload, modifier); + let amount = this.computeAmountDecimal(type, payload, modifier); + let mileage: number | null = null; + + if (type === 'MILEAGE') { + mileage = Number(payload.mileage ?? 0); + if (!(mileage > 0)) { + throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE'); + } + + const amountNumber = computeMileageAmount(mileage, modifier); + amount = new Prisma.Decimal(amountNumber); + + } else { + if (!(typeof payload.amount === 'number' && payload.amount >= 0)) { + throw new BadRequestException('Amount required for non-MILEAGE expense'); + } + amount = new Prisma.Decimal(payload.amount); + } return { type, bank_code_id, - amount, + amount, + mileage, comment, attachment }; @@ -105,18 +133,20 @@ export class ExpensesCommandService extends BaseApprovalService { const findExactOld = async (norm: { bank_code_id: number; - amount: Prisma.Decimal; - comment: string; - attachment: string | null; + amount: Prisma.Decimal; + mileage: number | null; + comment: string; + attachment: string | null; }) => { return tx.expenses.findFirst({ where: { timesheet_id: timesheet_id, - date: dateOnly, + date: dateOnly, bank_code_id: norm.bank_code_id, - amount: norm.amount, - comment: norm.comment, - attachment: norm.attachment, + amount: norm.amount, + comment: norm.comment, + attachment: norm.attachment, + ...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }), }, select: { id: true }, }); @@ -136,24 +166,24 @@ export class ExpensesCommandService extends BaseApprovalService { await tx.expenses.delete({where: { id: existing.id } }); action = 'deleted'; } - //-------------------- CREATE -------------------- + //-------------------- CREATE -------------------- else if (!old_expense && new_expense) { const new_exp = await normalizePayload(new_expense); await tx.expenses.create({ data: { timesheet_id: timesheet_id, - date: dateOnly, + date: dateOnly, bank_code_id: new_exp.bank_code_id, - amount: new_exp.amount, - mileage: null, - comment: new_exp.comment, - attachment: new_exp.attachment, - is_approved: false, + amount: new_exp.amount, + mileage: new_exp.mileage, + comment: new_exp.comment, + attachment: new_exp.attachment, + is_approved: false, }, }); action = 'created'; } - + //-------------------- UPDATE -------------------- else if(old_expense && new_expense) { const oldNorm = await normalizePayload(old_expense); const existing = await findExactOld(oldNorm); @@ -169,10 +199,10 @@ export class ExpensesCommandService extends BaseApprovalService { where: { id: existing.id }, data: { bank_code_id: new_exp.bank_code_id, - amount: new_exp.amount, - mileage: null, - comment: new_exp.comment, - attachment: new_exp.attachment, + amount: new_exp.amount, + mileage: new_exp.mileage, + comment: new_exp.comment, + attachment: new_exp.attachment, }, }); action = 'updated'; @@ -188,7 +218,7 @@ export class ExpensesCommandService extends BaseApprovalService { } - //helpers imported from utils and repos. + //-------------------- helpers -------------------- private readonly normalizeType = (type: string): string => normalizeTypeUtil(type); @@ -210,7 +240,10 @@ export class ExpensesCommandService extends BaseApprovalService { private readonly computeAmountDecimal = ( type: string, - payload: { amount?: number; mileage?: number;}, + payload: { + amount?: number; + mileage?: number; + }, modifier: number, ): Prisma.Decimal => { if(type === 'MILEAGE') { @@ -222,11 +255,12 @@ export class ExpensesCommandService extends BaseApprovalService { }; private readonly mapDbToDayResponse = (row: { - date: Date; - amount: Prisma.Decimal | number | string; - comment: string; + date: Date; + amount: Prisma.Decimal | number | string; + mileage: Prisma.Decimal | number | string; + comment: string; is_approved: boolean; - bank_code: { type: string } | null; + bank_code: { type: string } | null; }): DayExpenseResponse => mapDbExpenseToDayResponse(row); diff --git a/src/modules/expenses/utils/expenses.utils.ts b/src/modules/expenses/utils/expenses.utils.ts index 9dd2497..edd2c4a 100644 --- a/src/modules/expenses/utils/expenses.utils.ts +++ b/src/modules/expenses/utils/expenses.utils.ts @@ -1,5 +1,6 @@ import { BadRequestException } from "@nestjs/common"; import { DayExpenseResponse } from "../types and interfaces/expenses.types.interfaces"; +import { Prisma } from "@prisma/client"; //uppercase and trim for validation export function normalizeType(type: string): string { @@ -48,18 +49,21 @@ export function toNumberSafe(value: DecimalLike): number { //map of a row for DayExpenseResponse export function mapDbExpenseToDayResponse(row: { - date: Date; - amount: DecimalLike; - comment: string; + date: Date; + amount: Prisma.Decimal | number | string | null; + mileage?: Prisma.Decimal | number | string | null; + comment: string; is_approved: boolean; - bank_code?: { type?: string | null } | null; + bank_code?: { type?: string | null } | null; }): DayExpenseResponse { const yyyyMmDd = row.date.toISOString().slice(0,10); + const toNum = (value: any)=> (value == null ? 0 : Number(value)); return { - date: yyyyMmDd, - type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'), - amount: toNumberSafe(row.amount), - comment: row.comment, + date: yyyyMmDd, + type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'), + amount: toNum(row.amount), + comment: row.comment, is_approved: row.is_approved, + ...(row.mileage !== null ? { mileage: toNum(row.mileage) }: {}), }; } \ No newline at end of file From 77f065f37fe1894c6548320e89137d5fa8db91ea Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 1 Oct 2025 16:35:40 -0400 Subject: [PATCH 40/69] feat(expense): link expense with attachment. --- .../migration.sql | 25 ++++++++ prisma/schema.prisma | 11 +++- .../expenses/dtos/upsert-expense.dto.ts | 21 ++++++- .../services/expenses-command.service.ts | 63 ++++++++++++++++--- 4 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20251001193437_link_expense_attachments/migration.sql diff --git a/prisma/migrations/20251001193437_link_expense_attachments/migration.sql b/prisma/migrations/20251001193437_link_expense_attachments/migration.sql new file mode 100644 index 0000000..a74952c --- /dev/null +++ b/prisma/migrations/20251001193437_link_expense_attachments/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - You are about to drop the column `attachement` on the `expenses` table. All the data in the column will be lost. + - You are about to drop the column `attachement` on the `expenses_archive` table. All the data in the column will be lost. + - Made the column `comment` on table `expenses` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "public"."expenses" DROP COLUMN "attachement", +ADD COLUMN "attachment" INTEGER, +ADD COLUMN "mileage" DECIMAL(65,30), +ALTER COLUMN "comment" SET NOT NULL; + +-- AlterTable +ALTER TABLE "public"."expenses_archive" DROP COLUMN "attachement", +ADD COLUMN "attachment" INTEGER, +ADD COLUMN "mileage" DECIMAL(65,30), +ALTER COLUMN "amount" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "public"."expenses" ADD CONSTRAINT "expenses_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."expenses_archive" ADD CONSTRAINT "expenses_archive_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9e56a04..fb0414b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -225,9 +225,10 @@ model Expenses { bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int date DateTime @db.Date - amount Decimal @db.Money + amount Decimal @db.Money mileage Decimal? - attachment String? + attachment Int? + attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull) comment String is_approved Boolean @default(false) supervisor_comment String? @@ -247,7 +248,8 @@ model ExpensesArchive { date DateTime @db.Date amount Decimal? @db.Money mileage Decimal? - attachment String? + attachment Int? + attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull) comment String? is_approved Boolean supervisor_comment String? @@ -298,6 +300,9 @@ model Attachments { created_by String created_at DateTime @default(now()) + expenses Expenses[] @relation("ExpenseAttachment") + expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment") + @@index([owner_type, owner_id, created_at]) @@index([sha256]) @@map("attachments") diff --git a/src/modules/expenses/dtos/upsert-expense.dto.ts b/src/modules/expenses/dtos/upsert-expense.dto.ts index c79ffd9..6ec007e 100644 --- a/src/modules/expenses/dtos/upsert-expense.dto.ts +++ b/src/modules/expenses/dtos/upsert-expense.dto.ts @@ -1,5 +1,14 @@ import { Transform, Type } from "class-transformer"; -import { IsNumber, IsOptional, IsString, MaxLength, Min, ValidateIf, ValidateNested } from "class-validator"; +import { + IsNumber, + IsOptional, + IsString, + Matches, + MaxLength, + Min, + ValidateIf, + ValidateNested +} from "class-validator"; export class ExpensePayloadDto { @IsString() @@ -21,7 +30,17 @@ export class ExpensePayloadDto { comment!: string; @IsOptional() + @Transform(({ value }) => { + if (value === null || value === undefined || value === '') return undefined; + if (typeof value === 'number') return value.toString(); + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length ? trimmed : undefined; + } + return undefined; + }) @IsString() + @Matches(/^\d+$/) @MaxLength(255) attachment?: string; } diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index dea5bea..13ccd84 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -1,4 +1,3 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { Expenses, Prisma } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; @@ -7,8 +6,21 @@ import { BankCodesRepo } from "../repos/bank-codes.repo"; import { TimesheetsRepo } from "../repos/timesheets.repo"; import { EmployeesRepo } from "../repos/employee.repo"; import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; -import { assertAndTrimComment, computeMileageAmount, mapDbExpenseToDayResponse, normalizeType as normalizeTypeUtil } from "../utils/expenses.utils"; -import { DayExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; +import { + BadRequestException, + Injectable, + NotFoundException +} from "@nestjs/common"; +import { + DayExpenseResponse, + UpsertAction +} from "../types and interfaces/expenses.types.interfaces"; +import { + assertAndTrimComment, + computeMileageAmount, + mapDbExpenseToDayResponse, + normalizeType as normalizeTypeUtil +} from "../utils/expenses.utils"; @Injectable() export class ExpensesCommandService extends BaseApprovalService { @@ -88,18 +100,18 @@ export class ExpensesCommandService extends BaseApprovalService { amount?: number; mileage?: number; comment: string; - attachment?: string; + attachment?: string | number; }): Promise<{ type: string; bank_code_id: number; amount: Prisma.Decimal; mileage: number | null; comment: string; - attachment: string | null; + attachment: number | null; }> => { const type = this.normalizeType(payload.type); const comment = this.assertAndTrimComment(payload.comment); - const attachment = payload.attachment?.trim()?.length ? payload.attachment.trim() : null; + const attachment = this.parseAttachmentId(payload.attachment); const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type); let amount = this.computeAmountDecimal(type, payload, modifier); @@ -121,6 +133,16 @@ export class ExpensesCommandService extends BaseApprovalService { amount = new Prisma.Decimal(payload.amount); } + if (attachment !== null) { + const attachmentRow = await tx.attachments.findUnique({ + where: { id: attachment }, + select: { status: true }, + }); + if (!attachmentRow || attachmentRow.status !== 'ACTIVE') { + throw new BadRequestException('Attachment not found or inactive'); + } + } + return { type, bank_code_id, @@ -136,7 +158,7 @@ export class ExpensesCommandService extends BaseApprovalService { amount: Prisma.Decimal; mileage: number | null; comment: string; - attachment: string | null; + attachment: number | null; }) => { return tx.expenses.findFirst({ where: { @@ -225,6 +247,33 @@ export class ExpensesCommandService extends BaseApprovalService { private readonly assertAndTrimComment = (comment: string): string => assertAndTrimComment(comment); + private readonly parseAttachmentId = (value: unknown): number | null => { + if (value == null) { + return null; + } + + if (typeof value === 'number') { + if (!Number.isInteger(value) || value <= 0) { + throw new BadRequestException('Invalid attachment id'); + } + return value; + } + + if (typeof value === 'string') { + + const trimmed = value.trim(); + if (!trimmed.length) return null; + if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id'); + + const parsed = Number(trimmed); + if (parsed <= 0) throw new BadRequestException('Invalid attachment id'); + + return parsed; + } + throw new BadRequestException('Invalid attachment id'); + }; + + private readonly resolveEmployeeIdByEmail = async (email: string): Promise => this.employeesRepo.findIdByEmail(email); From d36d2f922bcc7deed9a79930d33963034370066f Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 3 Oct 2025 09:37:42 -0400 Subject: [PATCH 41/69] feat(leave-request): added holiday shift's creation and CRUD for holiday leave-requests. --- package-lock.json | 78 ++--- package.json | 2 +- prisma/schema.prisma | 17 +- .../services/holiday.service.ts | 51 ++- .../controllers/leave-requests.controller.ts | 73 +--- .../dtos/create-leave-request.dto.ts | 56 ---- .../dtos/leave-request-view.dto.ts | 14 + .../dtos/leave-request.view.dto.ts | 13 - .../dtos/search-leave-request.dto.ts | 30 -- src/modules/leave-requests/dtos/sick.dto.ts | 52 +++ .../dtos/update-leave-request.dto.ts | 4 - .../leave-requests/dtos/upsert-holiday.dto.ts | 42 +++ .../leave-requests/dtos/vacation.dto.ts | 52 +++ .../mappers/leave-requests-archive.mapper.ts | 14 +- .../mappers/leave-requests.mapper.ts | 20 +- .../services/leave-requests.service.ts | 311 ++++++++++-------- .../utils/leave-requests-archive.select.ts | 6 +- .../utils/leave-requests.select.ts | 5 +- 18 files changed, 461 insertions(+), 379 deletions(-) delete mode 100644 src/modules/leave-requests/dtos/create-leave-request.dto.ts create mode 100644 src/modules/leave-requests/dtos/leave-request-view.dto.ts delete mode 100644 src/modules/leave-requests/dtos/leave-request.view.dto.ts delete mode 100644 src/modules/leave-requests/dtos/search-leave-request.dto.ts create mode 100644 src/modules/leave-requests/dtos/sick.dto.ts delete mode 100644 src/modules/leave-requests/dtos/update-leave-request.dto.ts create mode 100644 src/modules/leave-requests/dtos/upsert-holiday.dto.ts create mode 100644 src/modules/leave-requests/dtos/vacation.dto.ts diff --git a/package-lock.json b/package-lock.json index 5b5f67b..0622bea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^6.14.0", + "prisma": "^6.16.3", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", @@ -3148,9 +3148,9 @@ } }, "node_modules/@prisma/config": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz", - "integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz", + "integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==", "devOptional": true, "dependencies": { "c12": "3.1.0", @@ -3160,48 +3160,48 @@ } }, "node_modules/@prisma/debug": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz", - "integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz", + "integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==", "devOptional": true }, "node_modules/@prisma/engines": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz", - "integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz", + "integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/debug": "6.14.0", - "@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", - "@prisma/fetch-engine": "6.14.0", - "@prisma/get-platform": "6.14.0" + "@prisma/debug": "6.16.3", + "@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", + "@prisma/fetch-engine": "6.16.3", + "@prisma/get-platform": "6.16.3" } }, "node_modules/@prisma/engines-version": { - "version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz", - "integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==", + "version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz", + "integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==", "devOptional": true }, "node_modules/@prisma/fetch-engine": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz", - "integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz", + "integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==", "devOptional": true, "dependencies": { - "@prisma/debug": "6.14.0", - "@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", - "@prisma/get-platform": "6.14.0" + "@prisma/debug": "6.16.3", + "@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", + "@prisma/get-platform": "6.16.3" } }, "node_modules/@prisma/get-platform": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz", - "integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz", + "integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==", "devOptional": true, "dependencies": { - "@prisma/debug": "6.14.0" + "@prisma/debug": "6.16.3" } }, "node_modules/@scarf/scarf": { @@ -9450,15 +9450,15 @@ } }, "node_modules/nypm": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz", - "integrity": "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", "devOptional": true, "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", - "pkg-types": "^2.2.0", + "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { @@ -9967,9 +9967,9 @@ } }, "node_modules/pkg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz", - "integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "devOptional": true, "dependencies": { "confbox": "^0.2.2", @@ -10049,14 +10049,14 @@ } }, "node_modules/prisma": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz", - "integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz", + "integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/config": "6.14.0", - "@prisma/engines": "6.14.0" + "@prisma/config": "6.16.3", + "@prisma/engines": "6.16.3" }, "bin": { "prisma": "build/index.js" diff --git a/package.json b/package.json index db16934..68eb03f 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^6.14.0", + "prisma": "^6.16.3", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fb0414b..b32b202 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,16 +105,19 @@ model LeaveRequests { id Int @id @default(autoincrement()) employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id]) employee_id Int - bank_code BankCodes? @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id]) + bank_code BankCodes @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int leave_type LeaveTypes - start_date_time DateTime @db.Date - end_date_time DateTime? @db.Date + date DateTime @db.Date + payable_hours Decimal? @db.Decimal(5,2) + requested_hours Decimal? @db.Decimal(5,2) comment String approval_status LeaveApprovalStatus @default(PENDING) archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive") + @@unique([employee_id, leave_type, date], name: "leave_per_employee_date") + @@index([employee_id, date]) @@map("leave_requests") } @@ -125,11 +128,14 @@ model LeaveRequestsArchive { archived_at DateTime @default(now()) employee_id Int leave_type LeaveTypes - start_date_time DateTime @db.Date - end_date_time DateTime? @db.Date + date DateTime @db.Date + payable_hours Decimal? @db.Decimal(5,2) + requested_hours Decimal? @db.Decimal(5,2) comment String approval_status LeaveApprovalStatus + @@unique([leave_request_id]) + @@index([employee_id, date]) @@map("leave_requests_archive") } @@ -340,6 +346,7 @@ enum LeaveTypes { PARENTAL // maternite/paternite/adoption LEGAL // obligations legales comme devoir de juree WEDDING // mariage + HOLIDAY // férier @@map("leave_types") } diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index c0c8393..8b8a31b 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -2,6 +2,14 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../../../prisma/prisma.service"; import { computeHours, getWeekStart } from "src/common/utils/date-utils"; +const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000; + +/* + le calcul est 1/20 des 4 dernières semaines, précédent la semaine incluant le férier. + Un maximum de 08h00 est allouable pour le férier + Un maximum de 40hrs par semaine est retenue pour faire le calcul. +*/ + @Injectable() export class HolidayService { private readonly logger = new Logger(HolidayService.name); @@ -11,7 +19,7 @@ export class HolidayService { //fetch employee_id by email private async resolveEmployeeByEmail(email: string): Promise { const employee = await this.prisma.employees.findFirst({ - where: { + where: { user: { email } }, select: { id: true }, @@ -22,36 +30,49 @@ export class HolidayService { private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise { const employee_id = await this.resolveEmployeeByEmail(email); - return this.computeHoursPrevious4Weeks(employee_id, holiday_date) + return this.computeHoursPrevious4Weeks(employee_id, holiday_date); } private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise { - //sets the end of the window to 1ms before the week with the holiday const holiday_week_start = getWeekStart(holiday_date); + const window_start = new Date(holiday_week_start.getTime() - 4 * WEEK_IN_MS); const window_end = new Date(holiday_week_start.getTime() - 1); - //sets the start of the window to 28 days ( 4 completed weeks ) before the week with the holiday - const window_start = new Date(window_end.getTime() - 28 * 24 * 60 * 60000 + 1 ) const valid_codes = ['G1', 'G43', 'G56', 'G104', 'G105', 'G700']; - //fetches all shift of the employee in said window ( 4 previous completed weeks ) const shifts = await this.prisma.shifts.findMany({ - where: { timesheet: { employee_id: employee_id } , - date: { gte: window_start, lte: window_end }, - bank_code: { bank_code: { in: valid_codes } }, + where: { + timesheet: { employee_id: employee_id }, + date: { gte: window_start, lte: window_end }, + bank_code: { bank_code: { in: valid_codes } }, }, select: { date: true, start_time: true, end_time: true }, }); - const total_hours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0); - const daily_hours = total_hours / 20; + const hours_by_week = new Map(); + for(const shift of shifts) { + const hours = computeHours(shift.start_time, shift.end_time); + if(hours <= 0) continue; + const shift_week_start = getWeekStart(shift.date); + const key = shift_week_start.getTime(); + hours_by_week.set(key, (hours_by_week.get(key) ?? 0) + hours); + } - return daily_hours; + let capped_total = 0; + for(let offset = 1; offset <= 4; offset++) { + const week_start = new Date(holiday_week_start.getTime() - offset * WEEK_IN_MS); + const key = week_start.getTime(); + const weekly_hours = hours_by_week.get(key) ?? 0; + capped_total += Math.min(weekly_hours, 40); + } + + const average_daily_hours = capped_total / 20; + return average_daily_hours; } async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise { - const hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date); - const daily_rate = Math.min(hours, 8); - this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`); + const average_daily_hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date); + const daily_rate = Math.min(average_daily_hours, 8); + this.logger.debug(`Holiday pay calculation: cappedHoursPerDay= ${average_daily_hours.toFixed(2)}, appliedDailyRate= ${daily_rate.toFixed(2)}`); return daily_rate * modifier; } } \ No newline at end of file diff --git a/src/modules/leave-requests/controllers/leave-requests.controller.ts b/src/modules/leave-requests/controllers/leave-requests.controller.ts index d3251ad..e6b8e8e 100644 --- a/src/modules/leave-requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave-requests/controllers/leave-requests.controller.ts @@ -1,76 +1,13 @@ -import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; +import { Controller } from "@nestjs/common"; import { LeaveRequestsService } from "../services/leave-requests.service"; -import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto"; -import { LeaveRequests } from "@prisma/client"; -import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto"; -import { RolesAllowed } from "src/common/decorators/roles.decorators"; -import { LeaveApprovalStatus, Roles as RoleEnum } from '.prisma/client'; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; -import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto"; -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; + +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; @ApiTags('Leave Requests') @ApiBearerAuth('access-token') // @UseGuards() @Controller('leave-requests') export class LeaveRequestController { - constructor(private readonly leaveRequetsService: LeaveRequestsService){} - - @Post() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Create leave request' }) - @ApiResponse({ status: 201, description: 'Leave request created',type: CreateLeaveRequestsDto }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateLeaveRequestsDto): Promise { - return this. leaveRequetsService.create(dto); - } - - @Get() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Find all leave request' }) - @ApiResponse({ status: 201, description: 'List of Leave requests found',type: LeaveRequestViewDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of leave request not found' }) - @UsePipes(new ValidationPipe({transform: true, whitelist: true})) - findAll(@Query() filters: SearchLeaveRequestsDto): Promise { - return this.leaveRequetsService.findAll(filters); - } - //remove emp_id and use email - @Get(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Find leave request' }) - @ApiResponse({ status: 201, description: 'Leave request found',type: LeaveRequestViewDto }) - @ApiResponse({ status: 400, description: 'Leave request not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.leaveRequetsService.findOne(id); - } - //remove emp_id and use email - @Patch(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Update leave request' }) - @ApiResponse({ status: 201, description: 'Leave request updated',type: LeaveRequestViewDto }) - @ApiResponse({ status: 400, description: 'Leave request not found' }) - update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateLeaveRequestsDto): Promise { - return this.leaveRequetsService.update(id, dto); - } - - //remove emp_id and use email - @Delete(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Delete leave request' }) - @ApiResponse({ status: 201, description: 'Leave request deleted',type: CreateLeaveRequestsDto }) - @ApiResponse({ status: 400, description: 'Leave request not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.leaveRequetsService.remove(id); - } - - //remove emp_id and use email - @Patch('approval/:id') - updateApproval( @Param('id', ParseIntPipe) id: number, - @Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise { - const approvalStatus = is_approved ? - LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED; - return this.leaveRequetsService.update(id, { approval_status: approvalStatus }); - } - - } + constructor(private readonly leave_service: LeaveRequestsService){} +} diff --git a/src/modules/leave-requests/dtos/create-leave-request.dto.ts b/src/modules/leave-requests/dtos/create-leave-request.dto.ts deleted file mode 100644 index 136c858..0000000 --- a/src/modules/leave-requests/dtos/create-leave-request.dto.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; -import { Type } from "class-transformer"; -import { IsDateString, IsEmail, IsEnum, IsInt, IsISO8601, IsNotEmpty, IsOptional, IsString } from "class-validator"; - -export class CreateLeaveRequestsDto { - - @IsEmail() - email: string; - - @ApiProperty({ - example: 7, - description: 'ID number of a leave-request code (link with bank-codes)', - }) - @Type(()=> Number) - @IsInt() - bank_code_id: number; - - @ApiProperty({ - example: 'Sick or Vacation or Unpaid or Bereavement or Parental or Legal', - description: 'type of leave request for an accounting perception', - }) - @IsEnum(LeaveTypes) - leave_type: LeaveTypes; - - @ApiProperty({ - example: '22/06/2463', - description: 'Leave request`s start date', - }) - @IsISO8601() - start_date_time:string; - - @ApiProperty({ - example: '25/03/3019', - description: 'Leave request`s end date', - }) - @IsOptional() - @IsISO8601() - end_date_time?: string; - - @ApiProperty({ - example: 'My precious', - description: 'Leave request`s comment', - }) - @IsString() - @IsNotEmpty() - comment: string; - - @ApiProperty({ - example: 'True or False or Pending or Denied or Cancelled or Escalated', - description: 'Leave request`s approval status', - }) - @IsEnum(LeaveApprovalStatus) - @IsOptional() - approval_status?: LeaveApprovalStatus; -} diff --git a/src/modules/leave-requests/dtos/leave-request-view.dto.ts b/src/modules/leave-requests/dtos/leave-request-view.dto.ts new file mode 100644 index 0000000..db36da7 --- /dev/null +++ b/src/modules/leave-requests/dtos/leave-request-view.dto.ts @@ -0,0 +1,14 @@ +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; + +export class LeaveRequestViewDto { + id: number; + leave_type!: LeaveTypes; + date!: string; + comment!: string; + approval_status: LeaveApprovalStatus; + email!: string; + employee_full_name!: string; + payable_hours?: number; + requested_hours?: number; + action?: 'created' | 'updated' | 'deleted'; +} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/leave-request.view.dto.ts b/src/modules/leave-requests/dtos/leave-request.view.dto.ts deleted file mode 100644 index 693368d..0000000 --- a/src/modules/leave-requests/dtos/leave-request.view.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; - -export class LeaveRequestViewDto { - id!: number; - leave_type!: LeaveTypes; - start_date_time!: string; - end_date_time!: string | null; - comment!: string | null; - approval_status: LeaveApprovalStatus; - email!: string; - employee_full_name: string; - days_requested?: number; -} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/search-leave-request.dto.ts b/src/modules/leave-requests/dtos/search-leave-request.dto.ts deleted file mode 100644 index 15ce8e4..0000000 --- a/src/modules/leave-requests/dtos/search-leave-request.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; -import { Type } from "class-transformer"; -import { IsOptional, IsInt, IsEnum, IsDateString, IsEmail } from "class-validator"; - -export class SearchLeaveRequestsDto { - - @IsEmail() - email: string; - - @IsOptional() - @Type(()=> Number) - @IsInt() - bank_code_id?: number; - - @IsOptional() - @IsEnum(LeaveApprovalStatus) - approval_status?: LeaveApprovalStatus - - @IsOptional() - @IsDateString() - start_date?: string; - - @IsOptional() - @IsDateString() - end_date?: string; - - @IsOptional() - @IsEnum(LeaveTypes) - leave_type?: LeaveTypes; -} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/sick.dto.ts b/src/modules/leave-requests/dtos/sick.dto.ts new file mode 100644 index 0000000..13d5b45 --- /dev/null +++ b/src/modules/leave-requests/dtos/sick.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsEmail, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; + +export class UpsertSickDto { + @ApiProperty({ example: "jane.doe@example.com" }) + @IsEmail() + email!: string; + + @ApiProperty({ + type: [String], + example: ["2025-03-04"], + description: "ISO dates that represent the sick leave request.", + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsISO8601({}, { each: true }) + dates!: string[]; + + @ApiProperty({ + required: false, + example: "Medical note provided", + description: "Optional comment applied to every date.", + }) + @IsOptional() + @IsString() + comment?: string; + + @ApiProperty({ + required: false, + example: 8, + description: "Hours requested per day. Lets you keep the user input even if the calculation differs.", + }) + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(24) + requested_hours?: number; +} diff --git a/src/modules/leave-requests/dtos/update-leave-request.dto.ts b/src/modules/leave-requests/dtos/update-leave-request.dto.ts deleted file mode 100644 index ec4bb86..0000000 --- a/src/modules/leave-requests/dtos/update-leave-request.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from "@nestjs/swagger"; -import { CreateLeaveRequestsDto } from "./create-leave-request.dto"; - -export class UpdateLeaveRequestsDto extends PartialType(CreateLeaveRequestsDto){} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/upsert-holiday.dto.ts b/src/modules/leave-requests/dtos/upsert-holiday.dto.ts new file mode 100644 index 0000000..d1f0f51 --- /dev/null +++ b/src/modules/leave-requests/dtos/upsert-holiday.dto.ts @@ -0,0 +1,42 @@ +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsEmail, + IsIn, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; + +export const HOLIDAY_UPSERT_ACTIONS = ['create', 'update', 'delete'] as const; +export type HolidayUpsertAction = typeof HOLIDAY_UPSERT_ACTIONS[number]; + +export class UpsertHolidayDto { + @IsEmail() + email!: string; + + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsISO8601({}, { each: true }) + dates!: string[]; + + @IsIn(HOLIDAY_UPSERT_ACTIONS) + action!: HolidayUpsertAction; + + @IsOptional() + @IsString() + comment?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(24) + requested_hours?: number; +} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/vacation.dto.ts b/src/modules/leave-requests/dtos/vacation.dto.ts new file mode 100644 index 0000000..79d558d --- /dev/null +++ b/src/modules/leave-requests/dtos/vacation.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsEmail, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; + +export class UpsertVacationDto { + @ApiProperty({ example: "jane.doe@example.com" }) + @IsEmail() + email!: string; + + @ApiProperty({ + type: [String], + example: ["2025-07-14", "2025-07-15"], + description: "ISO dates that represent the vacation request.", + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsISO8601({}, { each: true }) + dates!: string[]; + + @ApiProperty({ + required: false, + example: "Summer break", + description: "Optional comment applied to every date.", + }) + @IsOptional() + @IsString() + comment?: string; + + @ApiProperty({ + required: false, + example: 8, + description: "Hours requested per day. Used as default when creating shifts.", + }) + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(24) + requested_hours?: number; +} diff --git a/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts b/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts index 48d91e0..36d05fa 100644 --- a/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts +++ b/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts @@ -1,14 +1,20 @@ -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; +import { Prisma } from "@prisma/client"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; import { LeaveRequestArchiveRow } from "../utils/leave-requests-archive.select"; -const toISO = (date: Date | null): string | null => (date ? date.toISOString().slice(0,10): null); +const toNum = (value?: Prisma.Decimal | null) => value ? Number(value) : undefined; export function mapArchiveRowToView(row: LeaveRequestArchiveRow, email: string, employee_full_name:string): LeaveRequestViewDto { + const isoDate = row.date?.toISOString().slice(0, 10); + if (!isoDate) { + throw new Error(`Leave request #${row.id} has no date set.`); + } return { id: row.id, leave_type: row.leave_type, - start_date_time: toISO(row.start_date_time)!, - end_date_time: toISO(row.end_date_time), + date: isoDate, + payable_hours: toNum(row.payable_hours), + requested_hours: toNum(row.requested_hours), comment: row.comment, approval_status: row.approval_status, email, diff --git a/src/modules/leave-requests/mappers/leave-requests.mapper.ts b/src/modules/leave-requests/mappers/leave-requests.mapper.ts index 4fe2133..fda4f6d 100644 --- a/src/modules/leave-requests/mappers/leave-requests.mapper.ts +++ b/src/modules/leave-requests/mappers/leave-requests.mapper.ts @@ -1,19 +1,23 @@ -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; +import { Prisma } from "@prisma/client"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; import { LeaveRequestRow } from "../utils/leave-requests.select"; -function toISODateString(date:Date | null): string | null { - return date ? date.toISOString().slice(0,10) : null; -} +const toNum = (value?: Prisma.Decimal | null) => + value !== null && value !== undefined ? Number(value) : undefined; export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto { + const isoDate = row.date?.toISOString().slice(0, 10); + if (!isoDate) throw new Error(`Leave request #${row.id} has no date set.`); + return { id: row.id, leave_type: row.leave_type, - start_date_time: toISODateString(row.start_date_time)!, - end_date_time: toISODateString(row.end_date_time), + date: isoDate, + payable_hours: toNum(row.payable_hours), + requested_hours: toNum(row.requested_hours), comment: row.comment, approval_status: row.approval_status, email: row.employee.user.email, - employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}` - } + employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`, + }; } \ No newline at end of file diff --git a/src/modules/leave-requests/services/leave-requests.service.ts b/src/modules/leave-requests/services/leave-requests.service.ts index 1231ed6..a325965 100644 --- a/src/modules/leave-requests/services/leave-requests.service.ts +++ b/src/modules/leave-requests/services/leave-requests.service.ts @@ -1,175 +1,224 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { LeaveTypes, LeaveRequestsArchive } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; -import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto"; -import { LeaveRequests, LeaveRequestsArchive } from "@prisma/client"; -import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto"; import { HolidayService } from "src/modules/business-logics/services/holiday.service"; -import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; -import { VacationService } from "src/modules/business-logics/services/vacation.service"; -import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto"; -import { buildPrismaWhere } from "src/common/shared/build-prisma-where"; -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; -import { LeaveRequestRow, leaveRequestsSelect } from "../utils/leave-requests.select"; + +import { UpsertHolidayDto, HolidayUpsertAction } from "../dtos/upsert-holiday.dto"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; import { mapRowToView } from "../mappers/leave-requests.mapper"; -import { LeaveRequestsArchiveController } from "src/modules/archival/controllers/leave-requests-archive.controller"; +import { mapArchiveRowToViewWithDays } from "../utils/leave-request.transform"; import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select"; -import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper"; -import { mapArchiveRowToViewWithDays, mapRowToViewWithDays } from "../utils/leave-request.transform"; +import { leaveRequestsSelect } from "../utils/leave-requests.select"; @Injectable() export class LeaveRequestsService { constructor( private readonly prisma: PrismaService, private readonly holidayService: HolidayService, - private readonly vacationService: VacationService, - private readonly sickLeaveService: SickLeaveService ) {} - //function to avoid using employee_id as identifier in the frontend. + //-------------------- helpers -------------------- private async resolveEmployeeIdByEmail(email: string): Promise { const employee = await this.prisma.employees.findFirst({ - where: { user: { email} }, - select: { id:true }, + where: { user: { email } }, + select: { id: true }, }); - if(!employee) throw new NotFoundException(`Employee with email ${email} not found`); + if (!employee) { + throw new NotFoundException(`Employee with email ${email} not found`); + } return employee.id; } - //create a leave-request without the use of employee_id - async create(dto: CreateLeaveRequestsDto): Promise { - const employee_id = await this.resolveEmployeeIdByEmail(dto.email); - const row: LeaveRequestRow = await this.prisma.leaveRequests.create({ - data: { - employee_id, - bank_code_id: dto.bank_code_id, - leave_type: dto.leave_type, - start_date_time: new Date(dto.start_date_time), - end_date_time: dto.end_date_time ? new Date(dto.end_date_time) : null, - comment: dto.comment, - approval_status: dto.approval_status ?? undefined, + private async resolveHolidayBankCode() { + const bankCode = await this.prisma.bankCodes.findFirst({ + where: { type: 'HOLIDAY' }, + select: { id: true, bank_code: true, modifier: true }, + }); + if (!bankCode) { + throw new BadRequestException('Bank code type "HOLIDAY" not found'); + } + return bankCode; + } + + async handleHoliday(dto: UpsertHolidayDto): Promise<{ action: HolidayUpsertAction; leave_requests: LeaveRequestViewDto[] }> { + switch (dto.action) { + case 'create': + return this.createHoliday(dto); + case 'update': + return this.updateHoliday(dto); + case 'delete': + return this.deleteHoliday(dto); + default: + throw new BadRequestException(`Unknown action: ${dto.action}`); + } + } + + private async createHoliday(dto: UpsertHolidayDto): Promise<{ action: 'create'; leave_requests: LeaveRequestViewDto[] }> { + const email = dto.email.trim(); + const employeeId = await this.resolveEmployeeIdByEmail(email); + const bankCode = await this.resolveHolidayBankCode(); + const dates = normalizeDates(dto.dates); + if (!dates.length) { + throw new BadRequestException('Dates array must not be empty'); + } + + const created: LeaveRequestViewDto[] = []; + for (const isoDate of dates) { + const date = toDateOnly(isoDate); + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employeeId, + leave_type: LeaveTypes.HOLIDAY, + date, + }, + }, + select: { id: true }, + }); + if (existing) { + throw new BadRequestException(`A holiday request already exists for ${isoDate}`); + } + + const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier); + const row = await this.prisma.leaveRequests.create({ + data: { + employee_id: employeeId, + bank_code_id: bankCode.id, + leave_type: LeaveTypes.HOLIDAY, + date, + comment: dto.comment ?? '', + approval_status: undefined, + requested_hours: dto.requested_hours ?? 8, + payable_hours: payable, + }, + select: leaveRequestsSelect, + }); + created.push({ ...mapRowToView(row), action: 'create' }); + } + + return { action: 'create', leave_requests: created }; + } + + private async updateHoliday(dto: UpsertHolidayDto): Promise<{ action: 'update'; leave_requests: LeaveRequestViewDto[] }> { + const email = dto.email.trim(); + const employeeId = await this.resolveEmployeeIdByEmail(email); + const bankCode = await this.resolveHolidayBankCode(); + const dates = normalizeDates(dto.dates); + if (!dates.length) { + throw new BadRequestException('Dates array must not be empty'); + } + + const updated: LeaveRequestViewDto[] = []; + for (const isoDate of dates) { + const date = toDateOnly(isoDate); + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employeeId, + leave_type: LeaveTypes.HOLIDAY, + date, + }, + }, + select: leaveRequestsSelect, + }); + if (!existing) { + throw new NotFoundException(`No HOLIDAY request found for ${isoDate}`); + } + + const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier); + const row = await this.prisma.leaveRequests.update({ + where: { id: existing.id }, + data: { + comment: dto.comment ?? existing.comment, + requested_hours: dto.requested_hours ?? undefined, + payable_hours: payable, + bank_code_id: bankCode.id, + }, + select: leaveRequestsSelect, + }); + updated.push({ ...mapRowToView(row), action: 'update' }); + } + + return { action: 'update', leave_requests: updated }; + } + + private async deleteHoliday(dto: UpsertHolidayDto): Promise<{ action: 'delete'; leave_requests: LeaveRequestViewDto[] }> { + const email = dto.email.trim(); + const employeeId = await this.resolveEmployeeIdByEmail(email); + const dates = normalizeDates(dto.dates); + if (!dates.length) { + throw new BadRequestException('Dates array must not be empty'); + } + + const rows = await this.prisma.leaveRequests.findMany({ + where: { + employee_id: employeeId, + leave_type: LeaveTypes.HOLIDAY, + date: { in: dates.map((d) => toDateOnly(d)) }, }, select: leaveRequestsSelect, }); - return mapRowToViewWithDays(row); - } - //fetches all leave-requests using email - async findAll(filters: SearchLeaveRequestsDto): Promise { - const {start_date, end_date,email, leave_type, approval_status, bank_code_id } = filters; - const where: any = {}; + if (rows.length !== dates.length) { + const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate)); + throw new NotFoundException(`No HOLIDAY request found for: ${missing.join(', ')}`); + } - if (start_date) where.start_date_time = { ...(where.start_date_time ?? {}), gte: new Date(start_date) }; - if (end_date) where.end_date_time = { ...(where.end_date_time ?? {}), lte: new Date(end_date) }; - if (email) where.employee = { user: { email } }; - if (leave_type) where.leave_type = leave_type; - if (approval_status) where.approval_status = approval_status; - if (typeof bank_code_id === 'number') where.bank_code_id = bank_code_id; - - const rows= await this.prisma.leaveRequests.findMany({ - where, - select: leaveRequestsSelect, - orderBy: { start_date_time: 'desc' }, + await this.prisma.leaveRequests.deleteMany({ + where: { id: { in: rows.map((row) => row.id) } }, }); - return rows.map(mapRowToViewWithDays); + const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' as const })); + return { action: 'delete', leave_requests: deleted }; } - //fetch 1 leave-request using email - async findOne(id:number): Promise { - const row: LeaveRequestRow | null = await this.prisma.leaveRequests.findUnique({ - where: { id }, - select: leaveRequestsSelect, - }); - if(!row) throw new NotFoundException(`Leave Request #${id} not found`); - return mapRowToViewWithDays(row); - } - - //updates 1 leave-request using email - async update(id: number, dto: UpdateLeaveRequestsDto): Promise { - await this.findOne(id); - const data: Record = {}; - - if(dto.email !== undefined) data.employee_id = await this.resolveEmployeeIdByEmail(dto.email); - if(dto.leave_type !== undefined) data.bank_code_id = dto.bank_code_id; - if(dto.start_date_time !== undefined) data.start_date_time = new Date(dto.start_date_time); - if(dto.end_date_time !== undefined) data.end_date_time = new Date(dto.end_date_time); - if(dto.comment !== undefined) data.comment = dto.comment; - if(dto.approval_status !== undefined) data.approval_status = dto.approval_status; - - const row: LeaveRequestRow = await this.prisma.leaveRequests.update({ - where: { id }, - data, - select: leaveRequestsSelect, - }); - return mapRowToViewWithDays(row); - } - - //removes 1 leave-request using email - async remove(id:number): Promise { - await this.findOne(id); - const row: LeaveRequestRow = await this.prisma.leaveRequests.delete({ - where: { id }, - select: leaveRequestsSelect, - }); - return mapRowToViewWithDays(row); - } - - //archivation functions ****************************************************** - + //-------------------- archival -------------------- async archiveExpired(): Promise { - const now = new Date(); - - await this.prisma.$transaction(async transaction => { - //fetches expired leave requests - const expired = await transaction.leaveRequests.findMany({ - where: { end_date_time: { lt: now } }, - }); - if(expired.length === 0) { - return; - } - //copy unto archive table - await transaction.leaveRequestsArchive.createMany({ - data: expired.map(request => ({ - leave_request_id: request.id, - employee_id: request.employee_id, - leave_type: request.leave_type, - start_date_time: request.start_date_time, - end_date_time: request.end_date_time, - comment: request.comment, - approval_status: request.approval_status, - })), - }); - //delete from leave_requests table - await transaction.leaveRequests.deleteMany({ - where: { id: { in: expired.map(request => request.id ) } }, - }); - }); + // TODO: adjust logic to the new LeaveRequests structure } - //fetches all archived leave-requests async findAllArchived(): Promise { - return this.prisma.leaveRequestsArchive.findMany(); + return this.prisma.leaveRequestsArchive.findMany(); } - //remove emp_id and use email - //fetches an archived employee async findOneArchived(id: number): Promise { const row: LeaveRequestArchiveRow | null = await this.prisma.leaveRequestsArchive.findUnique({ where: { id }, select: leaveRequestsArchiveSelect, }); - if(!row) throw new NotFoundException(`Archived Leave Request #${id} not found`); + if (!row) { + throw new NotFoundException(`Archived Leave Request #${id} not found`); + } - const emp = await this.prisma.employees.findUnique({ + const emp = await this.prisma.employees.findUnique({ where: { id: row.employee_id }, - select: { user: {select: { email:true, - first_name: true, - last_name: true, - }}}, + select: { + user: { + select: { + email: true, + first_name: true, + last_name: true, + }, + }, + }, }); - const email = emp?.user.email ?? ""; - const full_name = emp ? `${emp.user.first_name} ${emp.user.last_name}` : ""; + const email = emp?.user.email ?? ''; + const fullName = emp ? `${emp.user.first_name} ${emp.user.last_name}` : ''; - return mapArchiveRowToViewWithDays(row, email, full_name); + return mapArchiveRowToViewWithDays(row, email, fullName); } -} \ No newline at end of file +} + +const toDateOnly = (iso: string): Date => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException(`Invalid date: ${iso}`); + } + date.setHours(0, 0, 0, 0); + return date; +}; + +const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10); + +const normalizeDates = (dates: string[]): string[] => + Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso))))); \ No newline at end of file diff --git a/src/modules/leave-requests/utils/leave-requests-archive.select.ts b/src/modules/leave-requests/utils/leave-requests-archive.select.ts index 5dbbd36..be06345 100644 --- a/src/modules/leave-requests/utils/leave-requests-archive.select.ts +++ b/src/modules/leave-requests/utils/leave-requests-archive.select.ts @@ -6,11 +6,11 @@ export const leaveRequestsArchiveSelect = { archived_at: true, employee_id: true, leave_type: true, - start_date_time: true, - end_date_time: true, + date: true, + payable_hours: true, + requested_hours: true, comment: true, approval_status: true, - } satisfies Prisma.LeaveRequestsArchiveSelect; export type LeaveRequestArchiveRow = Prisma.LeaveRequestsArchiveGetPayload<{ select: typeof leaveRequestsArchiveSelect}>; \ No newline at end of file diff --git a/src/modules/leave-requests/utils/leave-requests.select.ts b/src/modules/leave-requests/utils/leave-requests.select.ts index 9636334..e48a930 100644 --- a/src/modules/leave-requests/utils/leave-requests.select.ts +++ b/src/modules/leave-requests/utils/leave-requests.select.ts @@ -5,8 +5,9 @@ export const leaveRequestsSelect = { id: true, bank_code_id: true, leave_type: true, - start_date_time: true, - end_date_time: true, + date: true, + payable_hours: true, + requested_hours: true, comment: true, approval_status: true, employee: { select: { From 10d4f11f761edca4528c0bb8f33cf273da9d5b86 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 3 Oct 2025 09:38:09 -0400 Subject: [PATCH 42/69] feat(leave-request): added holiday shift's creation and CRUD for holiday leave-requests. --- .../leave-requests-archive.controller.ts | 2 +- .../archival/services/archival.service.ts | 2 +- .../services/expenses-command.service.ts | 21 +- .../expenses.types.interfaces.ts | 7 +- .../controllers/leave-requests.controller.ts | 23 +- .../leave-requests/dtos/upsert-holiday.dto.ts | 6 + .../leave-requests/leave-requests.module.ts | 12 +- ...e.ts => holiday-leave-requests.service.ts} | 235 +++++++++++++----- 8 files changed, 216 insertions(+), 92 deletions(-) rename src/modules/leave-requests/services/{leave-requests.service.ts => holiday-leave-requests.service.ts} (50%) diff --git a/src/modules/archival/controllers/leave-requests-archive.controller.ts b/src/modules/archival/controllers/leave-requests-archive.controller.ts index 51ad8e6..89cb024 100644 --- a/src/modules/archival/controllers/leave-requests-archive.controller.ts +++ b/src/modules/archival/controllers/leave-requests-archive.controller.ts @@ -3,7 +3,7 @@ import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { LeaveRequestsArchive, Roles as RoleEnum } from "@prisma/client"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { LeaveRequestViewDto } from "src/modules/leave-requests/dtos/leave-request.view.dto"; -import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service"; +import { LeaveRequestsService } from "src/modules/leave-requests/services/holiday-leave-requests.service"; @ApiTags('LeaveRequests Archives') // @UseGuards() diff --git a/src/modules/archival/services/archival.service.ts b/src/modules/archival/services/archival.service.ts index bf7a36d..07ab621 100644 --- a/src/modules/archival/services/archival.service.ts +++ b/src/modules/archival/services/archival.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; -import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service"; +import { LeaveRequestsService } from "src/modules/leave-requests/services/holiday-leave-requests.service"; import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 13ccd84..06e3895 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -11,10 +11,7 @@ import { Injectable, NotFoundException } from "@nestjs/common"; -import { - DayExpenseResponse, - UpsertAction -} from "../types and interfaces/expenses.types.interfaces"; +import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; import { assertAndTrimComment, computeMileageAmount, @@ -26,9 +23,9 @@ import { export class ExpensesCommandService extends BaseApprovalService { constructor( prisma: PrismaService, - private readonly bankCodesRepo: BankCodesRepo, + private readonly bankCodesRepo: BankCodesRepo, private readonly timesheetsRepo: TimesheetsRepo, - private readonly employeesRepo: EmployeesRepo, + private readonly employeesRepo: EmployeesRepo, ) { super(prisma); } protected get delegate() { @@ -47,7 +44,7 @@ export class ExpensesCommandService extends BaseApprovalService { //-------------------- Master CRUD function -------------------- readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto, - ): Promise<{ action:UpsertAction; day: DayExpenseResponse[] }> => { + ): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => { //validates if there is an existing expense, at least 1 old or new const { old_expense, new_expense } = dto ?? {}; @@ -68,7 +65,7 @@ export class ExpensesCommandService extends BaseApprovalService { const timesheet_id = await this.ensureTimesheetForDate(employee_id, dateOnly); return this.prisma.$transaction(async (tx) => { - const loadDay = async (): Promise => { + const loadDay = async (): Promise => { const rows = await tx.expenses.findMany({ where: { timesheet_id: timesheet_id, @@ -186,7 +183,7 @@ export class ExpensesCommandService extends BaseApprovalService { }); } await tx.expenses.delete({where: { id: existing.id } }); - action = 'deleted'; + action = 'delete'; } //-------------------- CREATE -------------------- else if (!old_expense && new_expense) { @@ -203,7 +200,7 @@ export class ExpensesCommandService extends BaseApprovalService { is_approved: false, }, }); - action = 'created'; + action = 'create'; } //-------------------- UPDATE -------------------- else if(old_expense && new_expense) { @@ -227,7 +224,7 @@ export class ExpensesCommandService extends BaseApprovalService { attachment: new_exp.attachment, }, }); - action = 'updated'; + action = 'update'; } else { throw new BadRequestException('Invalid upsert combination'); @@ -310,7 +307,7 @@ export class ExpensesCommandService extends BaseApprovalService { comment: string; is_approved: boolean; bank_code: { type: string } | null; - }): DayExpenseResponse => mapDbExpenseToDayResponse(row); + }): ExpenseResponse => mapDbExpenseToDayResponse(row); } \ No newline at end of file diff --git a/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts index e567070..84a82dc 100644 --- a/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts +++ b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts @@ -1,6 +1,6 @@ -export type UpsertAction = 'created' | 'updated' | 'deleted'; +export type UpsertAction = 'create' | 'update' | 'delete'; -export interface DayExpenseResponse { +export interface ExpenseResponse { date: string; type: string; amount: number; @@ -9,6 +9,5 @@ export interface DayExpenseResponse { }; export type UpsertExpenseResult = { - action: UpsertAction; - day: DayExpenseResponse[] + expenses: ExpenseResponse[] }; \ No newline at end of file diff --git a/src/modules/leave-requests/controllers/leave-requests.controller.ts b/src/modules/leave-requests/controllers/leave-requests.controller.ts index e6b8e8e..15311a5 100644 --- a/src/modules/leave-requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave-requests/controllers/leave-requests.controller.ts @@ -1,13 +1,30 @@ -import { Controller } from "@nestjs/common"; -import { LeaveRequestsService } from "../services/leave-requests.service"; +import { Body, Controller, Post } from "@nestjs/common"; +import { HolidayLeaveRequestsService } from "../services/holiday-leave-requests.service"; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { UpsertHolidayDto } from "../dtos/upsert-holiday.dto"; @ApiTags('Leave Requests') @ApiBearerAuth('access-token') // @UseGuards() @Controller('leave-requests') export class LeaveRequestController { - constructor(private readonly leave_service: LeaveRequestsService){} + constructor(private readonly leave_service: HolidayLeaveRequestsService){} + + @Post('holiday') + async upsertHoliday(@Body() dto: UpsertHolidayDto) { + const { action, leave_requests } = await this.leave_service.handleHoliday(dto); + return { action, leave_requests }; + } + + //TODO: + /* + @Get('archive') + findAllArchived(){...} + + @Get('archive/:id') + findOneArchived(id){...} + */ + } diff --git a/src/modules/leave-requests/dtos/upsert-holiday.dto.ts b/src/modules/leave-requests/dtos/upsert-holiday.dto.ts index d1f0f51..376b7a4 100644 --- a/src/modules/leave-requests/dtos/upsert-holiday.dto.ts +++ b/src/modules/leave-requests/dtos/upsert-holiday.dto.ts @@ -1,9 +1,11 @@ +import { LeaveApprovalStatus } from "@prisma/client"; import { Type } from "class-transformer"; import { ArrayNotEmpty, ArrayUnique, IsArray, IsEmail, + IsEnum, IsIn, IsISO8601, IsNumber, @@ -39,4 +41,8 @@ export class UpsertHolidayDto { @Min(0) @Max(24) requested_hours?: number; + + @IsOptional() + @IsEnum(LeaveApprovalStatus) + approval_status?: LeaveApprovalStatus; } \ No newline at end of file diff --git a/src/modules/leave-requests/leave-requests.module.ts b/src/modules/leave-requests/leave-requests.module.ts index 7762846..2249f21 100644 --- a/src/modules/leave-requests/leave-requests.module.ts +++ b/src/modules/leave-requests/leave-requests.module.ts @@ -1,13 +1,19 @@ +import { PrismaService } from "src/prisma/prisma.service"; +import { HolidayService } from "../business-logics/services/holiday.service"; import { LeaveRequestController } from "./controllers/leave-requests.controller"; -import { LeaveRequestsService } from "./services/leave-requests.service"; +import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service"; import { Module } from "@nestjs/common"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; @Module({ imports: [BusinessLogicsModule], controllers: [LeaveRequestController], - providers: [LeaveRequestsService], - exports: [LeaveRequestsService], + providers: [ + HolidayService, + HolidayLeaveRequestsService, + PrismaService, + ], + exports: [HolidayLeaveRequestsService], }) export class LeaveRequestsModule {} \ No newline at end of file diff --git a/src/modules/leave-requests/services/leave-requests.service.ts b/src/modules/leave-requests/services/holiday-leave-requests.service.ts similarity index 50% rename from src/modules/leave-requests/services/leave-requests.service.ts rename to src/modules/leave-requests/services/holiday-leave-requests.service.ts index a325965..3e5fbcb 100644 --- a/src/modules/leave-requests/services/leave-requests.service.ts +++ b/src/modules/leave-requests/services/holiday-leave-requests.service.ts @@ -1,46 +1,37 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { LeaveTypes, LeaveRequestsArchive } from "@prisma/client"; -import { PrismaService } from "src/prisma/prisma.service"; -import { HolidayService } from "src/modules/business-logics/services/holiday.service"; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; -import { UpsertHolidayDto, HolidayUpsertAction } from "../dtos/upsert-holiday.dto"; -import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; -import { mapRowToView } from "../mappers/leave-requests.mapper"; -import { mapArchiveRowToViewWithDays } from "../utils/leave-request.transform"; -import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select"; -import { leaveRequestsSelect } from "../utils/leave-requests.select"; +import { HolidayService } from 'src/modules/business-logics/services/holiday.service'; +import { ShiftsCommandService } from 'src/modules/shifts/services/shifts-command.service'; +import { PrismaService } from 'src/prisma/prisma.service'; + +import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto'; +import { HolidayUpsertAction, UpsertHolidayDto } from '../dtos/upsert-holiday.dto'; +import { mapRowToView } from '../mappers/leave-requests.mapper'; +import { leaveRequestsSelect } from '../utils/leave-requests.select'; + +interface HolidayUpsertResult { + action: HolidayUpsertAction; + leave_requests: LeaveRequestViewDto[]; +} @Injectable() -export class LeaveRequestsService { +export class HolidayLeaveRequestsService { constructor( private readonly prisma: PrismaService, private readonly holidayService: HolidayService, + private readonly shiftsCommand: ShiftsCommandService, ) {} - //-------------------- helpers -------------------- - private async resolveEmployeeIdByEmail(email: string): Promise { - const employee = await this.prisma.employees.findFirst({ - where: { user: { email } }, - select: { id: true }, - }); - if (!employee) { - throw new NotFoundException(`Employee with email ${email} not found`); - } - return employee.id; - } + // --------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------- - private async resolveHolidayBankCode() { - const bankCode = await this.prisma.bankCodes.findFirst({ - where: { type: 'HOLIDAY' }, - select: { id: true, bank_code: true, modifier: true }, - }); - if (!bankCode) { - throw new BadRequestException('Bank code type "HOLIDAY" not found'); - } - return bankCode; - } - - async handleHoliday(dto: UpsertHolidayDto): Promise<{ action: HolidayUpsertAction; leave_requests: LeaveRequestViewDto[] }> { + async handleHoliday(dto: UpsertHolidayDto): Promise { switch (dto.action) { case 'create': return this.createHoliday(dto); @@ -53,7 +44,11 @@ export class LeaveRequestsService { } } - private async createHoliday(dto: UpsertHolidayDto): Promise<{ action: 'create'; leave_requests: LeaveRequestViewDto[] }> { + // --------------------------------------------------------------------- + // Create + // --------------------------------------------------------------------- + + private async createHoliday(dto: UpsertHolidayDto): Promise { const email = dto.email.trim(); const employeeId = await this.resolveEmployeeIdByEmail(email); const bankCode = await this.resolveHolidayBankCode(); @@ -63,8 +58,10 @@ export class LeaveRequestsService { } const created: LeaveRequestViewDto[] = []; + for (const isoDate of dates) { const date = toDateOnly(isoDate); + const existing = await this.prisma.leaveRequests.findUnique({ where: { leave_per_employee_date: { @@ -76,7 +73,7 @@ export class LeaveRequestsService { select: { id: true }, }); if (existing) { - throw new BadRequestException(`A holiday request already exists for ${isoDate}`); + throw new BadRequestException(`Holiday request already exists for ${isoDate}`); } const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier); @@ -87,19 +84,29 @@ export class LeaveRequestsService { leave_type: LeaveTypes.HOLIDAY, date, comment: dto.comment ?? '', - approval_status: undefined, requested_hours: dto.requested_hours ?? 8, payable_hours: payable, + approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, }, select: leaveRequestsSelect, }); + + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment); + } + created.push({ ...mapRowToView(row), action: 'create' }); } return { action: 'create', leave_requests: created }; } - private async updateHoliday(dto: UpsertHolidayDto): Promise<{ action: 'update'; leave_requests: LeaveRequestViewDto[] }> { + // --------------------------------------------------------------------- + // Update + // --------------------------------------------------------------------- + + private async updateHoliday(dto: UpsertHolidayDto): Promise { const email = dto.email.trim(); const employeeId = await this.resolveEmployeeIdByEmail(email); const bankCode = await this.resolveHolidayBankCode(); @@ -109,8 +116,10 @@ export class LeaveRequestsService { } const updated: LeaveRequestViewDto[] = []; + for (const isoDate of dates) { const date = toDateOnly(isoDate); + const existing = await this.prisma.leaveRequests.findUnique({ where: { leave_per_employee_date: { @@ -126,23 +135,43 @@ export class LeaveRequestsService { } const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier); + const previousStatus = existing.approval_status; + const row = await this.prisma.leaveRequests.update({ where: { id: existing.id }, data: { comment: dto.comment ?? existing.comment, - requested_hours: dto.requested_hours ?? undefined, + requested_hours: dto.requested_hours ?? existing.requested_hours ?? 8, payable_hours: payable, bank_code_id: bankCode.id, + approval_status: dto.approval_status ?? existing.approval_status, }, select: leaveRequestsSelect, }); + + const wasApproved = previousStatus === LeaveApprovalStatus.APPROVED; + const isApproved = row.approval_status === LeaveApprovalStatus.APPROVED; + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + + if (!wasApproved && isApproved) { + await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment); + } else if (wasApproved && !isApproved) { + await this.removeHolidayShift(email, employeeId, isoDate); + } else if (wasApproved && isApproved) { + await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment); + } + updated.push({ ...mapRowToView(row), action: 'update' }); } return { action: 'update', leave_requests: updated }; } - private async deleteHoliday(dto: UpsertHolidayDto): Promise<{ action: 'delete'; leave_requests: LeaveRequestViewDto[] }> { + // --------------------------------------------------------------------- + // Delete + // --------------------------------------------------------------------- + + private async deleteHoliday(dto: UpsertHolidayDto): Promise { const email = dto.email.trim(); const employeeId = await this.resolveEmployeeIdByEmail(email); const dates = normalizeDates(dto.dates); @@ -164,48 +193,118 @@ export class LeaveRequestsService { throw new NotFoundException(`No HOLIDAY request found for: ${missing.join(', ')}`); } + for (const row of rows) { + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + const iso = toISODateKey(row.date); + await this.removeHolidayShift(email, employeeId, iso); + } + } + await this.prisma.leaveRequests.deleteMany({ where: { id: { in: rows.map((row) => row.id) } }, }); - const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' as const })); + const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' })); return { action: 'delete', leave_requests: deleted }; } - //-------------------- archival -------------------- - async archiveExpired(): Promise { - // TODO: adjust logic to the new LeaveRequests structure - } + // --------------------------------------------------------------------- + // Shift synchronisation + // --------------------------------------------------------------------- - async findAllArchived(): Promise { - return this.prisma.leaveRequestsArchive.findMany(); - } + private async syncHolidayShift( + email: string, + employeeId: number, + isoDate: string, + hours: number, + comment?: string, + ) { + if (hours <= 0) return; - async findOneArchived(id: number): Promise { - const row: LeaveRequestArchiveRow | null = await this.prisma.leaveRequestsArchive.findUnique({ - where: { id }, - select: leaveRequestsArchiveSelect, - }); - if (!row) { - throw new NotFoundException(`Archived Leave Request #${id} not found`); + const durationMinutes = Math.round(hours * 60); + if (durationMinutes > 8 * 60) { + throw new BadRequestException('Holiday hours cannot exceed 8 hours.'); } - const emp = await this.prisma.employees.findUnique({ - where: { id: row.employee_id }, - select: { - user: { - select: { - email: true, - first_name: true, - last_name: true, - }, - }, + const startMinutes = 8 * 60; + const endMinutes = startMinutes + durationMinutes; + const toHHmm = (total: number) => `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; + + const existing = await this.prisma.shifts.findFirst({ + where: { + date: new Date(isoDate), + bank_code: { type: 'HOLIDAY' }, + timesheet: { employee_id: employeeId }, + }, + include: { bank_code: true }, + }); + + await this.shiftsCommand.upsertShiftsByDate(email, isoDate, { + old_shift: existing + ? { + start_time: existing.start_time.toISOString().slice(11, 16), + end_time: existing.end_time.toISOString().slice(11, 16), + type: existing.bank_code?.type ?? 'HOLIDAY', + is_remote: existing.is_remote, + comment: existing.comment ?? undefined, + } + : undefined, + new_shift: { + start_time: toHHmm(startMinutes), + end_time: toHHmm(endMinutes), + type: 'HOLIDAY', + is_remote: existing?.is_remote ?? false, + comment: comment ?? existing?.comment ?? '', }, }); - const email = emp?.user.email ?? ''; - const fullName = emp ? `${emp.user.first_name} ${emp.user.last_name}` : ''; + } - return mapArchiveRowToViewWithDays(row, email, fullName); + private async removeHolidayShift(email: string, employeeId: number, isoDate: string) { + const existing = await this.prisma.shifts.findFirst({ + where: { + date: new Date(isoDate), + bank_code: { type: 'HOLIDAY' }, + timesheet: { employee_id: employeeId }, + }, + include: { bank_code: true }, + }); + if (!existing) return; + + await this.shiftsCommand.upsertShiftsByDate(email, isoDate, { + old_shift: { + start_time: existing.start_time.toISOString().slice(11, 16), + end_time: existing.end_time.toISOString().slice(11, 16), + type: existing.bank_code?.type ?? 'HOLIDAY', + is_remote: existing.is_remote, + comment: existing.comment ?? undefined, + }, + }); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private async resolveEmployeeIdByEmail(email: string): Promise { + const employee = await this.prisma.employees.findFirst({ + where: { user: { email } }, + select: { id: true }, + }); + if (!employee) { + throw new NotFoundException(`Employee with email ${email} not found`); + } + return employee.id; + } + + private async resolveHolidayBankCode() { + const bankCode = await this.prisma.bankCodes.findFirst({ + where: { type: 'HOLIDAY' }, + select: { id: true, bank_code: true, modifier: true }, + }); + if (!bankCode) { + throw new BadRequestException('Bank code type "HOLIDAY" not found'); + } + return bankCode; } } From 3984540edbf8cc3cda81bb6f3da9403aea6c339d Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 3 Oct 2025 10:22:27 -0400 Subject: [PATCH 43/69] fix(leaves and expenses): import name fixes --- docs/swagger/swagger-spec.json | 303 +----------------- .../07-leave-requests-future.ts | 27 +- .../08-leave-requests-archive.ts | 54 ++-- src/modules/archival/archival.module.ts | 2 +- .../leave-requests-archive.controller.ts | 32 +- .../archival/services/archival.service.ts | 4 +- .../services/expenses-command.service.ts | 12 +- .../expenses.types.interfaces.ts | 3 +- src/modules/expenses/utils/expenses.utils.ts | 4 +- .../dtos/leave-request-view.dto.ts | 2 +- .../leave-requests/leave-requests.module.ts | 2 + .../mappers/leave-requests.mapper.ts | 6 +- .../holiday-leave-requests.service.ts | 20 +- .../utils/leave-request.transform.ts | 41 +-- 14 files changed, 93 insertions(+), 419 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 68cdc3d..8f2bd52 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -77,30 +77,6 @@ ] } }, - "/archives/leaveRequests": { - "get": { - "operationId": "LeaveRequestsArchiveController_findOneArchived", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "200": { - "description": "Archived leaveRequest found" - } - }, - "summary": "Fetch leaveRequest in archives with its Id", - "tags": [ - "LeaveRequests Archives" - ] - } - }, "/archives/shifts": { "get": { "operationId": "ShiftsArchiveController_findOneArchived", @@ -1256,215 +1232,22 @@ ] } }, - "/leave-requests": { + "/leave-requests/holiday": { "post": { - "operationId": "LeaveRequestController_create", + "operationId": "LeaveRequestController_upsertHoliday", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateLeaveRequestsDto" + "$ref": "#/components/schemas/UpsertHolidayDto" } } } }, "responses": { "201": { - "description": "Leave request created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateLeaveRequestsDto" - } - } - } - }, - "400": { - "description": "Incomplete task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create leave request", - "tags": [ - "Leave Requests" - ] - }, - "get": { - "operationId": "LeaveRequestController_findAll", - "parameters": [], - "responses": { - "201": { - "description": "List of Leave requests found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LeaveRequestViewDto" - } - } - } - } - }, - "400": { - "description": "List of leave request not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find all leave request", - "tags": [ - "Leave Requests" - ] - } - }, - "/leave-requests/{id}": { - "get": { - "operationId": "LeaveRequestController_findOne", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Leave request found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LeaveRequestViewDto" - } - } - } - }, - "400": { - "description": "Leave request not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find leave request", - "tags": [ - "Leave Requests" - ] - }, - "patch": { - "operationId": "LeaveRequestController_update", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateLeaveRequestsDto" - } - } - } - }, - "responses": { - "201": { - "description": "Leave request updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LeaveRequestViewDto" - } - } - } - }, - "400": { - "description": "Leave request not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Update leave request", - "tags": [ - "Leave Requests" - ] - }, - "delete": { - "operationId": "LeaveRequestController_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Leave request deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateLeaveRequestsDto" - } - } - } - }, - "400": { - "description": "Leave request not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete leave request", - "tags": [ - "Leave Requests" - ] - } - }, - "/leave-requests/approval/{id}": { - "patch": { - "operationId": "LeaveRequestController_updateApproval", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "200": { "description": "" } }, @@ -2691,88 +2474,10 @@ } } }, - "CreateLeaveRequestsDto": { - "type": "object", - "properties": { - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of a leave-request code (link with bank-codes)" - }, - "leave_type": { - "type": "string", - "example": "Sick or Vacation or Unpaid or Bereavement or Parental or Legal", - "description": "type of leave request for an accounting perception" - }, - "start_date_time": { - "type": "string", - "example": "22/06/2463", - "description": "Leave request`s start date" - }, - "end_date_time": { - "type": "string", - "example": "25/03/3019", - "description": "Leave request`s end date" - }, - "comment": { - "type": "string", - "example": "My precious", - "description": "Leave request`s comment" - }, - "approval_status": { - "type": "string", - "example": "True or False or Pending or Denied or Cancelled or Escalated", - "description": "Leave request`s approval status" - } - }, - "required": [ - "bank_code_id", - "leave_type", - "start_date_time", - "end_date_time", - "comment", - "approval_status" - ] - }, - "LeaveRequestViewDto": { + "UpsertHolidayDto": { "type": "object", "properties": {} }, - "UpdateLeaveRequestsDto": { - "type": "object", - "properties": { - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of a leave-request code (link with bank-codes)" - }, - "leave_type": { - "type": "string", - "example": "Sick or Vacation or Unpaid or Bereavement or Parental or Legal", - "description": "type of leave request for an accounting perception" - }, - "start_date_time": { - "type": "string", - "example": "22/06/2463", - "description": "Leave request`s start date" - }, - "end_date_time": { - "type": "string", - "example": "25/03/3019", - "description": "Leave request`s end date" - }, - "comment": { - "type": "string", - "example": "My precious", - "description": "Leave request`s comment" - }, - "approval_status": { - "type": "string", - "example": "True or False or Pending or Denied or Cancelled or Escalated", - "description": "Leave request`s approval status" - } - } - }, "CreateBankCodeDto": { "type": "object", "properties": { diff --git a/prisma/mock-seeds-scripts/07-leave-requests-future.ts b/prisma/mock-seeds-scripts/07-leave-requests-future.ts index c5dc5e2..b96c63d 100644 --- a/prisma/mock-seeds-scripts/07-leave-requests-future.ts +++ b/prisma/mock-seeds-scripts/07-leave-requests-future.ts @@ -1,14 +1,14 @@ import { PrismaClient, Prisma, LeaveTypes, LeaveApprovalStatus } from '@prisma/client'; if (process.env.SKIP_LEAVE_REQUESTS === 'true') { - console.log("â­ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + console.log('?? Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)'); process.exit(0); } const prisma = new PrismaClient(); function dateOn(y: number, m: number, d: number) { - // stocke une date (pour @db.Date) à minuit UTC + // stocke une date (@db.Date) à minuit UTC return new Date(Date.UTC(y, m - 1, d, 0, 0, 0)); } @@ -19,7 +19,7 @@ async function main() { const employees = await prisma.employees.findMany({ select: { id: true } }); const bankCodes = await prisma.bankCodes.findMany({ where: { categorie: 'LEAVE' }, - select: { id: true }, + select: { id: true, type: true }, }); if (!employees.length || !bankCodes.length) { @@ -44,30 +44,31 @@ async function main() { LeaveApprovalStatus.ESCALATED, ]; - const futureMonths = [8, 9, 10, 11, 12]; // Août→Déc (1-based) + const futureMonths = [8, 9, 10, 11, 12]; // Août ? Déc. (1-based) - // ✅ typer rows pour éviter never[] const rows: Prisma.LeaveRequestsCreateManyInput[] = []; for (let i = 0; i < 10; i++) { const emp = employees[i % employees.length]; const m = futureMonths[i % futureMonths.length]; - const start = dateOn(year, m, 5 + i); // 5..14 - if (start <= today) continue; // garantir "futur" + const date = dateOn(year, m, 5 + i); // 5..14 + if (date <= today) continue; // garantir « futur » - const end = Math.random() < 0.5 ? null : dateOn(year, m, 6 + i); const type = types[i % types.length]; const status = statuses[i % statuses.length]; const bc = bankCodes[i % bankCodes.length]; + const requestedHours = 4 + (i % 5); // 4 ? 8 h + const payableHours = status === LeaveApprovalStatus.APPROVED ? Math.min(requestedHours, 8) : null; rows.push({ employee_id: emp.id, bank_code_id: bc.id, leave_type: type, - start_date_time: start, - end_date_time: end, // ok: Date | null - comment: `Future leave #${i + 1}`, + date, + comment: `Future leave #${i + 1} (${bc.type})`, approval_status: status, + requested_hours: requestedHours, + payable_hours: payableHours, }); } @@ -75,7 +76,7 @@ async function main() { await prisma.leaveRequests.createMany({ data: rows }); } - console.log(`✓ LeaveRequests (future): ${rows.length} rows`); + console.log(`? LeaveRequests (future): ${rows.length} rows`); } -main().finally(() => prisma.$disconnect()); +main().finally(() => prisma.$disconnect()); \ No newline at end of file diff --git a/prisma/mock-seeds-scripts/08-leave-requests-archive.ts b/prisma/mock-seeds-scripts/08-leave-requests-archive.ts index d92a51f..45b1d43 100644 --- a/prisma/mock-seeds-scripts/08-leave-requests-archive.ts +++ b/prisma/mock-seeds-scripts/08-leave-requests-archive.ts @@ -1,7 +1,7 @@ -import { PrismaClient, LeaveApprovalStatus, LeaveRequests } from '@prisma/client'; +import { PrismaClient, LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; if (process.env.SKIP_LEAVE_REQUESTS === 'true') { - console.log("â­ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + console.log('?? Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)'); process.exit(0); } @@ -15,65 +15,73 @@ function daysAgo(n: number) { } async function main() { - // 1) Récupère tous les employés const employees = await prisma.employees.findMany({ select: { id: true } }); if (!employees.length) { - throw new Error('Aucun employé trouvé. Exécute le seed employees avant celui-ci.'); + throw new Error('Aucun employé trouvé. Exécute le seed employees avant celui-ci.'); } - // 2) Va chercher les bank codes dont le type est SICK, VACATION ou HOLIDAY const leaveCodes = await prisma.bankCodes.findMany({ - where: { type: { in: ['SICK', 'VACATION'] } }, - select: { id: true, type: true, bank_code: true }, + where: { type: { in: ['SICK', 'VACATION', 'HOLIDAY'] } }, + select: { id: true, type: true }, }); if (!leaveCodes.length) { - throw new Error("Aucun bank code trouvé avec type in ('SICK','VACATION','HOLIDAY'). Vérifie ta table bank_codes."); + throw new Error("Aucun bank code trouvé avec type in ('SICK','VACATION','HOLIDAY'). Vérifie ta table bank_codes."); } const statuses = Object.values(LeaveApprovalStatus); - const created: LeaveRequests[] = []; + const created = [] as Array<{ id: number; employee_id: number; leave_type: LeaveTypes; date: Date; comment: string; approval_status: LeaveApprovalStatus; requested_hours: number; payable_hours: number | null }>; - // 3) Crée quelques leave requests const COUNT = 12; for (let i = 0; i < COUNT; i++) { const emp = employees[i % employees.length]; const leaveCode = leaveCodes[Math.floor(Math.random() * leaveCodes.length)]; - const start = daysAgo(120 - i * 3); - const end = Math.random() < 0.6 ? daysAgo(119 - i * 3) : null; + const date = daysAgo(120 - i * 3); + const status = statuses[(i + 2) % statuses.length]; + const requestedHours = 4 + (i % 5); // 4 ? 8 h + const payableHours = status === LeaveApprovalStatus.APPROVED ? Math.min(requestedHours, 8) : null; const lr = await prisma.leaveRequests.create({ data: { employee_id: emp.id, bank_code_id: leaveCode.id, - // on stocke le "type" tel qu’il est défini dans bank_codes - leave_type: leaveCode.type as any, - start_date_time: start, - end_date_time: end, + leave_type: leaveCode.type as LeaveTypes, + date, comment: `Past leave #${i + 1} (${leaveCode.type})`, - approval_status: statuses[(i + 2) % statuses.length], + approval_status: status, + requested_hours: requestedHours, + payable_hours: payableHours, }, }); - created.push(lr); + created.push({ + id: lr.id, + employee_id: lr.employee_id, + leave_type: lr.leave_type, + date: lr.date, + comment: lr.comment, + approval_status: lr.approval_status, + requested_hours: requestedHours, + payable_hours: payableHours, + }); } - // 4) Archive for (const lr of created) { await prisma.leaveRequestsArchive.create({ data: { leave_request_id: lr.id, employee_id: lr.employee_id, leave_type: lr.leave_type, - start_date_time: lr.start_date_time, - end_date_time: lr.end_date_time, + date: lr.date, comment: lr.comment, approval_status: lr.approval_status, + requested_hours: lr.requested_hours, + payable_hours: lr.payable_hours, }, }); } - console.log(`✓ LeaveRequestsArchive: ${created.length} rows`); + console.log(`? LeaveRequestsArchive: ${created.length} rows`); } -main().finally(() => prisma.$disconnect()); +main().finally(() => prisma.$disconnect()); \ No newline at end of file diff --git a/src/modules/archival/archival.module.ts b/src/modules/archival/archival.module.ts index 7a8b73a..03f1bf9 100644 --- a/src/modules/archival/archival.module.ts +++ b/src/modules/archival/archival.module.ts @@ -28,7 +28,7 @@ import { EmployeesModule } from "../employees/employees.module"; LeaveRequestsArchiveController, ShiftsArchiveController, TimesheetsArchiveController, - ] + ], }) export class ArchivalModule {} \ No newline at end of file diff --git a/src/modules/archival/controllers/leave-requests-archive.controller.ts b/src/modules/archival/controllers/leave-requests-archive.controller.ts index 89cb024..1c5e4be 100644 --- a/src/modules/archival/controllers/leave-requests-archive.controller.ts +++ b/src/modules/archival/controllers/leave-requests-archive.controller.ts @@ -1,33 +1,7 @@ -import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } from "@nestjs/common"; -import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; -import { LeaveRequestsArchive, Roles as RoleEnum } from "@prisma/client"; -import { RolesAllowed } from "src/common/decorators/roles.decorators"; -import { LeaveRequestViewDto } from "src/modules/leave-requests/dtos/leave-request.view.dto"; -import { LeaveRequestsService } from "src/modules/leave-requests/services/holiday-leave-requests.service"; +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; @ApiTags('LeaveRequests Archives') // @UseGuards() @Controller('archives/leaveRequests') -export class LeaveRequestsArchiveController { - constructor(private readonly leaveRequestsService: LeaveRequestsService) {} - - @Get() - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'List of archived leaveRequests'}) - @ApiResponse({ status: 200, description: 'List of archived leaveRequests', isArray: true }) - async findAllArchived(): Promise { - return this.leaveRequestsService.findAllArchived(); - } - - @Get() - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Fetch leaveRequest in archives with its Id'}) - @ApiResponse({ status: 200, description: 'Archived leaveRequest found'}) - async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise { - try{ - return await this.leaveRequestsService.findOneArchived(id); - }catch { - throw new NotFoundException(`Archived leaveRequest #${id} not found`); - } - } -} \ No newline at end of file +export class LeaveRequestsArchiveController {} \ No newline at end of file diff --git a/src/modules/archival/services/archival.service.ts b/src/modules/archival/services/archival.service.ts index 07ab621..7dbf567 100644 --- a/src/modules/archival/services/archival.service.ts +++ b/src/modules/archival/services/archival.service.ts @@ -1,7 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; -import { LeaveRequestsService } from "src/modules/leave-requests/services/holiday-leave-requests.service"; import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; @@ -13,7 +12,6 @@ export class ArchivalService { private readonly timesheetsService: TimesheetsQueryService, private readonly expensesService: ExpensesQueryService, private readonly shiftsService: ShiftsQueryService, - private readonly leaveRequestsService: LeaveRequestsService, ) {} @Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00 @@ -31,7 +29,7 @@ export class ArchivalService { await this.timesheetsService.archiveOld(); await this.expensesService.archiveOld(); await this.shiftsService.archiveOld(); - await this.leaveRequestsService.archiveExpired(); + // await this.leaveRequestsService.archiveExpired(); this.logger.log('archivation process done'); } catch (err) { this.logger.error('an error occured during archivation process ', err); diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 06e3895..ae8af6b 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -53,8 +53,8 @@ export class ExpensesCommandService extends BaseApprovalService { } //validate date format - const dateOnly = toDateOnlyUTC(date); - if(Number.isNaN(dateOnly.getTime())) { + const date_only = toDateOnlyUTC(date); + if(Number.isNaN(date_only.getTime())) { throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)'); } @@ -62,14 +62,14 @@ export class ExpensesCommandService extends BaseApprovalService { const employee_id = await this.resolveEmployeeIdByEmail(email); //make sure a timesheet existes - const timesheet_id = await this.ensureTimesheetForDate(employee_id, dateOnly); + const timesheet_id = await this.ensureTimesheetForDate(employee_id, date_only); return this.prisma.$transaction(async (tx) => { const loadDay = async (): Promise => { const rows = await tx.expenses.findMany({ where: { timesheet_id: timesheet_id, - date: dateOnly, + date: date_only, }, include: { bank_code: { @@ -160,7 +160,7 @@ export class ExpensesCommandService extends BaseApprovalService { return tx.expenses.findFirst({ where: { timesheet_id: timesheet_id, - date: dateOnly, + date: date_only, bank_code_id: norm.bank_code_id, amount: norm.amount, comment: norm.comment, @@ -191,7 +191,7 @@ export class ExpensesCommandService extends BaseApprovalService { await tx.expenses.create({ data: { timesheet_id: timesheet_id, - date: dateOnly, + date: date_only, bank_code_id: new_exp.bank_code_id, amount: new_exp.amount, mileage: new_exp.mileage, diff --git a/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts index 84a82dc..5f592a5 100644 --- a/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts +++ b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts @@ -9,5 +9,6 @@ export interface ExpenseResponse { }; export type UpsertExpenseResult = { - expenses: ExpenseResponse[] + action: UpsertAction; + day: ExpenseResponse[] }; \ No newline at end of file diff --git a/src/modules/expenses/utils/expenses.utils.ts b/src/modules/expenses/utils/expenses.utils.ts index edd2c4a..87e2120 100644 --- a/src/modules/expenses/utils/expenses.utils.ts +++ b/src/modules/expenses/utils/expenses.utils.ts @@ -1,5 +1,5 @@ import { BadRequestException } from "@nestjs/common"; -import { DayExpenseResponse } from "../types and interfaces/expenses.types.interfaces"; +import { ExpenseResponse } from "../types and interfaces/expenses.types.interfaces"; import { Prisma } from "@prisma/client"; //uppercase and trim for validation @@ -55,7 +55,7 @@ export function mapDbExpenseToDayResponse(row: { comment: string; is_approved: boolean; bank_code?: { type?: string | null } | null; -}): DayExpenseResponse { +}): ExpenseResponse { const yyyyMmDd = row.date.toISOString().slice(0,10); const toNum = (value: any)=> (value == null ? 0 : Number(value)); return { diff --git a/src/modules/leave-requests/dtos/leave-request-view.dto.ts b/src/modules/leave-requests/dtos/leave-request-view.dto.ts index db36da7..7cf3f35 100644 --- a/src/modules/leave-requests/dtos/leave-request-view.dto.ts +++ b/src/modules/leave-requests/dtos/leave-request-view.dto.ts @@ -10,5 +10,5 @@ export class LeaveRequestViewDto { employee_full_name!: string; payable_hours?: number; requested_hours?: number; - action?: 'created' | 'updated' | 'deleted'; + action?: 'create' | 'update' | 'delete'; } \ No newline at end of file diff --git a/src/modules/leave-requests/leave-requests.module.ts b/src/modules/leave-requests/leave-requests.module.ts index 2249f21..394b6be 100644 --- a/src/modules/leave-requests/leave-requests.module.ts +++ b/src/modules/leave-requests/leave-requests.module.ts @@ -4,6 +4,7 @@ import { LeaveRequestController } from "./controllers/leave-requests.controller" import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service"; import { Module } from "@nestjs/common"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; +import { ShiftsCommandService } from "../shifts/services/shifts-command.service"; @Module({ imports: [BusinessLogicsModule], @@ -12,6 +13,7 @@ import { BusinessLogicsModule } from "src/modules/business-logics/business-logic HolidayService, HolidayLeaveRequestsService, PrismaService, + ShiftsCommandService, ], exports: [HolidayLeaveRequestsService], }) diff --git a/src/modules/leave-requests/mappers/leave-requests.mapper.ts b/src/modules/leave-requests/mappers/leave-requests.mapper.ts index fda4f6d..e93f94b 100644 --- a/src/modules/leave-requests/mappers/leave-requests.mapper.ts +++ b/src/modules/leave-requests/mappers/leave-requests.mapper.ts @@ -6,13 +6,13 @@ const toNum = (value?: Prisma.Decimal | null) => value !== null && value !== undefined ? Number(value) : undefined; export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto { - const isoDate = row.date?.toISOString().slice(0, 10); - if (!isoDate) throw new Error(`Leave request #${row.id} has no date set.`); + const iso_date = row.date?.toISOString().slice(0, 10); + if (!iso_date) throw new Error(`Leave request #${row.id} has no date set.`); return { id: row.id, leave_type: row.leave_type, - date: isoDate, + date: iso_date, payable_hours: toNum(row.payable_hours), requested_hours: toNum(row.requested_hours), comment: row.comment, diff --git a/src/modules/leave-requests/services/holiday-leave-requests.service.ts b/src/modules/leave-requests/services/holiday-leave-requests.service.ts index 3e5fbcb..c3f614d 100644 --- a/src/modules/leave-requests/services/holiday-leave-requests.service.ts +++ b/src/modules/leave-requests/services/holiday-leave-requests.service.ts @@ -3,16 +3,14 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; - -import { HolidayService } from 'src/modules/business-logics/services/holiday.service'; -import { ShiftsCommandService } from 'src/modules/shifts/services/shifts-command.service'; -import { PrismaService } from 'src/prisma/prisma.service'; - -import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto'; +import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; import { HolidayUpsertAction, UpsertHolidayDto } from '../dtos/upsert-holiday.dto'; -import { mapRowToView } from '../mappers/leave-requests.mapper'; -import { leaveRequestsSelect } from '../utils/leave-requests.select'; +import { HolidayService } from 'src/modules/business-logics/services/holiday.service'; +import { ShiftsCommandService } from 'src/modules/shifts/services/shifts-command.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto'; +import { mapRowToView } from '../mappers/leave-requests.mapper'; +import { leaveRequestsSelect } from '../utils/leave-requests.select'; interface HolidayUpsertResult { action: HolidayUpsertAction; @@ -204,7 +202,7 @@ export class HolidayLeaveRequestsService { where: { id: { in: rows.map((row) => row.id) } }, }); - const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' })); + const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' as const})); return { action: 'delete', leave_requests: deleted }; } @@ -320,4 +318,4 @@ const toDateOnly = (iso: string): Date => { const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10); const normalizeDates = (dates: string[]): string[] => - Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso))))); \ No newline at end of file + Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso))))); diff --git a/src/modules/leave-requests/utils/leave-request.transform.ts b/src/modules/leave-requests/utils/leave-request.transform.ts index b70c66d..63b9936 100644 --- a/src/modules/leave-requests/utils/leave-request.transform.ts +++ b/src/modules/leave-requests/utils/leave-request.transform.ts @@ -1,32 +1,19 @@ -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; -import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper"; -import { mapRowToView } from "../mappers/leave-requests.mapper"; -import { LeaveRequestArchiveRow } from "./leave-requests-archive.select"; -import { LeaveRequestRow } from "./leave-requests.select"; +import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto'; +import { mapArchiveRowToView } from '../mappers/leave-requests-archive.mapper'; +import { mapRowToView } from '../mappers/leave-requests.mapper'; +import { LeaveRequestArchiveRow } from './leave-requests-archive.select'; +import { LeaveRequestRow } from './leave-requests.select'; -function toUTCDateOnly(date: Date): Date { - return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); -} - -const MS_PER_DAY = 86_400_000; -function computeDaysRequested(start_date: Date, end_date?: Date | null): number { - const start = toUTCDateOnly(start_date); - const end = toUTCDateOnly(end_date ?? start_date); - const diff = Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY) + 1; - return Math.max(1, diff); -} - -/** Active (table leave_requests) : map + days_requested */ +/** Active (table leave_requests) : proxy to base mapper */ export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto { - const view = mapRowToView(row); - view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time); - return view; + return mapRowToView(row); } -/** Archive (table leave_requests_archive) : map + days_requested */ -export function mapArchiveRowToViewWithDays(row: LeaveRequestArchiveRow, email: string, employee_full_name?: string): - LeaveRequestViewDto { - const view = mapArchiveRowToView(row, email, employee_full_name!); - view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time); - return view; +/** Archive (table leave_requests_archive) : proxy to base mapper */ +export function mapArchiveRowToViewWithDays( + row: LeaveRequestArchiveRow, + email: string, + employee_full_name?: string, +): LeaveRequestViewDto { + return mapArchiveRowToView(row, email, employee_full_name!); } \ No newline at end of file From d8bc05f6e2a4cea18a8cd9e861e267156defc79a Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 3 Oct 2025 14:03:09 -0400 Subject: [PATCH 44/69] feat(preferences): setup for prefrences module which allows a user to manage dark mode, notifications, lefty mode for phone user and a lang switch (en, fr) --- .../migration.sql | 56 +++++++++++++++++++ prisma/schema.prisma | 15 ++++- .../controllers/preferences.controller.ts | 14 +++++ .../preferences/dtos/preferences.dto.ts | 16 ++++++ src/modules/preferences/preferences.module.ts | 11 ++++ .../services/preferences.service.ts | 32 +++++++++++ .../shifts/controllers/shifts.controller.ts | 5 +- .../shifts/services/shifts-query.service.ts | 10 ++-- 8 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/20251003151925_added_preferences_table/migration.sql create mode 100644 src/modules/preferences/controllers/preferences.controller.ts create mode 100644 src/modules/preferences/dtos/preferences.dto.ts create mode 100644 src/modules/preferences/preferences.module.ts create mode 100644 src/modules/preferences/services/preferences.service.ts diff --git a/prisma/migrations/20251003151925_added_preferences_table/migration.sql b/prisma/migrations/20251003151925_added_preferences_table/migration.sql new file mode 100644 index 0000000..ab54053 --- /dev/null +++ b/prisma/migrations/20251003151925_added_preferences_table/migration.sql @@ -0,0 +1,56 @@ +/* + Warnings: + + - You are about to drop the column `end_date_time` on the `leave_requests` table. All the data in the column will be lost. + - You are about to drop the column `start_date_time` on the `leave_requests` table. All the data in the column will be lost. + - You are about to drop the column `end_date_time` on the `leave_requests_archive` table. All the data in the column will be lost. + - You are about to drop the column `start_date_time` on the `leave_requests_archive` table. All the data in the column will be lost. + - A unique constraint covering the columns `[employee_id,leave_type,date]` on the table `leave_requests` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[leave_request_id]` on the table `leave_requests_archive` will be added. If there are existing duplicate values, this will fail. + - Added the required column `date` to the `leave_requests` table without a default value. This is not possible if the table is not empty. + - Added the required column `date` to the `leave_requests_archive` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterEnum +ALTER TYPE "leave_types" ADD VALUE 'HOLIDAY'; + +-- AlterTable +ALTER TABLE "leave_requests" DROP COLUMN "end_date_time", +DROP COLUMN "start_date_time", +ADD COLUMN "date" DATE NOT NULL, +ADD COLUMN "payable_hours" DECIMAL(5,2), +ADD COLUMN "requested_hours" DECIMAL(5,2); + +-- AlterTable +ALTER TABLE "leave_requests_archive" DROP COLUMN "end_date_time", +DROP COLUMN "start_date_time", +ADD COLUMN "date" DATE NOT NULL, +ADD COLUMN "payable_hours" DECIMAL(5,2), +ADD COLUMN "requested_hours" DECIMAL(5,2); + +-- CreateTable +CREATE TABLE "preferences" ( + "user_id" UUID NOT NULL, + "notifications" BOOLEAN NOT NULL DEFAULT false, + "dark_mode" BOOLEAN NOT NULL DEFAULT false, + "lang_switch" BOOLEAN NOT NULL DEFAULT false, + "lefty_mode" BOOLEAN NOT NULL DEFAULT false +); + +-- CreateIndex +CREATE UNIQUE INDEX "preferences_user_id_key" ON "preferences"("user_id"); + +-- CreateIndex +CREATE INDEX "leave_requests_employee_id_date_idx" ON "leave_requests"("employee_id", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "leave_requests_employee_id_leave_type_date_key" ON "leave_requests"("employee_id", "leave_type", "date"); + +-- CreateIndex +CREATE INDEX "leave_requests_archive_employee_id_date_idx" ON "leave_requests_archive"("employee_id", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "leave_requests_archive_leave_request_id_key" ON "leave_requests_archive"("leave_request_id"); + +-- AddForeignKey +ALTER TABLE "preferences" ADD CONSTRAINT "preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b32b202..4a45c7b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,7 +28,7 @@ model Users { oauth_sessions OAuthSessions[] @relation("UserOAuthSessions") employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive") customer_archive CustomersArchive[] @relation("UserToCustomersToArchive") - + preferences Preferences? @relation("UserPreferences") @@map("users") } @@ -314,6 +314,19 @@ model Attachments { @@map("attachments") } +model Preferences { + id Int @id @default(autoincrement()) + user Users @relation("UserPreferences", fields: [user_id], references: [id]) + user_id String @unique @db.Uuid + notifications Boolean @default(false) + dark_mode Boolean @default(false) + lang_switch Boolean @default(false) + lefty_mode Boolean @default(false) + + @@map("preferences") +} + + enum AttachmentStatus { ACTIVE DELETED diff --git a/src/modules/preferences/controllers/preferences.controller.ts b/src/modules/preferences/controllers/preferences.controller.ts new file mode 100644 index 0000000..ae5af16 --- /dev/null +++ b/src/modules/preferences/controllers/preferences.controller.ts @@ -0,0 +1,14 @@ +import { Body, Controller, Param, Patch } from "@nestjs/common"; +import { PreferencesService } from "../services/preferences.service"; +import { PreferencesDto } from "../dtos/preferences.dto"; + +@Controller('preferences') +export class PreferencesController { + constructor(private readonly service: PreferencesService){} + + @Patch(':email') + async updatePreferences(@Param('email') email: string, @Body()payload: PreferencesDto) { + return this.service.updatePreferences(email, payload); + } + +} \ No newline at end of file diff --git a/src/modules/preferences/dtos/preferences.dto.ts b/src/modules/preferences/dtos/preferences.dto.ts new file mode 100644 index 0000000..2bfa3e3 --- /dev/null +++ b/src/modules/preferences/dtos/preferences.dto.ts @@ -0,0 +1,16 @@ +import { IsBoolean, IsEmail, IsString } from "class-validator"; + +export class PreferencesDto { + + @IsBoolean() + notifications: boolean; + + @IsBoolean() + dark_mode: boolean; + + @IsBoolean() + lang_switch: boolean; + + @IsBoolean() + lefty_mode: boolean; +} \ No newline at end of file diff --git a/src/modules/preferences/preferences.module.ts b/src/modules/preferences/preferences.module.ts new file mode 100644 index 0000000..94161cb --- /dev/null +++ b/src/modules/preferences/preferences.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { PreferencesController } from "./controllers/preferences.controller"; +import { PreferencesService } from "./services/preferences.service"; + +@Module({ +controllers: [ PreferencesController ], +providers: [ PreferencesService ], +exports: [ PreferencesService ], +}) + +export class PreferencesModule {} \ No newline at end of file diff --git a/src/modules/preferences/services/preferences.service.ts b/src/modules/preferences/services/preferences.service.ts new file mode 100644 index 0000000..cafac84 --- /dev/null +++ b/src/modules/preferences/services/preferences.service.ts @@ -0,0 +1,32 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Preferences } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; +import { PreferencesDto } from "../dtos/preferences.dto"; + +@Injectable() +export class PreferencesService { + constructor(private readonly prisma: PrismaService){} + + async resolveUserIdWithEmail(email: string): Promise { + const user = await this.prisma.users.findFirst({ + where: { email }, + select: { id: true }, + }); + if(!user) throw new NotFoundException(`User with email ${ email } not found`); + return user.id; + } + + async updatePreferences(email: string, dto: PreferencesDto ): Promise { + const user_id = await this.resolveUserIdWithEmail(email); + return this.prisma.preferences.update({ + where: { user_id }, + data: { + notifications: dto.notifications, + dark_mode: dto.dark_mode, + lang_switch: dto.lang_switch, + lefty_mode: dto.lefty_mode, + }, + include: { user: true }, + }); + } +} \ No newline at end of file diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index 89af236..3a261ce 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -19,7 +19,6 @@ export class ShiftsController { constructor( private readonly shiftsService: ShiftsQueryService, private readonly shiftsCommandService: ShiftsCommandService, - private readonly shiftsValidationService: ShiftsQueryService, ){} @Put('upsert/:email/:date') @@ -85,14 +84,14 @@ export class ShiftsController { @Get('summary') async getSummary( @Query() query: GetShiftsOverviewDto): Promise { - return this.shiftsValidationService.getSummary(query.period_id); + return this.shiftsService.getSummary(query.period_id); } @Get('export.csv') @Header('Content-Type', 'text/csv; charset=utf-8') @Header('Content-Disposition', 'attachment; filename="shifts-validation.csv"') async exportCsv(@Query() query: GetShiftsOverviewDto): Promise{ - const rows = await this.shiftsValidationService.getSummary(query.period_id); + const rows = await this.shiftsService.getSummary(query.period_id); //CSV Headers const header = [ diff --git a/src/modules/shifts/services/shifts-query.service.ts b/src/modules/shifts/services/shifts-query.service.ts index 7bc6efe..cd1c286 100644 --- a/src/modules/shifts/services/shifts-query.service.ts +++ b/src/modules/shifts/services/shifts-query.service.ts @@ -99,10 +99,10 @@ export class ShiftsQueryService { data: { ...(timesheet_id !== undefined && { timesheet_id }), ...(bank_code_id !== undefined && { bank_code_id }), - ...(date !== undefined && { date }), - ...(start_time !== undefined && { start_time }), - ...(end_time !== undefined && { end_time }), - ...(comment !== undefined && { comment }), + ...(date !== undefined && { date }), + ...(start_time !== undefined && { start_time }), + ...(end_time !== undefined && { end_time }), + ...(comment !== undefined && { comment }), }, include: { timesheet: { include: { employee: { include: { user: true } } } }, bank_code: true, @@ -115,7 +115,7 @@ export class ShiftsQueryService { return this.prisma.shifts.delete({ where: { id } }); } - async getSummary(period_id: number): Promise { + async getSummary(period_id: number): Promise { //fetch pay-period to display const period = await this.prisma.payPeriods.findFirst({ where: { pay_period_no: period_id }, From 79153c6de3936d895cbf8286905abc37cf71045c Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 6 Oct 2025 12:15:51 -0400 Subject: [PATCH 45/69] feat(leave-requests): implementation of vacation, sick and holiday leave-requests. --- docs/swagger/swagger-spec.json | 8 +- .../services/holiday.service.ts | 2 +- .../services/sick-leave.service.ts | 55 ++- .../services/vacation.service.ts | 14 +- .../controllers/leave-requests.controller.ts | 16 +- src/modules/leave-requests/dtos/sick.dto.ts | 52 --- .../leave-requests/dtos/upsert-holiday.dto.ts | 48 --- .../dtos/upsert-leave-request.dto.ts | 51 +++ .../leave-requests/dtos/vacation.dto.ts | 52 --- .../leave-requests/leave-requests.module.ts | 19 +- .../holiday-leave-requests.service.ts | 295 ++------------- .../services/leave-request.service.ts | 339 ++++++++++++++++++ .../services/sick-leave-requests.service.ts | 105 ++++++ .../vacation-leave-requests.service.ts | 98 +++++ 14 files changed, 688 insertions(+), 466 deletions(-) delete mode 100644 src/modules/leave-requests/dtos/sick.dto.ts delete mode 100644 src/modules/leave-requests/dtos/upsert-holiday.dto.ts create mode 100644 src/modules/leave-requests/dtos/upsert-leave-request.dto.ts delete mode 100644 src/modules/leave-requests/dtos/vacation.dto.ts create mode 100644 src/modules/leave-requests/services/leave-request.service.ts create mode 100644 src/modules/leave-requests/services/sick-leave-requests.service.ts create mode 100644 src/modules/leave-requests/services/vacation-leave-requests.service.ts diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 8f2bd52..2e7fd71 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -1232,16 +1232,16 @@ ] } }, - "/leave-requests/holiday": { + "/leave-requests/upsert": { "post": { - "operationId": "LeaveRequestController_upsertHoliday", + "operationId": "LeaveRequestController_upsertLeaveRequest", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertHolidayDto" + "$ref": "#/components/schemas/UpsertLeaveRequestDto" } } } @@ -2474,7 +2474,7 @@ } } }, - "UpsertHolidayDto": { + "UpsertLeaveRequestDto": { "type": "object", "properties": {} }, diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index 8b8a31b..48e602c 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { PrismaService } from "../../../prisma/prisma.service"; import { computeHours, getWeekStart } from "src/common/utils/date-utils"; +import { PrismaService } from "../../../prisma/prisma.service"; const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000; diff --git a/src/modules/business-logics/services/sick-leave.service.ts b/src/modules/business-logics/services/sick-leave.service.ts index 6ebb3d9..6c00113 100644 --- a/src/modules/business-logics/services/sick-leave.service.ts +++ b/src/modules/business-logics/services/sick-leave.service.ts @@ -1,6 +1,6 @@ +import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils"; import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "../../../prisma/prisma.service"; -import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils"; @Injectable() export class SickLeaveService { @@ -9,28 +9,38 @@ export class SickLeaveService { private readonly logger = new Logger(SickLeaveService.name); //switch employeeId for email - async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number): - Promise { + async calculateSickLeavePay( + employee_id: number, + reference_date: Date, + days_requested: number, + hours_per_day: number, + modifier: number, + ): Promise { + if (days_requested <= 0 || hours_per_day <= 0 || modifier <= 0) { + return 0; + } + //sets the year to jan 1st to dec 31st const period_start = getYearStart(reference_date); - const period_end = reference_date; + const period_end = reference_date; //fetches all shifts of a selected employee const shifts = await this.prisma.shifts.findMany({ where: { timesheet: { employee_id: employee_id }, - date: { gte: period_start, lte: period_end}, + date: { gte: period_start, lte: period_end }, }, select: { date: true }, }); //count the amount of worked days const worked_dates = new Set( - shifts.map(shift => shift.date.toISOString().slice(0,10)) + shifts.map((shift) => shift.date.toISOString().slice(0, 10)), ); const days_worked = worked_dates.size; - this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} - -> ${period_end.toDateString()}`); + this.logger.debug( + `Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} -> ${period_end.toDateString()}`, + ); //less than 30 worked days returns 0 if (days_worked < 30) { @@ -45,22 +55,31 @@ export class SickLeaveService { const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day //calculate each completed month, starting the 1st of the next month - const first_bonus_date = new Date(threshold_date.getFullYear(), threshold_date.getMonth() +1, 1); - let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 + - (period_end.getMonth() - first_bonus_date.getMonth()) + 1; - if(months < 0) months = 0; + const first_bonus_date = new Date( + threshold_date.getFullYear(), + threshold_date.getMonth() + 1, + 1, + ); + let months = + (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 + + (period_end.getMonth() - first_bonus_date.getMonth()) + + 1; + if (months < 0) months = 0; acquired_days += months; //cap of 10 days if (acquired_days > 10) acquired_days = 10; - this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()} - , bonusMonths = ${months}, acquired Days = ${acquired_days}`); + this.logger.debug( + `Sick leave: threshold Date = ${threshold_date.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquired_days}`, + ); const payable_days = Math.min(acquired_days, days_requested); - const raw_hours = payable_days * 8 * modifier; - const rounded = roundToQuarterHour(raw_hours) - this.logger.debug(`Sick leave pay: days= ${payable_days}, modifier= ${modifier}, hours= ${rounded}`); + const raw_hours = payable_days * hours_per_day * modifier; + const rounded = roundToQuarterHour(raw_hours); + this.logger.debug( + `Sick leave pay: days= ${payable_days}, hoursPerDay= ${hours_per_day}, modifier= ${modifier}, hours= ${rounded}`, + ); return rounded; } -} \ No newline at end of file +} diff --git a/src/modules/business-logics/services/vacation.service.ts b/src/modules/business-logics/services/vacation.service.ts index f3b3447..9445149 100644 --- a/src/modules/business-logics/services/vacation.service.ts +++ b/src/modules/business-logics/services/vacation.service.ts @@ -6,16 +6,8 @@ export class VacationService { constructor(private readonly prisma: PrismaService) {} private readonly logger = new Logger(VacationService.name); - /** - * Calculate the ammount allowed for vacation days. - * - * @param employee_id employee ID - * @param startDate first day of vacation - * @param daysRequested number of days requested - * @param modifier Coefficient of hours(1) - * @returns amount of payable hours - */ - //switch employeeId for email + + //switch employeeId for email async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise { //fetch hiring date const employee = await this.prisma.employees.findUnique({ @@ -56,7 +48,7 @@ export class VacationService { const segment_end = boundaries[i+1]; //number of days in said segment - const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day); + const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day); const years_since_hire = (segment_start.getFullYear() - hire_date.getFullYear()) - (segment_start < new Date(segment_start.getFullYear(), hire_date.getMonth()) ? 1 : 0); let alloc_days: number; diff --git a/src/modules/leave-requests/controllers/leave-requests.controller.ts b/src/modules/leave-requests/controllers/leave-requests.controller.ts index 15311a5..fc934ff 100644 --- a/src/modules/leave-requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave-requests/controllers/leave-requests.controller.ts @@ -1,21 +1,21 @@ import { Body, Controller, Post } from "@nestjs/common"; -import { HolidayLeaveRequestsService } from "../services/holiday-leave-requests.service"; - import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; -import { UpsertHolidayDto } from "../dtos/upsert-holiday.dto"; +import { LeaveRequestsService } from "../services/leave-request.service"; +import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto"; +import { LeaveTypes } from "@prisma/client"; @ApiTags('Leave Requests') @ApiBearerAuth('access-token') // @UseGuards() @Controller('leave-requests') export class LeaveRequestController { - constructor(private readonly leave_service: HolidayLeaveRequestsService){} + constructor(private readonly leave_service: LeaveRequestsService){} - @Post('holiday') - async upsertHoliday(@Body() dto: UpsertHolidayDto) { - const { action, leave_requests } = await this.leave_service.handleHoliday(dto); + @Post('upsert') + async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) { + const { action, leave_requests } = await this.leave_service.handle(dto); return { action, leave_requests }; - } + }q //TODO: /* diff --git a/src/modules/leave-requests/dtos/sick.dto.ts b/src/modules/leave-requests/dtos/sick.dto.ts deleted file mode 100644 index 13d5b45..0000000 --- a/src/modules/leave-requests/dtos/sick.dto.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { Type } from "class-transformer"; -import { - ArrayNotEmpty, - ArrayUnique, - IsArray, - IsEmail, - IsISO8601, - IsNumber, - IsOptional, - IsString, - Max, - Min, -} from "class-validator"; - -export class UpsertSickDto { - @ApiProperty({ example: "jane.doe@example.com" }) - @IsEmail() - email!: string; - - @ApiProperty({ - type: [String], - example: ["2025-03-04"], - description: "ISO dates that represent the sick leave request.", - }) - @IsArray() - @ArrayNotEmpty() - @ArrayUnique() - @IsISO8601({}, { each: true }) - dates!: string[]; - - @ApiProperty({ - required: false, - example: "Medical note provided", - description: "Optional comment applied to every date.", - }) - @IsOptional() - @IsString() - comment?: string; - - @ApiProperty({ - required: false, - example: 8, - description: "Hours requested per day. Lets you keep the user input even if the calculation differs.", - }) - @IsOptional() - @Type(() => Number) - @IsNumber({ maxDecimalPlaces: 2 }) - @Min(0) - @Max(24) - requested_hours?: number; -} diff --git a/src/modules/leave-requests/dtos/upsert-holiday.dto.ts b/src/modules/leave-requests/dtos/upsert-holiday.dto.ts deleted file mode 100644 index 376b7a4..0000000 --- a/src/modules/leave-requests/dtos/upsert-holiday.dto.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { LeaveApprovalStatus } from "@prisma/client"; -import { Type } from "class-transformer"; -import { - ArrayNotEmpty, - ArrayUnique, - IsArray, - IsEmail, - IsEnum, - IsIn, - IsISO8601, - IsNumber, - IsOptional, - IsString, - Max, - Min, -} from "class-validator"; - -export const HOLIDAY_UPSERT_ACTIONS = ['create', 'update', 'delete'] as const; -export type HolidayUpsertAction = typeof HOLIDAY_UPSERT_ACTIONS[number]; - -export class UpsertHolidayDto { - @IsEmail() - email!: string; - - @IsArray() - @ArrayNotEmpty() - @ArrayUnique() - @IsISO8601({}, { each: true }) - dates!: string[]; - - @IsIn(HOLIDAY_UPSERT_ACTIONS) - action!: HolidayUpsertAction; - - @IsOptional() - @IsString() - comment?: string; - - @IsOptional() - @Type(() => Number) - @IsNumber({ maxDecimalPlaces: 2 }) - @Min(0) - @Max(24) - requested_hours?: number; - - @IsOptional() - @IsEnum(LeaveApprovalStatus) - approval_status?: LeaveApprovalStatus; -} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/upsert-leave-request.dto.ts b/src/modules/leave-requests/dtos/upsert-leave-request.dto.ts new file mode 100644 index 0000000..0f420e7 --- /dev/null +++ b/src/modules/leave-requests/dtos/upsert-leave-request.dto.ts @@ -0,0 +1,51 @@ +import { IsEmail, IsArray, ArrayNotEmpty, ArrayUnique, IsISO8601, IsIn, IsOptional, IsString, IsNumber, Min, Max, IsEnum } from "class-validator"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { LeaveRequestViewDto } from "./leave-request-view.dto"; +import { Type } from "class-transformer"; + +//sets wich function to call +export const UPSERT_ACTIONS = ['create', 'update', 'delete'] as const; +export type UpsertAction = (typeof UPSERT_ACTIONS)[number]; + +//sets wich types to use +export const REQUEST_TYPES = Object.values(LeaveTypes) as readonly LeaveTypes[]; +export type RequestTypes = (typeof REQUEST_TYPES)[number]; + +//filter requests by type and action +export interface UpsertResult { + action: UpsertAction; + leave_requests: LeaveRequestViewDto[]; +} + +export class UpsertLeaveRequestDto { + @IsEmail() + email!: string; + + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsISO8601({}, { each: true }) + dates!: string[]; + + @IsOptional() + @IsEnum(LeaveTypes) + type!: string; + + @IsIn(UPSERT_ACTIONS) + action!: UpsertAction; + + @IsOptional() + @IsString() + comment?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(24) + requested_hours?: number; + + @IsOptional() + @IsEnum(LeaveApprovalStatus) + approval_status?: LeaveApprovalStatus +} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/vacation.dto.ts b/src/modules/leave-requests/dtos/vacation.dto.ts deleted file mode 100644 index 79d558d..0000000 --- a/src/modules/leave-requests/dtos/vacation.dto.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { Type } from "class-transformer"; -import { - ArrayNotEmpty, - ArrayUnique, - IsArray, - IsEmail, - IsISO8601, - IsNumber, - IsOptional, - IsString, - Max, - Min, -} from "class-validator"; - -export class UpsertVacationDto { - @ApiProperty({ example: "jane.doe@example.com" }) - @IsEmail() - email!: string; - - @ApiProperty({ - type: [String], - example: ["2025-07-14", "2025-07-15"], - description: "ISO dates that represent the vacation request.", - }) - @IsArray() - @ArrayNotEmpty() - @ArrayUnique() - @IsISO8601({}, { each: true }) - dates!: string[]; - - @ApiProperty({ - required: false, - example: "Summer break", - description: "Optional comment applied to every date.", - }) - @IsOptional() - @IsString() - comment?: string; - - @ApiProperty({ - required: false, - example: 8, - description: "Hours requested per day. Used as default when creating shifts.", - }) - @IsOptional() - @Type(() => Number) - @IsNumber({ maxDecimalPlaces: 2 }) - @Min(0) - @Max(24) - requested_hours?: number; -} diff --git a/src/modules/leave-requests/leave-requests.module.ts b/src/modules/leave-requests/leave-requests.module.ts index 394b6be..1b52938 100644 --- a/src/modules/leave-requests/leave-requests.module.ts +++ b/src/modules/leave-requests/leave-requests.module.ts @@ -1,21 +1,26 @@ import { PrismaService } from "src/prisma/prisma.service"; -import { HolidayService } from "../business-logics/services/holiday.service"; import { LeaveRequestController } from "./controllers/leave-requests.controller"; import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service"; import { Module } from "@nestjs/common"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; -import { ShiftsCommandService } from "../shifts/services/shifts-command.service"; +import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service"; +import { SickLeaveRequestsService } from "./services/sick-leave-requests.service"; +import { LeaveRequestsService } from "./services/leave-request.service"; +import { ShiftsModule } from "../shifts/shifts.module"; @Module({ - imports: [BusinessLogicsModule], + imports: [BusinessLogicsModule, ShiftsModule], controllers: [LeaveRequestController], providers: [ - HolidayService, + VacationLeaveRequestsService, + SickLeaveRequestsService, HolidayLeaveRequestsService, - PrismaService, - ShiftsCommandService, + LeaveRequestsService, + PrismaService + ], + exports: [ + LeaveRequestsService, ], - exports: [HolidayLeaveRequestsService], }) export class LeaveRequestsModule {} \ No newline at end of file diff --git a/src/modules/leave-requests/services/holiday-leave-requests.service.ts b/src/modules/leave-requests/services/holiday-leave-requests.service.ts index c3f614d..580a190 100644 --- a/src/modules/leave-requests/services/holiday-leave-requests.service.ts +++ b/src/modules/leave-requests/services/holiday-leave-requests.service.ts @@ -1,89 +1,72 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; -import { HolidayUpsertAction, UpsertHolidayDto } from '../dtos/upsert-holiday.dto'; +import { LeaveRequestsService, normalizeDates, toDateOnly } from './leave-request.service'; +import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto'; +import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto'; +import { BadRequestException, Inject, Injectable, forwardRef } from '@nestjs/common'; +import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; import { HolidayService } from 'src/modules/business-logics/services/holiday.service'; -import { ShiftsCommandService } from 'src/modules/shifts/services/shifts-command.service'; import { PrismaService } from 'src/prisma/prisma.service'; -import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto'; import { mapRowToView } from '../mappers/leave-requests.mapper'; import { leaveRequestsSelect } from '../utils/leave-requests.select'; -interface HolidayUpsertResult { - action: HolidayUpsertAction; - leave_requests: LeaveRequestViewDto[]; -} @Injectable() export class HolidayLeaveRequestsService { constructor( - private readonly prisma: PrismaService, private readonly holidayService: HolidayService, - private readonly shiftsCommand: ShiftsCommandService, + @Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService, + private readonly prisma: PrismaService, ) {} - // --------------------------------------------------------------------- - // Public API - // --------------------------------------------------------------------- - - async handleHoliday(dto: UpsertHolidayDto): Promise { + //handle distribution to the right service according to the selected action + async handle(dto: UpsertLeaveRequestDto): Promise { switch (dto.action) { case 'create': return this.createHoliday(dto); case 'update': - return this.updateHoliday(dto); + return this.leaveService.update(dto, LeaveTypes.HOLIDAY); case 'delete': - return this.deleteHoliday(dto); + return this.leaveService.delete(dto, LeaveTypes.HOLIDAY); default: throw new BadRequestException(`Unknown action: ${dto.action}`); } } - // --------------------------------------------------------------------- - // Create - // --------------------------------------------------------------------- - - private async createHoliday(dto: UpsertHolidayDto): Promise { - const email = dto.email.trim(); - const employeeId = await this.resolveEmployeeIdByEmail(email); - const bankCode = await this.resolveHolidayBankCode(); - const dates = normalizeDates(dto.dates); - if (!dates.length) { - throw new BadRequestException('Dates array must not be empty'); - } + private async createHoliday(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.HOLIDAY); + const dates = normalizeDates(dto.dates); + if (!dates.length) throw new BadRequestException('Dates array must not be empty'); const created: LeaveRequestViewDto[] = []; - for (const isoDate of dates) { - const date = toDateOnly(isoDate); + for (const iso_date of dates) { + const date = toDateOnly(iso_date); const existing = await this.prisma.leaveRequests.findUnique({ where: { leave_per_employee_date: { - employee_id: employeeId, - leave_type: LeaveTypes.HOLIDAY, + employee_id: employee_id, + leave_type: LeaveTypes.HOLIDAY, date, }, }, select: { id: true }, }); if (existing) { - throw new BadRequestException(`Holiday request already exists for ${isoDate}`); + throw new BadRequestException(`Holiday request already exists for ${iso_date}`); } - const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier); - const row = await this.prisma.leaveRequests.create({ + const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier); + const row = await this.prisma.leaveRequests.create({ data: { - employee_id: employeeId, - bank_code_id: bankCode.id, - leave_type: LeaveTypes.HOLIDAY, + employee_id: employee_id, + bank_code_id: bank_code.id, + leave_type: LeaveTypes.HOLIDAY, date, - comment: dto.comment ?? '', + comment: dto.comment ?? '', requested_hours: dto.requested_hours ?? 8, - payable_hours: payable, + payable_hours: payable, approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, }, select: leaveRequestsSelect, @@ -91,7 +74,7 @@ export class HolidayLeaveRequestsService { const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); if (row.approval_status === LeaveApprovalStatus.APPROVED) { - await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment); + await this.leaveService.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment); } created.push({ ...mapRowToView(row), action: 'create' }); @@ -99,223 +82,5 @@ export class HolidayLeaveRequestsService { return { action: 'create', leave_requests: created }; } - - // --------------------------------------------------------------------- - // Update - // --------------------------------------------------------------------- - - private async updateHoliday(dto: UpsertHolidayDto): Promise { - const email = dto.email.trim(); - const employeeId = await this.resolveEmployeeIdByEmail(email); - const bankCode = await this.resolveHolidayBankCode(); - const dates = normalizeDates(dto.dates); - if (!dates.length) { - throw new BadRequestException('Dates array must not be empty'); - } - - const updated: LeaveRequestViewDto[] = []; - - for (const isoDate of dates) { - const date = toDateOnly(isoDate); - - const existing = await this.prisma.leaveRequests.findUnique({ - where: { - leave_per_employee_date: { - employee_id: employeeId, - leave_type: LeaveTypes.HOLIDAY, - date, - }, - }, - select: leaveRequestsSelect, - }); - if (!existing) { - throw new NotFoundException(`No HOLIDAY request found for ${isoDate}`); - } - - const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier); - const previousStatus = existing.approval_status; - - const row = await this.prisma.leaveRequests.update({ - where: { id: existing.id }, - data: { - comment: dto.comment ?? existing.comment, - requested_hours: dto.requested_hours ?? existing.requested_hours ?? 8, - payable_hours: payable, - bank_code_id: bankCode.id, - approval_status: dto.approval_status ?? existing.approval_status, - }, - select: leaveRequestsSelect, - }); - - const wasApproved = previousStatus === LeaveApprovalStatus.APPROVED; - const isApproved = row.approval_status === LeaveApprovalStatus.APPROVED; - const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); - - if (!wasApproved && isApproved) { - await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment); - } else if (wasApproved && !isApproved) { - await this.removeHolidayShift(email, employeeId, isoDate); - } else if (wasApproved && isApproved) { - await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment); - } - - updated.push({ ...mapRowToView(row), action: 'update' }); - } - - return { action: 'update', leave_requests: updated }; - } - - // --------------------------------------------------------------------- - // Delete - // --------------------------------------------------------------------- - - private async deleteHoliday(dto: UpsertHolidayDto): Promise { - const email = dto.email.trim(); - const employeeId = await this.resolveEmployeeIdByEmail(email); - const dates = normalizeDates(dto.dates); - if (!dates.length) { - throw new BadRequestException('Dates array must not be empty'); - } - - const rows = await this.prisma.leaveRequests.findMany({ - where: { - employee_id: employeeId, - leave_type: LeaveTypes.HOLIDAY, - date: { in: dates.map((d) => toDateOnly(d)) }, - }, - select: leaveRequestsSelect, - }); - - if (rows.length !== dates.length) { - const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate)); - throw new NotFoundException(`No HOLIDAY request found for: ${missing.join(', ')}`); - } - - for (const row of rows) { - if (row.approval_status === LeaveApprovalStatus.APPROVED) { - const iso = toISODateKey(row.date); - await this.removeHolidayShift(email, employeeId, iso); - } - } - - await this.prisma.leaveRequests.deleteMany({ - where: { id: { in: rows.map((row) => row.id) } }, - }); - - const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' as const})); - return { action: 'delete', leave_requests: deleted }; - } - - // --------------------------------------------------------------------- - // Shift synchronisation - // --------------------------------------------------------------------- - - private async syncHolidayShift( - email: string, - employeeId: number, - isoDate: string, - hours: number, - comment?: string, - ) { - if (hours <= 0) return; - - const durationMinutes = Math.round(hours * 60); - if (durationMinutes > 8 * 60) { - throw new BadRequestException('Holiday hours cannot exceed 8 hours.'); - } - - const startMinutes = 8 * 60; - const endMinutes = startMinutes + durationMinutes; - const toHHmm = (total: number) => `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; - - const existing = await this.prisma.shifts.findFirst({ - where: { - date: new Date(isoDate), - bank_code: { type: 'HOLIDAY' }, - timesheet: { employee_id: employeeId }, - }, - include: { bank_code: true }, - }); - - await this.shiftsCommand.upsertShiftsByDate(email, isoDate, { - old_shift: existing - ? { - start_time: existing.start_time.toISOString().slice(11, 16), - end_time: existing.end_time.toISOString().slice(11, 16), - type: existing.bank_code?.type ?? 'HOLIDAY', - is_remote: existing.is_remote, - comment: existing.comment ?? undefined, - } - : undefined, - new_shift: { - start_time: toHHmm(startMinutes), - end_time: toHHmm(endMinutes), - type: 'HOLIDAY', - is_remote: existing?.is_remote ?? false, - comment: comment ?? existing?.comment ?? '', - }, - }); - } - - private async removeHolidayShift(email: string, employeeId: number, isoDate: string) { - const existing = await this.prisma.shifts.findFirst({ - where: { - date: new Date(isoDate), - bank_code: { type: 'HOLIDAY' }, - timesheet: { employee_id: employeeId }, - }, - include: { bank_code: true }, - }); - if (!existing) return; - - await this.shiftsCommand.upsertShiftsByDate(email, isoDate, { - old_shift: { - start_time: existing.start_time.toISOString().slice(11, 16), - end_time: existing.end_time.toISOString().slice(11, 16), - type: existing.bank_code?.type ?? 'HOLIDAY', - is_remote: existing.is_remote, - comment: existing.comment ?? undefined, - }, - }); - } - - // --------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------- - - private async resolveEmployeeIdByEmail(email: string): Promise { - const employee = await this.prisma.employees.findFirst({ - where: { user: { email } }, - select: { id: true }, - }); - if (!employee) { - throw new NotFoundException(`Employee with email ${email} not found`); - } - return employee.id; - } - - private async resolveHolidayBankCode() { - const bankCode = await this.prisma.bankCodes.findFirst({ - where: { type: 'HOLIDAY' }, - select: { id: true, bank_code: true, modifier: true }, - }); - if (!bankCode) { - throw new BadRequestException('Bank code type "HOLIDAY" not found'); - } - return bankCode; - } } -const toDateOnly = (iso: string): Date => { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) { - throw new BadRequestException(`Invalid date: ${iso}`); - } - date.setHours(0, 0, 0, 0); - return date; -}; - -const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10); - -const normalizeDates = (dates: string[]): string[] => - Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso))))); diff --git a/src/modules/leave-requests/services/leave-request.service.ts b/src/modules/leave-requests/services/leave-request.service.ts new file mode 100644 index 0000000..0351c38 --- /dev/null +++ b/src/modules/leave-requests/services/leave-request.service.ts @@ -0,0 +1,339 @@ +import { BadRequestException, Inject, Injectable, NotFoundException, forwardRef } from "@nestjs/common"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { roundToQuarterHour } from "src/common/utils/date-utils"; +import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; +import { mapRowToView } from "../mappers/leave-requests.mapper"; +import { leaveRequestsSelect } from "../utils/leave-requests.select"; +import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service"; +import { SickLeaveRequestsService } from "./sick-leave-requests.service"; +import { VacationLeaveRequestsService } from "./vacation-leave-requests.service"; +import { HolidayService } from "src/modules/business-logics/services/holiday.service"; +import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; +import { VacationService } from "src/modules/business-logics/services/vacation.service"; +import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class LeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + private readonly holidayService: HolidayService, + private readonly sickLogic: SickLeaveService, + private readonly vacationLogic: VacationService, + @Inject(forwardRef(() => HolidayLeaveRequestsService)) private readonly holidayLeaveService: HolidayLeaveRequestsService, + @Inject(forwardRef(() => SickLeaveRequestsService)) private readonly sickLeaveService: SickLeaveRequestsService, + private readonly shiftsCommand: ShiftsCommandService, + @Inject(forwardRef(() => VacationLeaveRequestsService)) private readonly vacationLeaveService: VacationLeaveRequestsService, + ) {} + + async handle(dto: UpsertLeaveRequestDto): Promise { + switch (dto.type) { + case LeaveTypes.HOLIDAY: + return this.holidayLeaveService.handle(dto); + case LeaveTypes.VACATION: + return this.vacationLeaveService.handle(dto); + case LeaveTypes.SICK: + return this.sickLeaveService.handle(dto); + default: + throw new BadRequestException(`Unsupported leave type: ${dto.type}`); + } + } + + async resolveEmployeeIdByEmail(email: string): Promise { + const employee = await this.prisma.employees.findFirst({ + where: { user: { email } }, + select: { id: true }, + }); + if (!employee) { + throw new NotFoundException(`Employee with email ${email} not found`); + } + return employee.id; + } + + async resolveBankCodeByType(type: LeaveTypes) { + const bankCode = await this.prisma.bankCodes.findFirst({ + where: { type }, + select: { id: true, bank_code: true, modifier: true }, + }); + if (!bankCode) { + throw new BadRequestException(`Bank code type "${type}" not found`); + } + return bankCode; + } + + async syncShift( + email: string, + employee_id: number, + iso_date: string, + hours: number, + type: LeaveTypes, + comment?: string, + ) { + if (hours <= 0) return; + + const duration_minutes = Math.round(hours * 60); + if (duration_minutes > 8 * 60) { + throw new BadRequestException("Amount of hours cannot exceed 8 hours per day."); + } + + const start_minutes = 8 * 60; + const end_minutes = start_minutes + duration_minutes; + const toHHmm = (total: number) => + `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; + + const existing = await this.prisma.shifts.findFirst({ + where: { + date: new Date(iso_date), + bank_code: { type }, + timesheet: { employee_id: employee_id }, + }, + include: { bank_code: true }, + }); + + await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { + old_shift: existing + ? { + start_time: existing.start_time.toISOString().slice(11, 16), + end_time: existing.end_time.toISOString().slice(11, 16), + type: existing.bank_code?.type ?? type, + is_remote: existing.is_remote, + comment: existing.comment ?? undefined, + } + : undefined, + new_shift: { + start_time: toHHmm(start_minutes), + end_time: toHHmm(end_minutes), + is_remote: existing?.is_remote ?? false, + comment: comment ?? existing?.comment ?? "", + type: type, + }, + }); + } + + async removeShift( + email: string, + employee_id: number, + iso_date: string, + type: LeaveTypes, + ) { + const existing = await this.prisma.shifts.findFirst({ + where: { + date: new Date(iso_date), + bank_code: { type }, + timesheet: { employee_id: employee_id }, + }, + include: { bank_code: true }, + }); + if (!existing) return; + + await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { + old_shift: { + start_time: existing.start_time.toISOString().slice(11, 16), + end_time: existing.end_time.toISOString().slice(11, 16), + type: existing.bank_code?.type ?? type, + is_remote: existing.is_remote, + comment: existing.comment ?? undefined, + }, + }); + } + + async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise { + const email = dto.email.trim(); + const employee_id = await this.resolveEmployeeIdByEmail(email); + const dates = normalizeDates(dto.dates); + if (!dates.length) throw new BadRequestException("Dates array must not be empty"); + + const rows = await this.prisma.leaveRequests.findMany({ + where: { + employee_id: employee_id, + leave_type: type, + date: { in: dates.map((d) => toDateOnly(d)) }, + }, + select: leaveRequestsSelect, + }); + + if (rows.length !== dates.length) { + const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate)); + throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`); + } + + for (const row of rows) { + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + const iso = toISODateKey(row.date); + await this.removeShift(email, employee_id, iso, type); + } + } + + await this.prisma.leaveRequests.deleteMany({ + where: { id: { in: rows.map((row) => row.id) } }, + }); + + const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const })); + return { action: "delete", leave_requests: deleted }; + } + + async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise { + const email = dto.email.trim(); + const employee_id = await this.resolveEmployeeIdByEmail(email); + const bank_code = await this.resolveBankCodeByType(type); + const modifier = Number(bank_code.modifier ?? 1); + const dates = normalizeDates(dto.dates); + if (!dates.length) { + throw new BadRequestException("Dates array must not be empty"); + } + + const entries = await Promise.all( + dates.map(async (iso_date) => { + const date = toDateOnly(iso_date); + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employee_id, + leave_type: type, + date, + }, + }, + select: leaveRequestsSelect, + }); + if (!existing) { + throw new NotFoundException(`No Leave request found for ${iso_date}`); + } + return { iso_date, date, existing }; + }), + ); + + const updated: LeaveRequestViewDto[] = []; + + if (type === LeaveTypes.SICK) { + const firstExisting = entries[0].existing; + const fallbackRequested = + firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined + ? Number(firstExisting.requested_hours) + : 8; + const requested_hours_per_day = dto.requested_hours ?? fallbackRequested; + const reference_date = entries.reduce( + (latest, entry) => (entry.date > latest ? entry.date : latest), + entries[0].date, + ); + const total_payable_hours = await this.sickLogic.calculateSickLeavePay( + employee_id, + reference_date, + entries.length, + requested_hours_per_day, + modifier, + ); + let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); + const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); + + for (const { iso_date, existing } of entries) { + const previous_status = existing.approval_status; + const payable = Math.min(remaining_payable_hours, daily_payable_cap); + const payable_rounded = roundToQuarterHour(Math.max(0, payable)); + remaining_payable_hours = roundToQuarterHour( + Math.max(0, remaining_payable_hours - payable_rounded), + ); + + const row = await this.prisma.leaveRequests.update({ + where: { id: existing.id }, + data: { + comment: dto.comment ?? existing.comment, + requested_hours: requested_hours_per_day, + payable_hours: payable_rounded, + bank_code_id: bank_code.id, + approval_status: dto.approval_status ?? existing.approval_status, + }, + select: leaveRequestsSelect, + }); + + const was_approved = previous_status === LeaveApprovalStatus.APPROVED; + const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + + if (!was_approved && is_approved) { + await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } else if (was_approved && !is_approved) { + await this.removeShift(email, employee_id, iso_date, type); + } else if (was_approved && is_approved) { + await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } + + updated.push({ ...mapRowToView(row), action: "update" }); + } + + return { action: "update", leave_requests: updated }; + } + + for (const { iso_date, date, existing } of entries) { + const previous_status = existing.approval_status; + const fallbackRequested = + existing.requested_hours !== null && existing.requested_hours !== undefined + ? Number(existing.requested_hours) + : 8; + const requested_hours = dto.requested_hours ?? fallbackRequested; + + let payable: number; + switch (type) { + case LeaveTypes.HOLIDAY: + payable = await this.holidayService.calculateHolidayPay(email, date, modifier); + break; + case LeaveTypes.VACATION: { + const days_requested = requested_hours / 8; + payable = await this.vacationLogic.calculateVacationPay( + employee_id, + date, + Math.max(0, days_requested), + modifier, + ); + break; + } + default: + payable = existing.payable_hours !== null && existing.payable_hours !== undefined + ? Number(existing.payable_hours) + : requested_hours; + } + + const row = await this.prisma.leaveRequests.update({ + where: { id: existing.id }, + data: { + comment: dto.comment ?? existing.comment, + requested_hours, + payable_hours: payable, + bank_code_id: bank_code.id, + approval_status: dto.approval_status ?? existing.approval_status, + }, + select: leaveRequestsSelect, + }); + + const was_approved = previous_status === LeaveApprovalStatus.APPROVED; + const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + + if (!was_approved && is_approved) { + await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } else if (was_approved && !is_approved) { + await this.removeShift(email, employee_id, iso_date, type); + } else if (was_approved && is_approved) { + await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } + + updated.push({ ...mapRowToView(row), action: "update" }); + } + + return { action: "update", leave_requests: updated }; + } +} + +export const toDateOnly = (iso: string): Date => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException(`Invalid date: ${iso}`); + } + date.setHours(0, 0, 0, 0); + return date; +}; + +export const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10); + +export const normalizeDates = (dates: string[]): string[] => + Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso))))); diff --git a/src/modules/leave-requests/services/sick-leave-requests.service.ts b/src/modules/leave-requests/services/sick-leave-requests.service.ts new file mode 100644 index 0000000..e489c56 --- /dev/null +++ b/src/modules/leave-requests/services/sick-leave-requests.service.ts @@ -0,0 +1,105 @@ +import { LeaveRequestsService, normalizeDates, toDateOnly } from "./leave-request.service"; +import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; +import { BadRequestException, Inject, Injectable, forwardRef } from "@nestjs/common"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { leaveRequestsSelect } from "../utils/leave-requests.select"; +import { mapRowToView } from "../mappers/leave-requests.mapper"; +import { PrismaService } from "src/prisma/prisma.service"; +import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; +import { roundToQuarterHour } from "src/common/utils/date-utils"; + +@Injectable() +export class SickLeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + @Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService, + private readonly sickService: SickLeaveService, + ) {} + + //handle distribution to the right service according to the selected action + async handle(dto: UpsertLeaveRequestDto): Promise { + switch (dto.action) { + case "create": + return this.createSick(dto); + case "update": + return this.leaveService.update(dto, LeaveTypes.SICK); + case "delete": + return this.leaveService.delete(dto, LeaveTypes.SICK); + default: + throw new BadRequestException(`Unknown action: ${dto.action}`); + } + } + + private async createSick(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.SICK); + const modifier = bank_code.modifier ?? 1; + const dates = normalizeDates(dto.dates); + if (!dates.length) throw new BadRequestException("Dates array must not be empty"); + const requested_hours_per_day = dto.requested_hours ?? 8; + + const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) })); + const reference_date = entries.reduce( + (latest, entry) => (entry.date > latest ? entry.date : latest), + entries[0].date, + ); + const total_payable_hours = await this.sickService.calculateSickLeavePay( + employee_id, + reference_date, + entries.length, + requested_hours_per_day, + modifier, + ); + let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); + const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); + + const created: LeaveRequestViewDto[] = []; + + for (const { iso, date } of entries) { + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employee_id, + leave_type: LeaveTypes.SICK, + date, + }, + }, + select: { id: true }, + }); + if (existing) { + throw new BadRequestException(`Sick request already exists for ${iso}`); + } + + const payable = Math.min(remaining_payable_hours, daily_payable_cap); + const payable_rounded = roundToQuarterHour(Math.max(0, payable)); + remaining_payable_hours = roundToQuarterHour( + Math.max(0, remaining_payable_hours - payable_rounded), + ); + + const row = await this.prisma.leaveRequests.create({ + data: { + employee_id: employee_id, + bank_code_id: bank_code.id, + leave_type: LeaveTypes.SICK, + comment: dto.comment ?? "", + requested_hours: requested_hours_per_day, + payable_hours: payable_rounded, + approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, + date, + }, + select: leaveRequestsSelect, + }); + + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + await this.leaveService.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment); + } + + created.push({ ...mapRowToView(row), action: "create" }); + } + + return { action: "create", leave_requests: created }; + } +} diff --git a/src/modules/leave-requests/services/vacation-leave-requests.service.ts b/src/modules/leave-requests/services/vacation-leave-requests.service.ts new file mode 100644 index 0000000..8128862 --- /dev/null +++ b/src/modules/leave-requests/services/vacation-leave-requests.service.ts @@ -0,0 +1,98 @@ +import { LeaveRequestsService, normalizeDates, toDateOnly } from "./leave-request.service"; +import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; +import { BadRequestException, Inject, Injectable, forwardRef } from "@nestjs/common"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { VacationService } from "src/modules/business-logics/services/vacation.service"; +import { PrismaService } from "src/prisma/prisma.service"; +import { mapRowToView } from "../mappers/leave-requests.mapper"; +import { leaveRequestsSelect } from "../utils/leave-requests.select"; +import { roundToQuarterHour } from "src/common/utils/date-utils"; + +@Injectable() +export class VacationLeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + @Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService, + private readonly vacationService: VacationService, + ) {} + + async handle(dto: UpsertLeaveRequestDto): Promise { + switch (dto.action) { + case "create": + return this.createVacation(dto); + case "update": + return this.leaveService.update(dto, LeaveTypes.VACATION); + case "delete": + return this.leaveService.delete(dto, LeaveTypes.VACATION); + default: + throw new BadRequestException(`Unknown action: ${dto.action}`); + } + } + + private async createVacation(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.VACATION); + const modifier = bank_code.modifier ?? 1; + const dates = normalizeDates(dto.dates); + const requested_hours_per_day = dto.requested_hours ?? 8; + if (!dates.length) throw new BadRequestException("Dates array must not be empty"); + + const entries = dates + .map((iso) => ({ iso, date: toDateOnly(iso) })) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + const start_date = entries[0].date; + const total_payable_hours = await this.vacationService.calculateVacationPay( + employee_id, + start_date, + entries.length, + modifier, + ); + let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); + const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); + + const created: LeaveRequestViewDto[] = []; + + for (const { iso, date } of entries) { + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employee_id, + leave_type: LeaveTypes.VACATION, + date, + }, + }, + select: { id: true }, + }); + if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`); + + const payable = Math.min(remaining_payable_hours, daily_payable_cap); + const payable_rounded = roundToQuarterHour(Math.max(0, payable)); + remaining_payable_hours = roundToQuarterHour( + Math.max(0, remaining_payable_hours - payable_rounded), + ); + + const row = await this.prisma.leaveRequests.create({ + data: { + employee_id: employee_id, + bank_code_id: bank_code.id, + payable_hours: payable_rounded, + requested_hours: requested_hours_per_day, + leave_type: LeaveTypes.VACATION, + comment: dto.comment ?? "", + approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, + date, + }, + select: leaveRequestsSelect, + }); + + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + await this.leaveService.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment); + } + created.push({ ...mapRowToView(row), action: "create" }); + } + return { action: "create", leave_requests: created }; + } +} From caf03d8d682e6dc9673479f1e59df06f82c7e0dc Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 6 Oct 2025 14:00:25 -0400 Subject: [PATCH 46/69] fix(leave-requests): fix depencies for the Leave-Requests Module --- .../leave-requests/leave-requests.module.ts | 4 +- .../holiday-leave-requests.service.ts | 31 +-- .../services/leave-request.service.ts | 228 +++++------------- .../services/sick-leave-requests.service.ts | 32 +-- .../vacation-leave-requests.service.ts | 30 +-- .../utils/leave-request.util.ts | 124 ++++++++++ 6 files changed, 222 insertions(+), 227 deletions(-) create mode 100644 src/modules/leave-requests/utils/leave-request.util.ts diff --git a/src/modules/leave-requests/leave-requests.module.ts b/src/modules/leave-requests/leave-requests.module.ts index 1b52938..8959e30 100644 --- a/src/modules/leave-requests/leave-requests.module.ts +++ b/src/modules/leave-requests/leave-requests.module.ts @@ -7,6 +7,7 @@ import { VacationLeaveRequestsService } from "./services/vacation-leave-requests import { SickLeaveRequestsService } from "./services/sick-leave-requests.service"; import { LeaveRequestsService } from "./services/leave-request.service"; import { ShiftsModule } from "../shifts/shifts.module"; +import { LeaveRequestsUtils } from "./utils/leave-request.util"; @Module({ imports: [BusinessLogicsModule, ShiftsModule], @@ -16,7 +17,8 @@ import { ShiftsModule } from "../shifts/shifts.module"; SickLeaveRequestsService, HolidayLeaveRequestsService, LeaveRequestsService, - PrismaService + PrismaService, + LeaveRequestsUtils, ], exports: [ LeaveRequestsService, diff --git a/src/modules/leave-requests/services/holiday-leave-requests.service.ts b/src/modules/leave-requests/services/holiday-leave-requests.service.ts index 580a190..025833c 100644 --- a/src/modules/leave-requests/services/holiday-leave-requests.service.ts +++ b/src/modules/leave-requests/services/holiday-leave-requests.service.ts @@ -1,40 +1,27 @@ -import { LeaveRequestsService, normalizeDates, toDateOnly } from './leave-request.service'; import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto'; import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto'; -import { BadRequestException, Inject, Injectable, forwardRef } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; import { HolidayService } from 'src/modules/business-logics/services/holiday.service'; import { PrismaService } from 'src/prisma/prisma.service'; import { mapRowToView } from '../mappers/leave-requests.mapper'; import { leaveRequestsSelect } from '../utils/leave-requests.select'; +import { LeaveRequestsUtils, normalizeDates, toDateOnly } from '../utils/leave-request.util'; @Injectable() export class HolidayLeaveRequestsService { constructor( - private readonly holidayService: HolidayService, - @Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService, private readonly prisma: PrismaService, + private readonly holidayService: HolidayService, + private readonly leaveUtils: LeaveRequestsUtils, ) {} - //handle distribution to the right service according to the selected action - async handle(dto: UpsertLeaveRequestDto): Promise { - switch (dto.action) { - case 'create': - return this.createHoliday(dto); - case 'update': - return this.leaveService.update(dto, LeaveTypes.HOLIDAY); - case 'delete': - return this.leaveService.delete(dto, LeaveTypes.HOLIDAY); - default: - throw new BadRequestException(`Unknown action: ${dto.action}`); - } - } - - private async createHoliday(dto: UpsertLeaveRequestDto): Promise { + async create(dto: UpsertLeaveRequestDto): Promise { const email = dto.email.trim(); - const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email); - const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.HOLIDAY); + const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveUtils.resolveBankCodeByType(LeaveTypes.HOLIDAY); + if(!bank_code) throw new NotFoundException(`bank_code not found`); const dates = normalizeDates(dto.dates); if (!dates.length) throw new BadRequestException('Dates array must not be empty'); @@ -74,7 +61,7 @@ export class HolidayLeaveRequestsService { const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); if (row.approval_status === LeaveApprovalStatus.APPROVED) { - await this.leaveService.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment); + await this.leaveUtils.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment); } created.push({ ...mapRowToView(row), action: 'create' }); diff --git a/src/modules/leave-requests/services/leave-request.service.ts b/src/modules/leave-requests/services/leave-request.service.ts index 0351c38..d7e3239 100644 --- a/src/modules/leave-requests/services/leave-request.service.ts +++ b/src/modules/leave-requests/services/leave-request.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject, Injectable, NotFoundException, forwardRef } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; import { roundToQuarterHour } from "src/common/utils/date-utils"; import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; @@ -11,137 +11,58 @@ import { VacationLeaveRequestsService } from "./vacation-leave-requests.service" import { HolidayService } from "src/modules/business-logics/services/holiday.service"; import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; import { VacationService } from "src/modules/business-logics/services/vacation.service"; -import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service"; import { PrismaService } from "src/prisma/prisma.service"; +import { LeaveRequestsUtils, normalizeDates, toDateOnly, toISODateKey } from "../utils/leave-request.util"; @Injectable() export class LeaveRequestsService { constructor( - private readonly prisma: PrismaService, - private readonly holidayService: HolidayService, - private readonly sickLogic: SickLeaveService, - private readonly vacationLogic: VacationService, - @Inject(forwardRef(() => HolidayLeaveRequestsService)) private readonly holidayLeaveService: HolidayLeaveRequestsService, - @Inject(forwardRef(() => SickLeaveRequestsService)) private readonly sickLeaveService: SickLeaveRequestsService, - private readonly shiftsCommand: ShiftsCommandService, - @Inject(forwardRef(() => VacationLeaveRequestsService)) private readonly vacationLeaveService: VacationLeaveRequestsService, + private readonly prisma: PrismaService, + private readonly holidayLeaveService: HolidayLeaveRequestsService, + private readonly holidayService: HolidayService, + private readonly sickLogic: SickLeaveService, + private readonly sickLeaveService: SickLeaveRequestsService, + private readonly vacationLeaveService: VacationLeaveRequestsService, + private readonly vacationLogic: VacationService, + private readonly leaveUtils: LeaveRequestsUtils, ) {} + //handle distribution to the right service according to the selected type and action async handle(dto: UpsertLeaveRequestDto): Promise { switch (dto.type) { case LeaveTypes.HOLIDAY: - return this.holidayLeaveService.handle(dto); + if( dto.action === 'create'){ + return this.holidayLeaveService.create(dto); + } else if (dto.action === 'update') { + return this.update(dto, LeaveTypes.HOLIDAY); + } else if (dto.action === 'delete'){ + return this.delete(dto, LeaveTypes.HOLIDAY); + } case LeaveTypes.VACATION: - return this.vacationLeaveService.handle(dto); + if( dto.action === 'create'){ + return this.vacationLeaveService.create(dto); + } else if (dto.action === 'update') { + return this.update(dto, LeaveTypes.VACATION); + } else if (dto.action === 'delete'){ + return this.delete(dto, LeaveTypes.VACATION); + } case LeaveTypes.SICK: - return this.sickLeaveService.handle(dto); + if( dto.action === 'create'){ + return this.sickLeaveService.create(dto); + } else if (dto.action === 'update') { + return this.update(dto, LeaveTypes.SICK); + } else if (dto.action === 'delete'){ + return this.delete(dto, LeaveTypes.SICK); + } default: - throw new BadRequestException(`Unsupported leave type: ${dto.type}`); + throw new BadRequestException(`Unsupported leave type: ${dto.type} or action: ${dto.action}`); } } - async resolveEmployeeIdByEmail(email: string): Promise { - const employee = await this.prisma.employees.findFirst({ - where: { user: { email } }, - select: { id: true }, - }); - if (!employee) { - throw new NotFoundException(`Employee with email ${email} not found`); - } - return employee.id; - } - - async resolveBankCodeByType(type: LeaveTypes) { - const bankCode = await this.prisma.bankCodes.findFirst({ - where: { type }, - select: { id: true, bank_code: true, modifier: true }, - }); - if (!bankCode) { - throw new BadRequestException(`Bank code type "${type}" not found`); - } - return bankCode; - } - - async syncShift( - email: string, - employee_id: number, - iso_date: string, - hours: number, - type: LeaveTypes, - comment?: string, - ) { - if (hours <= 0) return; - - const duration_minutes = Math.round(hours * 60); - if (duration_minutes > 8 * 60) { - throw new BadRequestException("Amount of hours cannot exceed 8 hours per day."); - } - - const start_minutes = 8 * 60; - const end_minutes = start_minutes + duration_minutes; - const toHHmm = (total: number) => - `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; - - const existing = await this.prisma.shifts.findFirst({ - where: { - date: new Date(iso_date), - bank_code: { type }, - timesheet: { employee_id: employee_id }, - }, - include: { bank_code: true }, - }); - - await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { - old_shift: existing - ? { - start_time: existing.start_time.toISOString().slice(11, 16), - end_time: existing.end_time.toISOString().slice(11, 16), - type: existing.bank_code?.type ?? type, - is_remote: existing.is_remote, - comment: existing.comment ?? undefined, - } - : undefined, - new_shift: { - start_time: toHHmm(start_minutes), - end_time: toHHmm(end_minutes), - is_remote: existing?.is_remote ?? false, - comment: comment ?? existing?.comment ?? "", - type: type, - }, - }); - } - - async removeShift( - email: string, - employee_id: number, - iso_date: string, - type: LeaveTypes, - ) { - const existing = await this.prisma.shifts.findFirst({ - where: { - date: new Date(iso_date), - bank_code: { type }, - timesheet: { employee_id: employee_id }, - }, - include: { bank_code: true }, - }); - if (!existing) return; - - await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { - old_shift: { - start_time: existing.start_time.toISOString().slice(11, 16), - end_time: existing.end_time.toISOString().slice(11, 16), - type: existing.bank_code?.type ?? type, - is_remote: existing.is_remote, - comment: existing.comment ?? undefined, - }, - }); - } - async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise { - const email = dto.email.trim(); - const employee_id = await this.resolveEmployeeIdByEmail(email); - const dates = normalizeDates(dto.dates); + const email = dto.email.trim(); + const dates = normalizeDates(dto.dates); + const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); if (!dates.length) throw new BadRequestException("Dates array must not be empty"); const rows = await this.prisma.leaveRequests.findMany({ @@ -161,7 +82,7 @@ export class LeaveRequestsService { for (const row of rows) { if (row.approval_status === LeaveApprovalStatus.APPROVED) { const iso = toISODateKey(row.date); - await this.removeShift(email, employee_id, iso, type); + await this.leaveUtils.removeShift(email, employee_id, iso, type); } } @@ -174,31 +95,30 @@ export class LeaveRequestsService { } async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise { - const email = dto.email.trim(); - const employee_id = await this.resolveEmployeeIdByEmail(email); - const bank_code = await this.resolveBankCodeByType(type); - const modifier = Number(bank_code.modifier ?? 1); - const dates = normalizeDates(dto.dates); + const email = dto.email.trim(); + const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveUtils.resolveBankCodeByType(type); + if(!bank_code) throw new NotFoundException(`bank_code not found`); + const modifier = Number(bank_code.modifier ?? 1); + const dates = normalizeDates(dto.dates); if (!dates.length) { throw new BadRequestException("Dates array must not be empty"); } const entries = await Promise.all( dates.map(async (iso_date) => { - const date = toDateOnly(iso_date); + const date = toDateOnly(iso_date); const existing = await this.prisma.leaveRequests.findUnique({ where: { leave_per_employee_date: { employee_id: employee_id, - leave_type: type, + leave_type: type, date, }, }, select: leaveRequestsSelect, }); - if (!existing) { - throw new NotFoundException(`No Leave request found for ${iso_date}`); - } + if (!existing) throw new NotFoundException(`No Leave request found for ${iso_date}`); return { iso_date, date, existing }; }), ); @@ -212,7 +132,7 @@ export class LeaveRequestsService { ? Number(firstExisting.requested_hours) : 8; const requested_hours_per_day = dto.requested_hours ?? fallbackRequested; - const reference_date = entries.reduce( + const reference_date = entries.reduce( (latest, entry) => (entry.date > latest ? entry.date : latest), entries[0].date, ); @@ -227,9 +147,9 @@ export class LeaveRequestsService { const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); for (const { iso_date, existing } of entries) { - const previous_status = existing.approval_status; - const payable = Math.min(remaining_payable_hours, daily_payable_cap); - const payable_rounded = roundToQuarterHour(Math.max(0, payable)); + const previous_status = existing.approval_status; + const payable = Math.min(remaining_payable_hours, daily_payable_cap); + const payable_rounded = roundToQuarterHour(Math.max(0, payable)); remaining_payable_hours = roundToQuarterHour( Math.max(0, remaining_payable_hours - payable_rounded), ); @@ -237,30 +157,28 @@ export class LeaveRequestsService { const row = await this.prisma.leaveRequests.update({ where: { id: existing.id }, data: { - comment: dto.comment ?? existing.comment, + comment: dto.comment ?? existing.comment, requested_hours: requested_hours_per_day, - payable_hours: payable_rounded, - bank_code_id: bank_code.id, + payable_hours: payable_rounded, + bank_code_id: bank_code.id, approval_status: dto.approval_status ?? existing.approval_status, }, select: leaveRequestsSelect, }); const was_approved = previous_status === LeaveApprovalStatus.APPROVED; - const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; - const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); if (!was_approved && is_approved) { - await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); } else if (was_approved && !is_approved) { - await this.removeShift(email, employee_id, iso_date, type); + await this.leaveUtils.removeShift(email, employee_id, iso_date, type); } else if (was_approved && is_approved) { - await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); } - updated.push({ ...mapRowToView(row), action: "update" }); } - return { action: "update", leave_requests: updated }; } @@ -296,44 +214,30 @@ export class LeaveRequestsService { const row = await this.prisma.leaveRequests.update({ where: { id: existing.id }, data: { - comment: dto.comment ?? existing.comment, requested_hours, - payable_hours: payable, - bank_code_id: bank_code.id, + comment: dto.comment ?? existing.comment, + payable_hours: payable, + bank_code_id: bank_code.id, approval_status: dto.approval_status ?? existing.approval_status, }, select: leaveRequestsSelect, }); const was_approved = previous_status === LeaveApprovalStatus.APPROVED; - const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; - const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); if (!was_approved && is_approved) { - await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); } else if (was_approved && !is_approved) { - await this.removeShift(email, employee_id, iso_date, type); + await this.leaveUtils.removeShift(email, employee_id, iso_date, type); } else if (was_approved && is_approved) { - await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); } - updated.push({ ...mapRowToView(row), action: "update" }); } - return { action: "update", leave_requests: updated }; } } -export const toDateOnly = (iso: string): Date => { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) { - throw new BadRequestException(`Invalid date: ${iso}`); - } - date.setHours(0, 0, 0, 0); - return date; -}; -export const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10); - -export const normalizeDates = (dates: string[]): string[] => - Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso))))); diff --git a/src/modules/leave-requests/services/sick-leave-requests.service.ts b/src/modules/leave-requests/services/sick-leave-requests.service.ts index e489c56..cde2013 100644 --- a/src/modules/leave-requests/services/sick-leave-requests.service.ts +++ b/src/modules/leave-requests/services/sick-leave-requests.service.ts @@ -1,40 +1,28 @@ -import { LeaveRequestsService, normalizeDates, toDateOnly } from "./leave-request.service"; -import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; +import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; -import { BadRequestException, Inject, Injectable, forwardRef } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; import { leaveRequestsSelect } from "../utils/leave-requests.select"; import { mapRowToView } from "../mappers/leave-requests.mapper"; import { PrismaService } from "src/prisma/prisma.service"; import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; import { roundToQuarterHour } from "src/common/utils/date-utils"; +import { LeaveRequestsUtils, normalizeDates, toDateOnly } from "../utils/leave-request.util"; @Injectable() export class SickLeaveRequestsService { constructor( private readonly prisma: PrismaService, - @Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService, private readonly sickService: SickLeaveService, + private readonly leaveUtils: LeaveRequestsUtils, ) {} - //handle distribution to the right service according to the selected action - async handle(dto: UpsertLeaveRequestDto): Promise { - switch (dto.action) { - case "create": - return this.createSick(dto); - case "update": - return this.leaveService.update(dto, LeaveTypes.SICK); - case "delete": - return this.leaveService.delete(dto, LeaveTypes.SICK); - default: - throw new BadRequestException(`Unknown action: ${dto.action}`); - } - } - - private async createSick(dto: UpsertLeaveRequestDto): Promise { + async create(dto: UpsertLeaveRequestDto): Promise { const email = dto.email.trim(); - const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email); - const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.SICK); + const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveUtils.resolveBankCodeByType(LeaveTypes.SICK); + if(!bank_code) throw new NotFoundException(`bank_code not found`); + const modifier = bank_code.modifier ?? 1; const dates = normalizeDates(dto.dates); if (!dates.length) throw new BadRequestException("Dates array must not be empty"); @@ -94,7 +82,7 @@ export class SickLeaveRequestsService { const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); if (row.approval_status === LeaveApprovalStatus.APPROVED) { - await this.leaveService.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment); + await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment); } created.push({ ...mapRowToView(row), action: "create" }); diff --git a/src/modules/leave-requests/services/vacation-leave-requests.service.ts b/src/modules/leave-requests/services/vacation-leave-requests.service.ts index 8128862..31d1081 100644 --- a/src/modules/leave-requests/services/vacation-leave-requests.service.ts +++ b/src/modules/leave-requests/services/vacation-leave-requests.service.ts @@ -1,39 +1,29 @@ -import { LeaveRequestsService, normalizeDates, toDateOnly } from "./leave-request.service"; + import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; -import { BadRequestException, Inject, Injectable, forwardRef } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; import { VacationService } from "src/modules/business-logics/services/vacation.service"; import { PrismaService } from "src/prisma/prisma.service"; import { mapRowToView } from "../mappers/leave-requests.mapper"; import { leaveRequestsSelect } from "../utils/leave-requests.select"; import { roundToQuarterHour } from "src/common/utils/date-utils"; +import { LeaveRequestsUtils, normalizeDates, toDateOnly } from "../utils/leave-request.util"; @Injectable() export class VacationLeaveRequestsService { constructor( private readonly prisma: PrismaService, - @Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService, private readonly vacationService: VacationService, + private readonly leaveUtils: LeaveRequestsUtils, ) {} - async handle(dto: UpsertLeaveRequestDto): Promise { - switch (dto.action) { - case "create": - return this.createVacation(dto); - case "update": - return this.leaveService.update(dto, LeaveTypes.VACATION); - case "delete": - return this.leaveService.delete(dto, LeaveTypes.VACATION); - default: - throw new BadRequestException(`Unknown action: ${dto.action}`); - } - } - - private async createVacation(dto: UpsertLeaveRequestDto): Promise { + async create(dto: UpsertLeaveRequestDto): Promise { const email = dto.email.trim(); - const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email); - const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.VACATION); + const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveUtils.resolveBankCodeByType(LeaveTypes.VACATION); + if(!bank_code) throw new NotFoundException(`bank_code not found`); + const modifier = bank_code.modifier ?? 1; const dates = normalizeDates(dto.dates); const requested_hours_per_day = dto.requested_hours ?? 8; @@ -89,7 +79,7 @@ export class VacationLeaveRequestsService { const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); if (row.approval_status === LeaveApprovalStatus.APPROVED) { - await this.leaveService.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment); + await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment); } created.push({ ...mapRowToView(row), action: "create" }); } diff --git a/src/modules/leave-requests/utils/leave-request.util.ts b/src/modules/leave-requests/utils/leave-request.util.ts new file mode 100644 index 0000000..746a568 --- /dev/null +++ b/src/modules/leave-requests/utils/leave-request.util.ts @@ -0,0 +1,124 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { LeaveTypes } from "@prisma/client"; +import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class LeaveRequestsUtils { + constructor( + private readonly prisma: PrismaService, + private readonly shiftsCommand: ShiftsCommandService, + ){} + + async resolveEmployeeIdByEmail(email: string): Promise { + const employee = await this.prisma.employees.findFirst({ + where: { user: { email } }, + select: { id: true }, + }); + if (!employee) { + throw new NotFoundException(`Employee with email ${email} not found`); + } + return employee.id; + } + + async resolveBankCodeByType(type: LeaveTypes) { + const bankCode = await this.prisma.bankCodes.findFirst({ + where: { type }, + select: { id: true, bank_code: true, modifier: true }, + }); + if (!bankCode) throw new BadRequestException(`Bank code type "${type}" not found`); + return bankCode; + } + + async syncShift( + email: string, + employee_id: number, + iso_date: string, + hours: number, + type: LeaveTypes, + comment?: string, + ) { + if (hours <= 0) return; + + const duration_minutes = Math.round(hours * 60); + if (duration_minutes > 8 * 60) { + throw new BadRequestException("Amount of hours cannot exceed 8 hours per day."); + } + + const start_minutes = 8 * 60; + const end_minutes = start_minutes + duration_minutes; + const toHHmm = (total: number) => + `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; + + const existing = await this.prisma.shifts.findFirst({ + where: { + date: new Date(iso_date), + bank_code: { type }, + timesheet: { employee_id: employee_id }, + }, + include: { bank_code: true }, + }); + + await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { + old_shift: existing + ? { + start_time: existing.start_time.toISOString().slice(11, 16), + end_time: existing.end_time.toISOString().slice(11, 16), + type: existing.bank_code?.type ?? type, + is_remote: existing.is_remote, + comment: existing.comment ?? undefined, + } + : undefined, + new_shift: { + start_time: toHHmm(start_minutes), + end_time: toHHmm(end_minutes), + is_remote: existing?.is_remote ?? false, + comment: comment ?? existing?.comment ?? "", + type: type, + }, + }); + } + + async removeShift( + email: string, + employee_id: number, + iso_date: string, + type: LeaveTypes, + ) { + const existing = await this.prisma.shifts.findFirst({ + where: { + date: new Date(iso_date), + bank_code: { type }, + timesheet: { employee_id: employee_id }, + }, + include: { bank_code: true }, + }); + if (!existing) return; + + await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { + old_shift: { + start_time: existing.start_time.toISOString().slice(11, 16), + end_time: existing.end_time.toISOString().slice(11, 16), + type: existing.bank_code?.type ?? type, + is_remote: existing.is_remote, + comment: existing.comment ?? undefined, + }, + }); + } + +} + + +export const toDateOnly = (iso: string): Date => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException(`Invalid date: ${iso}`); + } + date.setHours(0, 0, 0, 0); + return date; +}; + +export const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10); + +export const normalizeDates = (dates: string[]): string[] => + Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso))))); \ No newline at end of file From 79c5bec0ee705cff269056370dd6908d06bda496 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 6 Oct 2025 15:44:07 -0400 Subject: [PATCH 47/69] fix(controller): commented deprecated or unsued methods of controllers --- docs/swagger/swagger-spec.json | 1350 ----------------- .../controllers/bank-codes.controller.ts | 65 +- .../controllers/customers.controller.ts | 90 +- .../controllers/employees.controller.ts | 82 +- .../controllers/expenses.controller.ts | 96 +- .../controllers/pay-periods.controller.ts | 19 +- .../shifts/controllers/shifts.controller.ts | 97 +- .../controllers/timesheets.controller.ts | 49 +- 8 files changed, 265 insertions(+), 1583 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 2e7fd71..e8045ac 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -163,37 +163,6 @@ "tags": [ "Employees" ] - }, - "get": { - "operationId": "EmployeesController_findAll", - "parameters": [], - "responses": { - "200": { - "description": "List of employees found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateEmployeeDto" - } - } - } - } - }, - "400": { - "description": "List of employees not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find all employees", - "tags": [ - "Employees" - ] } }, "/employees/employee-list": { @@ -230,74 +199,6 @@ } }, "/employees/{email}": { - "get": { - "operationId": "EmployeesController_findOne", - "parameters": [ - { - "name": "email", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Employee found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateEmployeeDto" - } - } - } - }, - "400": { - "description": "Employee not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find employee", - "tags": [ - "Employees" - ] - }, - "delete": { - "operationId": "EmployeesController_remove", - "parameters": [ - { - "name": "email", - "required": true, - "in": "path", - "description": "Email of the employee to delete", - "schema": { - "type": "number" - } - } - ], - "responses": { - "204": { - "description": "Employee deleted" - }, - "404": { - "description": "Employee not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete employee", - "tags": [ - "Employees" - ] - }, "patch": { "operationId": "EmployeesController_updateOrArchiveOrRestore", "parameters": [ @@ -360,46 +261,6 @@ ] } }, - "/employees/profile/{email}": { - "get": { - "operationId": "EmployeesController_findOneProfile", - "parameters": [ - { - "name": "email", - "required": true, - "in": "path", - "description": "Identifier of the employee", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Employee profile found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmployeeProfileItemDto" - } - } - } - }, - "400": { - "description": "Employee profile not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find employee profile", - "tags": [ - "Employees" - ] - } - }, "/timesheets": { "get": { "operationId": "TimesheetsController_getPeriodByQuery", @@ -526,110 +387,6 @@ ] } }, - "/timesheets/{id}": { - "get": { - "operationId": "TimesheetsController_findOne", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Timesheet found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTimesheetDto" - } - } - } - }, - "400": { - "description": "Timesheet not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find timesheet", - "tags": [ - "Timesheets" - ] - }, - "delete": { - "operationId": "TimesheetsController_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Timesheet deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTimesheetDto" - } - } - } - }, - "400": { - "description": "Timesheet not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete timesheet", - "tags": [ - "Timesheets" - ] - } - }, - "/timesheets/approval/{id}": { - "patch": { - "operationId": "TimesheetsController_approve", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "access-token": [] - } - ], - "tags": [ - "Timesheets" - ] - } - }, "/Expenses/upsert/{email}/{date}": { "put": { "operationId": "ExpensesController_upsert_by_date", @@ -676,228 +433,6 @@ ] } }, - "/Expenses": { - "post": { - "operationId": "ExpensesController_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - }, - "responses": { - "201": { - "description": "Expense created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - }, - "400": { - "description": "Incomplete task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create expense", - "tags": [ - "Expenses" - ] - }, - "get": { - "operationId": "ExpensesController_findAll", - "parameters": [], - "responses": { - "201": { - "description": "List of expenses found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - } - }, - "400": { - "description": "List of expenses not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find all expenses", - "tags": [ - "Expenses" - ] - } - }, - "/Expenses/{id}": { - "get": { - "operationId": "ExpensesController_findOne", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Expense found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - }, - "400": { - "description": "Expense not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find expense", - "tags": [ - "Expenses" - ] - }, - "patch": { - "operationId": "ExpensesController_update", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateExpenseDto" - } - } - } - }, - "responses": { - "201": { - "description": "Expense updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - }, - "400": { - "description": "Expense not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Expense shift", - "tags": [ - "Expenses" - ] - }, - "delete": { - "operationId": "ExpensesController_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Expense deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - }, - "400": { - "description": "Expense not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete expense", - "tags": [ - "Expenses" - ] - } - }, - "/Expenses/approval/{id}": { - "patch": { - "operationId": "ExpensesController_approve", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "access-token": [] - } - ], - "tags": [ - "Expenses" - ] - } - }, "/shifts/upsert/{email}/{date}": { "put": { "operationId": "ShiftsController_upsert_by_date", @@ -944,200 +479,6 @@ ] } }, - "/shifts": { - "post": { - "operationId": "ShiftsController_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - }, - "responses": { - "201": { - "description": "Shift created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - }, - "400": { - "description": "Incomplete task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create shift", - "tags": [ - "Shifts" - ] - }, - "get": { - "operationId": "ShiftsController_findAll", - "parameters": [], - "responses": { - "201": { - "description": "List of shifts found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - } - }, - "400": { - "description": "List of shifts not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find all shifts", - "tags": [ - "Shifts" - ] - } - }, - "/shifts/{id}": { - "get": { - "operationId": "ShiftsController_findOne", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Shift found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - }, - "400": { - "description": "Shift not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find shift", - "tags": [ - "Shifts" - ] - }, - "patch": { - "operationId": "ShiftsController_update", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateShiftsDto" - } - } - } - }, - "responses": { - "201": { - "description": "Shift updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - }, - "400": { - "description": "Shift not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Update shift", - "tags": [ - "Shifts" - ] - }, - "delete": { - "operationId": "ShiftsController_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Shift deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - }, - "400": { - "description": "Shift not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete shift", - "tags": [ - "Shifts" - ] - } - }, "/shifts/approval/{id}": { "patch": { "operationId": "ShiftsController_approve", @@ -1289,319 +630,6 @@ ] } }, - "/bank-codes": { - "post": { - "operationId": "BankCodesControllers_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateBankCodeDto" - } - } - } - }, - "responses": { - "201": { - "description": "Bank code successfully created." - }, - "400": { - "description": "Invalid input data." - } - }, - "summary": "Create a new bank code", - "tags": [ - "BankCodesControllers" - ] - }, - "get": { - "operationId": "BankCodesControllers_findAll", - "parameters": [], - "responses": { - "200": { - "description": "List of bank codes." - } - }, - "summary": "Retrieve all bank codes", - "tags": [ - "BankCodesControllers" - ] - } - }, - "/bank-codes/{id}": { - "get": { - "operationId": "BankCodesControllers_findOne", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "404": { - "description": "Bank code not found." - } - }, - "summary": "Retrieve a bank code by its ID", - "tags": [ - "BankCodesControllers" - ] - }, - "patch": { - "operationId": "BankCodesControllers_update", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateBankCodeDto" - } - } - } - }, - "responses": { - "404": { - "description": "Bank code not found." - } - }, - "summary": "Update an existing bank code", - "tags": [ - "BankCodesControllers" - ] - }, - "delete": { - "operationId": "BankCodesControllers_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "404": { - "description": "Bank code not found." - } - }, - "summary": "Delete a bank code", - "tags": [ - "BankCodesControllers" - ] - } - }, - "/customers": { - "post": { - "operationId": "CustomersController_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - }, - "responses": { - "201": { - "description": "Customer created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - }, - "400": { - "description": "Invalid task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create customer", - "tags": [ - "Customers" - ] - }, - "get": { - "operationId": "CustomersController_findAll", - "parameters": [], - "responses": { - "201": { - "description": "List of customers found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - } - }, - "400": { - "description": "List of customers not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find all customers", - "tags": [ - "Customers" - ] - } - }, - "/customers/{id}": { - "get": { - "operationId": "CustomersController_findOne", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Customer found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - }, - "400": { - "description": "Customer not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find customer", - "tags": [ - "Customers" - ] - }, - "patch": { - "operationId": "CustomersController_update", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateCustomerDto" - } - } - } - }, - "responses": { - "201": { - "description": "Customer updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - }, - "400": { - "description": "Customer not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Update customer", - "tags": [ - "Customers" - ] - }, - "delete": { - "operationId": "CustomersController_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Customer deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - }, - "400": { - "description": "Customer not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete customer", - "tags": [ - "Customers" - ] - } - }, "/oauth-sessions": { "post": { "operationId": "OauthSessionsController_create", @@ -1796,31 +824,6 @@ ] } }, - "/pay-periods": { - "get": { - "operationId": "PayPeriodsController_findAll", - "parameters": [], - "responses": { - "200": { - "description": "List of pay period found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PayPeriodDto" - } - } - } - } - } - }, - "summary": "Find all pay period", - "tags": [ - "pay-periods" - ] - } - }, "/pay-periods/current-and-all": { "get": { "operationId": "PayPeriodsController_getCurrentAndAll", @@ -2205,10 +1208,6 @@ "type": "object", "properties": {} }, - "EmployeeProfileItemDto": { - "type": "object", - "properties": {} - }, "UpdateEmployeeDto": { "type": "object", "properties": { @@ -2284,367 +1283,18 @@ "type": "object", "properties": {} }, - "CreateTimesheetDto": { - "type": "object", - "properties": {} - }, "UpsertExpenseDto": { "type": "object", "properties": {} }, - "CreateExpenseDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of the expense (auto-generated)" - }, - "timesheet_id": { - "type": "number", - "example": 101, - "description": "ID number for a set timesheet" - }, - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of an bank code (link with bank-codes)" - }, - "date": { - "type": "string", - "example": "3018-10-20T00:00:00.000Z", - "description": "Date where the expense was made" - }, - "amount": { - "type": "number", - "example": 17.82, - "description": "amount in $ for a refund" - }, - "comment": { - "type": "string", - "example": "Spent for mileage between A and B", - "description": "explain`s why the expense was made" - }, - "is_approved": { - "type": "boolean", - "example": "DENIED, APPROUVED, PENDING, etc...", - "description": "validation status" - }, - "supervisor_comment": { - "type": "string", - "example": "Asked X to go there as an emergency response", - "description": "Supervisro`s justification for the spending of an employee" - } - }, - "required": [ - "id", - "timesheet_id", - "bank_code_id", - "date", - "amount", - "comment", - "is_approved", - "supervisor_comment" - ] - }, - "UpdateExpenseDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of the expense (auto-generated)" - }, - "timesheet_id": { - "type": "number", - "example": 101, - "description": "ID number for a set timesheet" - }, - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of an bank code (link with bank-codes)" - }, - "date": { - "type": "string", - "example": "3018-10-20T00:00:00.000Z", - "description": "Date where the expense was made" - }, - "amount": { - "type": "number", - "example": 17.82, - "description": "amount in $ for a refund" - }, - "comment": { - "type": "string", - "example": "Spent for mileage between A and B", - "description": "explain`s why the expense was made" - }, - "is_approved": { - "type": "boolean", - "example": "DENIED, APPROUVED, PENDING, etc...", - "description": "validation status" - }, - "supervisor_comment": { - "type": "string", - "example": "Asked X to go there as an emergency response", - "description": "Supervisro`s justification for the spending of an employee" - } - } - }, "UpsertShiftDto": { "type": "object", "properties": {} }, - "CreateShiftDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of the shift (auto-generated)" - }, - "timesheet_id": { - "type": "number", - "example": 101, - "description": "ID number for a set timesheet" - }, - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of a shift code (link with bank-codes)" - }, - "date": { - "type": "string", - "example": "3018-10-20T00:00:00.000Z", - "description": "Date where the shift takes place" - }, - "start_time": { - "type": "string", - "example": "3018-10-20T08:00:00.000Z", - "description": "Start time of the said shift" - }, - "end_time": { - "type": "string", - "example": "3018-10-20T17:00:00.000Z", - "description": "End time of the said shift" - } - }, - "required": [ - "id", - "timesheet_id", - "bank_code_id", - "date", - "start_time", - "end_time" - ] - }, - "UpdateShiftsDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of the shift (auto-generated)" - }, - "timesheet_id": { - "type": "number", - "example": 101, - "description": "ID number for a set timesheet" - }, - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of a shift code (link with bank-codes)" - }, - "date": { - "type": "string", - "example": "3018-10-20T00:00:00.000Z", - "description": "Date where the shift takes place" - }, - "start_time": { - "type": "string", - "example": "3018-10-20T08:00:00.000Z", - "description": "Start time of the said shift" - }, - "end_time": { - "type": "string", - "example": "3018-10-20T17:00:00.000Z", - "description": "End time of the said shift" - } - } - }, "UpsertLeaveRequestDto": { "type": "object", "properties": {} }, - "CreateBankCodeDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of a bank-code (auto-generated)", - "readOnly": true - }, - "type": { - "type": "string", - "example": "regular, vacation, emergency, sick, parental, etc", - "description": "Type of codes" - }, - "categorie": { - "type": "string", - "example": "shift, expense, leave", - "description": "categorie of the related code" - }, - "modifier": { - "type": "number", - "example": "0, 0.72, 1, 1.5, 2", - "description": "modifier number to apply to salary" - }, - "bank_code": { - "type": "string", - "example": "G1, G345, G501, G43, G700", - "description": "codes given by the bank" - } - }, - "required": [ - "id", - "type", - "categorie", - "modifier", - "bank_code" - ] - }, - "UpdateBankCodeDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of a bank-code (auto-generated)", - "readOnly": true - }, - "type": { - "type": "string", - "example": "regular, vacation, emergency, sick, parental, etc", - "description": "Type of codes" - }, - "categorie": { - "type": "string", - "example": "shift, expense, leave", - "description": "categorie of the related code" - }, - "modifier": { - "type": "number", - "example": "0, 0.72, 1, 1.5, 2", - "description": "modifier number to apply to salary" - }, - "bank_code": { - "type": "string", - "example": "G1, G345, G501, G43, G700", - "description": "codes given by the bank" - } - } - }, - "CreateCustomerDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of a customer(primary-key, auto-incremented)" - }, - "user_id": { - "type": "string", - "example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d", - "description": "UUID of the user linked to that customer" - }, - "first_name": { - "type": "string", - "example": "Gandalf", - "description": "Customer`s first name" - }, - "last_name": { - "type": "string", - "example": "TheGray", - "description": "Customer`s last name" - }, - "email": { - "type": "string", - "example": "you_shall_not_pass@middleEarth.com", - "description": "Customer`s email" - }, - "phone_number": { - "type": "string", - "example": "8436637464", - "description": "Customer`s phone number" - }, - "residence": { - "type": "string", - "example": "1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ", - "description": "Customer`s residence" - }, - "invoice_id": { - "type": "number", - "example": "4263253", - "description": "Customer`s invoice number" - } - }, - "required": [ - "id", - "user_id", - "first_name", - "last_name", - "email", - "phone_number" - ] - }, - "UpdateCustomerDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of a customer(primary-key, auto-incremented)" - }, - "user_id": { - "type": "string", - "example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d", - "description": "UUID of the user linked to that customer" - }, - "first_name": { - "type": "string", - "example": "Gandalf", - "description": "Customer`s first name" - }, - "last_name": { - "type": "string", - "example": "TheGray", - "description": "Customer`s last name" - }, - "email": { - "type": "string", - "example": "you_shall_not_pass@middleEarth.com", - "description": "Customer`s email" - }, - "phone_number": { - "type": "string", - "example": "8436637464", - "description": "Customer`s phone number" - }, - "residence": { - "type": "string", - "example": "1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ", - "description": "Customer`s residence" - }, - "invoice_id": { - "type": "number", - "example": "4263253", - "description": "Customer`s invoice number" - } - } - }, "CreateOauthSessionDto": { "type": "object", "properties": { diff --git a/src/modules/bank-codes/controllers/bank-codes.controller.ts b/src/modules/bank-codes/controllers/bank-codes.controller.ts index cb36ee2..678336c 100644 --- a/src/modules/bank-codes/controllers/bank-codes.controller.ts +++ b/src/modules/bank-codes/controllers/bank-codes.controller.ts @@ -7,40 +7,43 @@ import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOperation, ApiResponse } @Controller('bank-codes') export class BankCodesControllers { constructor(private readonly bankCodesService: BankCodesService) {} + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - @Post() - @ApiOperation({ summary: 'Create a new bank code' }) - @ApiResponse({ status: 201, description: 'Bank code successfully created.' }) - @ApiBadRequestResponse({ description: 'Invalid input data.' }) - create(@Body() dto: CreateBankCodeDto) { - return this.bankCodesService.create(dto); - } + // @Post() + // @ApiOperation({ summary: 'Create a new bank code' }) + // @ApiResponse({ status: 201, description: 'Bank code successfully created.' }) + // @ApiBadRequestResponse({ description: 'Invalid input data.' }) + // create(@Body() dto: CreateBankCodeDto) { + // return this.bankCodesService.create(dto); + // } - @Get() - @ApiOperation({ summary: 'Retrieve all bank codes' }) - @ApiResponse({ status: 200, description: 'List of bank codes.' }) - findAll() { - return this.bankCodesService.findAll(); - } + // @Get() + // @ApiOperation({ summary: 'Retrieve all bank codes' }) + // @ApiResponse({ status: 200, description: 'List of bank codes.' }) + // findAll() { + // return this.bankCodesService.findAll(); + // } - @Get(':id') - @ApiOperation({ summary: 'Retrieve a bank code by its ID' }) - @ApiNotFoundResponse({ description: 'Bank code not found.' }) - findOne(@Param('id', ParseIntPipe) id: number){ - return this.bankCodesService.findOne(id); - } + // @Get(':id') + // @ApiOperation({ summary: 'Retrieve a bank code by its ID' }) + // @ApiNotFoundResponse({ description: 'Bank code not found.' }) + // findOne(@Param('id', ParseIntPipe) id: number){ + // return this.bankCodesService.findOne(id); + // } - @Patch(':id') - @ApiOperation({ summary: 'Update an existing bank code' }) - @ApiNotFoundResponse({ description: 'Bank code not found.' }) - update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) { - return this.bankCodesService.update(id, dto) - } + // @Patch(':id') + // @ApiOperation({ summary: 'Update an existing bank code' }) + // @ApiNotFoundResponse({ description: 'Bank code not found.' }) + // update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) { + // return this.bankCodesService.update(id, dto) + // } - @Delete(':id') - @ApiOperation({ summary: 'Delete a bank code' }) - @ApiNotFoundResponse({ description: 'Bank code not found.' }) - remove(@Param('id', ParseIntPipe) id: number) { - return this.bankCodesService.remove(id); - } + // @Delete(':id') + // @ApiOperation({ summary: 'Delete a bank code' }) + // @ApiNotFoundResponse({ description: 'Bank code not found.' }) + // remove(@Param('id', ParseIntPipe) id: number) { + // return this.bankCodesService.remove(id); + // } } \ No newline at end of file diff --git a/src/modules/customers/controllers/customers.controller.ts b/src/modules/customers/controllers/customers.controller.ts index 83122d3..aaf4e42 100644 --- a/src/modules/customers/controllers/customers.controller.ts +++ b/src/modules/customers/controllers/customers.controller.ts @@ -14,51 +14,55 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg export class CustomersController { constructor(private readonly customersService: CustomersService) {} - @Post() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Create customer' }) - @ApiResponse({ status: 201, description: 'Customer created', type: CreateCustomerDto }) - @ApiResponse({ status: 400, description: 'Invalid task or invalid data' }) - create(@Body() dto: CreateCustomerDto): Promise { - return this.customersService.create(dto); - } + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ + + // @Post() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Create customer' }) + // @ApiResponse({ status: 201, description: 'Customer created', type: CreateCustomerDto }) + // @ApiResponse({ status: 400, description: 'Invalid task or invalid data' }) + // create(@Body() dto: CreateCustomerDto): Promise { + // return this.customersService.create(dto); + // } - @Get() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find all customers' }) - @ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of customers not found' }) - findAll(): Promise { - return this.customersService.findAll(); - } + // @Get() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find all customers' }) + // @ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true }) + // @ApiResponse({ status: 400, description: 'List of customers not found' }) + // findAll(): Promise { + // return this.customersService.findAll(); + // } - @Get(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find customer' }) - @ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto }) - @ApiResponse({ status: 400, description: 'Customer not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.customersService.findOne(id); - } + // @Get(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find customer' }) + // @ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto }) + // @ApiResponse({ status: 400, description: 'Customer not found' }) + // findOne(@Param('id', ParseIntPipe) id: number): Promise { + // return this.customersService.findOne(id); + // } - @Patch(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Update customer' }) - @ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto }) - @ApiResponse({ status: 400, description: 'Customer not found' }) - update( - @Param('id', ParseIntPipe) id: number, - @Body() dto: UpdateCustomerDto, - ): Promise { - return this.customersService.update(id, dto); - } + // @Patch(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Update customer' }) + // @ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto }) + // @ApiResponse({ status: 400, description: 'Customer not found' }) + // update( + // @Param('id', ParseIntPipe) id: number, + // @Body() dto: UpdateCustomerDto, + // ): Promise { + // return this.customersService.update(id, dto); + // } - @Delete(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Delete customer' }) - @ApiResponse({ status: 201, description: 'Customer deleted', type: CreateCustomerDto }) - @ApiResponse({ status: 400, description: 'Customer not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise{ - return this.customersService.remove(id); - } + // @Delete(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Delete customer' }) + // @ApiResponse({ status: 201, description: 'Customer deleted', type: CreateCustomerDto }) + // @ApiResponse({ status: 400, description: 'Customer not found' }) + // remove(@Param('id', ParseIntPipe) id: number): Promise{ + // return this.customersService.remove(id); + // } } diff --git a/src/modules/employees/controllers/employees.controller.ts b/src/modules/employees/controllers/employees.controller.ts index e46d2cc..b20c78e 100644 --- a/src/modules/employees/controllers/employees.controller.ts +++ b/src/modules/employees/controllers/employees.controller.ts @@ -23,16 +23,7 @@ export class EmployeesController { create(@Body() dto: CreateEmployeeDto): Promise { return this.employeesService.create(dto); } - - @Get() - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) - @ApiOperation({summary: 'Find all employees' }) - @ApiResponse({ status: 200, description: 'List of employees found', type: CreateEmployeeDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of employees not found' }) - findAll(): Promise { - return this.employeesService.findAll(); - } - + @Get('employee-list') //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) @ApiOperation({summary: 'Find all employees with scoped info' }) @@ -42,34 +33,6 @@ export class EmployeesController { return this.employeesService.findListEmployees(); } - @Get(':email') - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING ) - @ApiOperation({summary: 'Find employee' }) - @ApiResponse({ status: 200, description: 'Employee found', type: CreateEmployeeDto }) - @ApiResponse({ status: 400, description: 'Employee not found' }) - findOne(@Param('email', ParseIntPipe) email: string): Promise { - return this.employeesService.findOne(email); - } - - @Get('profile/:email') - @ApiOperation({summary: 'Find employee profile' }) - @ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' }) - @ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto }) - @ApiResponse({ status: 400, description: 'Employee profile not found' }) - findOneProfile(@Param('email') email: string): Promise { - return this.employeesService.findOneProfile(email); - } - - @Delete(':email') - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR ) - @ApiOperation({summary: 'Delete employee' }) - @ApiParam({ name: 'email', type: Number, description: 'Email of the employee to delete' }) - @ApiResponse({ status: 204, description: 'Employee deleted' }) - @ApiResponse({ status: 404, description: 'Employee not found' }) - remove(@Param('email', ParseIntPipe) email: string): Promise { - return this.employeesService.remove(email); - } - @Patch(':email') //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) @ApiBearerAuth('access-token') @@ -88,4 +51,47 @@ export class EmployeesController { } return result; } + + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ + + // @Get() + // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) + // @ApiOperation({summary: 'Find all employees' }) + // @ApiResponse({ status: 200, description: 'List of employees found', type: CreateEmployeeDto, isArray: true }) + // @ApiResponse({ status: 400, description: 'List of employees not found' }) + // findAll(): Promise { + // return this.employeesService.findAll(); + // } + + + // @Get(':email') + // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING ) + // @ApiOperation({summary: 'Find employee' }) + // @ApiResponse({ status: 200, description: 'Employee found', type: CreateEmployeeDto }) + // @ApiResponse({ status: 400, description: 'Employee not found' }) + // findOne(@Param('email', ParseIntPipe) email: string): Promise { + // return this.employeesService.findOne(email); + // } + + // @Get('profile/:email') + // @ApiOperation({summary: 'Find employee profile' }) + // @ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' }) + // @ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto }) + // @ApiResponse({ status: 400, description: 'Employee profile not found' }) + // findOneProfile(@Param('email') email: string): Promise { + // return this.employeesService.findOneProfile(email); + // } + + // @Delete(':email') + // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR ) + // @ApiOperation({summary: 'Delete employee' }) + // @ApiParam({ name: 'email', type: Number, description: 'Email of the employee to delete' }) + // @ApiResponse({ status: 204, description: 'Employee deleted' }) + // @ApiResponse({ status: 404, description: 'Employee not found' }) + // remove(@Param('email', ParseIntPipe) email: string): Promise { + // return this.employeesService.remove(email); + // } + } diff --git a/src/modules/expenses/controllers/expenses.controller.ts b/src/modules/expenses/controllers/expenses.controller.ts index 0309397..c352394 100644 --- a/src/modules/expenses/controllers/expenses.controller.ts +++ b/src/modules/expenses/controllers/expenses.controller.ts @@ -30,56 +30,60 @@ export class ExpensesController { return this.command.upsertExpensesByDate(email, date, dto); } - @Post() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Create expense' }) - @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateExpenseDto): Promise { - return this.query.create(dto); - } + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - @Get() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find all expenses' }) - @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of expenses not found' }) - @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) - findAll(@Query() filters: SearchExpensesDto): Promise { - return this.query.findAll(filters); - } + // @Post() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Create expense' }) + // @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto }) + // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) + // create(@Body() dto: CreateExpenseDto): Promise { + // return this.query.create(dto); + // } - @Get(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find expense' }) - @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto }) - @ApiResponse({ status: 400, description: 'Expense not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.query.findOne(id); - } + // @Get() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find all expenses' }) + // @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true }) + // @ApiResponse({ status: 400, description: 'List of expenses not found' }) + // @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + // findAll(@Query() filters: SearchExpensesDto): Promise { + // return this.query.findAll(filters); + // } - @Patch(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Expense shift' }) - @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto }) - @ApiResponse({ status: 400, description: 'Expense not found' }) - update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) { - return this.query.update(id,dto); - } + // @Get(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find expense' }) + // @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto }) + // @ApiResponse({ status: 400, description: 'Expense not found' }) + // findOne(@Param('id', ParseIntPipe) id: number): Promise { + // return this.query.findOne(id); + // } - @Delete(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Delete expense' }) - @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto }) - @ApiResponse({ status: 400, description: 'Expense not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.query.remove(id); - } + // @Patch(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Expense shift' }) + // @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto }) + // @ApiResponse({ status: 400, description: 'Expense not found' }) + // update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) { + // return this.query.update(id,dto); + // } - @Patch('approval/:id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { - return this.command.updateApproval(id, isApproved); - } + // @Delete(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Delete expense' }) + // @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto }) + // @ApiResponse({ status: 400, description: 'Expense not found' }) + // remove(@Param('id', ParseIntPipe) id: number): Promise { + // return this.query.remove(id); + // } + + // @Patch('approval/:id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + // async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { + // return this.command.updateApproval(id, isApproved); + // } } \ No newline at end of file diff --git a/src/modules/pay-periods/controllers/pay-periods.controller.ts b/src/modules/pay-periods/controllers/pay-periods.controller.ts index c87c658..c12f810 100644 --- a/src/modules/pay-periods/controllers/pay-periods.controller.ts +++ b/src/modules/pay-periods/controllers/pay-periods.controller.ts @@ -18,13 +18,6 @@ export class PayPeriodsController { private readonly commandService: PayPeriodsCommandService, ) {} - @Get() - @ApiOperation({ summary: 'Find all pay period' }) - @ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true }) - async findAll(): Promise { - return this.queryService.findAll(); - } - @Get('current-and-all') @ApiOperation({summary: 'Return current pay period and the full list'}) @ApiQuery({name: 'date', required:false, example: '2025-08-11', description:'Override for resolving the current period'}) @@ -95,4 +88,16 @@ export class PayPeriodsController { ): Promise { return this.queryService.getOverviewByYearPeriod(year, period_no); } + + + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ + + // @Get() + // @ApiOperation({ summary: 'Find all pay period' }) + // @ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true }) + // async findAll(): Promise { + // return this.queryService.findAll(); + // } } diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index 3a261ce..c936026 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -29,52 +29,6 @@ export class ShiftsController { ) { return this.shiftsCommandService.upsertShiftsByDate(email_param, date_param, payload); } - - @Post() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Create shift' }) - @ApiResponse({ status: 201, description: 'Shift created',type: CreateShiftDto }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateShiftDto): Promise { - return this.shiftsService.create(dto); - } - - @Get() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find all shifts' }) - @ApiResponse({ status: 201, description: 'List of shifts found',type: CreateShiftDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of shifts not found' }) - @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) - findAll(@Query() filters: SearchShiftsDto) { - return this.shiftsService.findAll(filters); - } - - @Get(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find shift' }) - @ApiResponse({ status: 201, description: 'Shift found',type: CreateShiftDto }) - @ApiResponse({ status: 400, description: 'Shift not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.shiftsService.findOne(id); - } - - @Patch(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Update shift' }) - @ApiResponse({ status: 201, description: 'Shift updated',type: CreateShiftDto }) - @ApiResponse({ status: 400, description: 'Shift not found' }) - update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateShiftsDto): Promise { - return this.shiftsService.update(id, dto); - } - - @Delete(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Delete shift' }) - @ApiResponse({ status: 201, description: 'Shift deleted',type: CreateShiftDto }) - @ApiResponse({ status: 400, description: 'Shift not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.shiftsService.remove(id); - } @Patch('approval/:id') //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) @@ -92,7 +46,6 @@ export class ShiftsController { @Header('Content-Disposition', 'attachment; filename="shifts-validation.csv"') async exportCsv(@Query() query: GetShiftsOverviewDto): Promise{ const rows = await this.shiftsService.getSummary(query.period_id); - //CSV Headers const header = [ 'full_name', @@ -123,5 +76,55 @@ export class ShiftsController { return Buffer.from('\uFEFF' + header + body, 'utf8'); } + + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ + + // @Post() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Create shift' }) + // @ApiResponse({ status: 201, description: 'Shift created',type: CreateShiftDto }) + // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) + // create(@Body() dto: CreateShiftDto): Promise { + // return this.shiftsService.create(dto); + // } + + // @Get() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find all shifts' }) + // @ApiResponse({ status: 201, description: 'List of shifts found',type: CreateShiftDto, isArray: true }) + // @ApiResponse({ status: 400, description: 'List of shifts not found' }) + // @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + // findAll(@Query() filters: SearchShiftsDto) { + // return this.shiftsService.findAll(filters); + // } + + // @Get(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find shift' }) + // @ApiResponse({ status: 201, description: 'Shift found',type: CreateShiftDto }) + // @ApiResponse({ status: 400, description: 'Shift not found' }) + // findOne(@Param('id', ParseIntPipe) id: number): Promise { + // return this.shiftsService.findOne(id); + // } + + // @Patch(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Update shift' }) + // @ApiResponse({ status: 201, description: 'Shift updated',type: CreateShiftDto }) + // @ApiResponse({ status: 400, description: 'Shift not found' }) + // update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateShiftsDto): Promise { + // return this.shiftsService.update(id, dto); + // } + + // @Delete(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Delete shift' }) + // @ApiResponse({ status: 201, description: 'Shift deleted',type: CreateShiftDto }) + // @ApiResponse({ status: 400, description: 'Shift not found' }) + // remove(@Param('id', ParseIntPipe) id: number): Promise { + // return this.shiftsService.remove(id); + // } } \ No newline at end of file diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index 2dff5b4..b5d2176 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -50,27 +50,34 @@ export class TimesheetsController { } - @Get(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find timesheet' }) - @ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto }) - @ApiResponse({ status: 400, description: 'Timesheet not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.timesheetsQuery.findOne(id); - } + + + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ + + // @Patch('approval/:id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + // async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { + // return this.timesheetsCommand.updateApproval(id, isApproved); + // } - @Delete(':id') - // @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Delete timesheet' }) - @ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto }) - @ApiResponse({ status: 400, description: 'Timesheet not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.timesheetsQuery.remove(id); - } + // @Get(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find timesheet' }) + // @ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto }) + // @ApiResponse({ status: 400, description: 'Timesheet not found' }) + // findOne(@Param('id', ParseIntPipe) id: number): Promise { + // return this.timesheetsQuery.findOne(id); + // } + + // @Delete(':id') + // // @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Delete timesheet' }) + // @ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto }) + // @ApiResponse({ status: 400, description: 'Timesheet not found' }) + // remove(@Param('id', ParseIntPipe) id: number): Promise { + // return this.timesheetsQuery.remove(id); + // } - @Patch('approval/:id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { - return this.timesheetsCommand.updateApproval(id, isApproved); - } } From fa845cf6e646bc0dd46258f4db9f3ee0ba35d18a Mon Sep 17 00:00:00 2001 From: Nicolas Drolet Date: Mon, 6 Oct 2025 16:06:26 -0400 Subject: [PATCH 48/69] fix(timesheet): add some quality of life to get required info, employee name, change pay period response from week 1 and 2 to array of weeks --- src/modules/timesheets/dtos/timesheet-period.dto.ts | 3 +-- src/modules/timesheets/utils/timesheet.helpers.ts | 8 +++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index 6da4551..a084bad 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -59,7 +59,6 @@ export class WeekDto { } export class TimesheetPeriodDto { - week1: WeekDto; - week2: WeekDto; + weeks: WeekDto[]; employee_full_name: string; } diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 05cfc38..7718f4c 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -127,7 +127,7 @@ export function makeEmptyWeek(week_start: Date): WeekDto { } export function makeEmptyPeriod(): TimesheetPeriodDto { - return { week1: makeEmptyWeek(new Date()), week2: makeEmptyWeek(new Date()), employee_full_name: " " }; + return { weeks: [ makeEmptyWeek(new Date()), makeEmptyWeek(new Date())], employee_full_name: " " }; } export function buildWeek( @@ -310,8 +310,10 @@ export function buildPeriod( const week2_end = endOfDayUTC(period_end); return { - week1: buildWeek(week1_start, week1_end, shifts, expenses), - week2: buildWeek(week2_start, week2_end, shifts, expenses), + weeks: [ + buildWeek(week1_start, week1_end, shifts, expenses), + buildWeek(week2_start, week2_end, shifts, expenses), + ], employee_full_name, }; } From 57b74b17267c2c0a20aaf49e5506ecb19983a200 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 7 Oct 2025 08:18:46 -0400 Subject: [PATCH 49/69] fix(modules): deprecate old methods and extract utils and helpers. created archival services. --- .../controllers/customers.controller.ts | 8 +- .../customers/services/customers.service.ts | 161 +++--- .../controllers/employees.controller.ts | 29 +- src/modules/employees/employees.module.ts | 5 +- .../services/employees-archival.service.ts | 173 ++++++ .../employees/services/employees.service.ts | 503 ++++++------------ src/modules/employees/utils/employee.utils.ts | 9 + .../controllers/expenses.controller.ts | 11 +- src/modules/expenses/expenses.module.ts | 5 +- .../services/expenses-archival.service.ts | 62 +++ .../services/expenses-command.service.ts | 103 ++-- .../services/expenses-query.service.ts | 217 +++----- src/modules/expenses/utils/expenses.utils.ts | 44 +- .../shifts/controllers/shifts.controller.ts | 11 +- .../helpers/shifts-date-time-helpers.ts | 7 + .../services/shifts-archival.service.ts | 59 ++ .../shifts/services/shifts-command.service.ts | 492 ++++++++--------- .../shifts/services/shifts-query.service.ts | 234 +++----- src/modules/shifts/shifts.module.ts | 5 +- .../shifts-overview-row.interface.ts | 10 + .../shifts-upsert.types.ts | 9 + src/modules/shifts/utils/shifts.utils.ts | 37 ++ .../services/timesheet-archive.service.ts | 52 ++ .../services/timesheets-command.service.ts | 21 +- .../services/timesheets-query.service.ts | 150 ++---- src/modules/timesheets/timesheets.module.ts | 24 +- .../timesheets/utils/timesheet.helpers.ts | 13 + 27 files changed, 1253 insertions(+), 1201 deletions(-) create mode 100644 src/modules/employees/services/employees-archival.service.ts create mode 100644 src/modules/employees/utils/employee.utils.ts create mode 100644 src/modules/expenses/services/expenses-archival.service.ts create mode 100644 src/modules/shifts/services/shifts-archival.service.ts create mode 100644 src/modules/shifts/types and interfaces/shifts-overview-row.interface.ts create mode 100644 src/modules/shifts/types and interfaces/shifts-upsert.types.ts create mode 100644 src/modules/shifts/utils/shifts.utils.ts create mode 100644 src/modules/timesheets/services/timesheet-archive.service.ts diff --git a/src/modules/customers/controllers/customers.controller.ts b/src/modules/customers/controllers/customers.controller.ts index aaf4e42..713ebde 100644 --- a/src/modules/customers/controllers/customers.controller.ts +++ b/src/modules/customers/controllers/customers.controller.ts @@ -14,10 +14,10 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg export class CustomersController { constructor(private readonly customersService: CustomersService) {} - //_____________________________________________________________________________________________ - // Deprecated or unused methods - //_____________________________________________________________________________________________ - +//_____________________________________________________________________________________________ +// Deprecated or unused methods +//_____________________________________________________________________________________________ + // @Post() // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR) // @ApiOperation({ summary: 'Create customer' }) diff --git a/src/modules/customers/services/customers.service.ts b/src/modules/customers/services/customers.service.ts index 6163552..b0b68c8 100644 --- a/src/modules/customers/services/customers.service.ts +++ b/src/modules/customers/services/customers.service.ts @@ -1,92 +1,93 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { PrismaService } from 'src/prisma/prisma.service'; -import { CreateCustomerDto } from '../dtos/create-customer.dto'; -import { Customers, Users } from '@prisma/client'; -import { UpdateCustomerDto } from '../dtos/update-customer.dto'; +import { Injectable } from '@nestjs/common'; @Injectable() export class CustomersService { - constructor(private readonly prisma: PrismaService) {} - async create(dto: CreateCustomerDto): Promise { - const { - first_name, - last_name, - email, - phone_number, - residence, - invoice_id, - } = dto; +//_____________________________________________________________________________________________ +// Deprecated or unused methods +//_____________________________________________________________________________________________ - return this.prisma.$transaction(async (transaction) => { - const user: Users = await transaction.users.create({ - data: { - first_name, - last_name, - email, - phone_number, - residence, - }, - }); - return transaction.customers.create({ - data: { - user_id: user.id, - invoice_id, - }, - }); - }); - } +// constructor(private readonly prisma: PrismaService) {} + +// async create(dto: CreateCustomerDto): Promise { +// const { +// first_name, +// last_name, +// email, +// phone_number, +// residence, +// invoice_id, +// } = dto; + +// return this.prisma.$transaction(async (transaction) => { +// const user: Users = await transaction.users.create({ +// data: { +// first_name, +// last_name, +// email, +// phone_number, +// residence, +// }, +// }); +// return transaction.customers.create({ +// data: { +// user_id: user.id, +// invoice_id, +// }, +// }); +// }); +// } - findAll(): Promise { - return this.prisma.customers.findMany({ - include: { user: true }, - }) - } +// findAll(): Promise { +// return this.prisma.customers.findMany({ +// include: { user: true }, +// }) +// } - async findOne(id:number): Promise { - const customer = await this.prisma.customers.findUnique({ - where: { id }, - include: { user: true }, - }); - if(!customer) throw new NotFoundException(`Customer #${id} not found`); - return customer; - } +// async findOne(id:number): Promise { +// const customer = await this.prisma.customers.findUnique({ +// where: { id }, +// include: { user: true }, +// }); +// if(!customer) throw new NotFoundException(`Customer #${id} not found`); +// return customer; +// } -async update(id: number,dto: UpdateCustomerDto): Promise { - const customer = await this.findOne(id); +// async update(id: number,dto: UpdateCustomerDto): Promise { +// const customer = await this.findOne(id); - const { - first_name, - last_name, - email, - phone_number, - residence, - invoice_id, - } = dto; +// const { +// first_name, +// last_name, +// email, +// phone_number, +// residence, +// invoice_id, +// } = dto; - return this.prisma.$transaction(async (transaction) => { - await transaction.users.update({ - where: { id: customer.user_id }, - data: { - ...(first_name !== undefined && { first_name }), - ...(last_name !== undefined && { last_name }), - ...(email !== undefined && { email }), - ...(phone_number !== undefined && { phone_number }), - ...(residence !== undefined && { residence }), - }, - }); +// return this.prisma.$transaction(async (transaction) => { +// await transaction.users.update({ +// where: { id: customer.user_id }, +// data: { +// ...(first_name !== undefined && { first_name }), +// ...(last_name !== undefined && { last_name }), +// ...(email !== undefined && { email }), +// ...(phone_number !== undefined && { phone_number }), +// ...(residence !== undefined && { residence }), +// }, +// }); - return transaction.customers.update({ - where: { id }, - data: { - ...(invoice_id !== undefined && { invoice_id }), - }, - }); - }); -} - - async remove(id: number): Promise { - await this.findOne(id); - return this.prisma.customers.delete({ where: { id }}); - } +// return transaction.customers.update({ +// where: { id }, +// data: { +// ...(invoice_id !== undefined && { invoice_id }), +// }, +// }); +// }); +// } + +// async remove(id: number): Promise { +// await this.findOne(id); +// return this.prisma.customers.delete({ where: { id }}); +// } } diff --git a/src/modules/employees/controllers/employees.controller.ts b/src/modules/employees/controllers/employees.controller.ts index b20c78e..2026a28 100644 --- a/src/modules/employees/controllers/employees.controller.ts +++ b/src/modules/employees/controllers/employees.controller.ts @@ -1,29 +1,22 @@ -import { Body,Controller,Delete,Get,NotFoundException,Param,ParseIntPipe,Patch,Post,UseGuards } from '@nestjs/common'; -import { Employees, Roles as RoleEnum } from '@prisma/client'; +import { Body,Controller,Get,NotFoundException,Param,Patch } from '@nestjs/common'; import { EmployeesService } from '../services/employees.service'; import { CreateEmployeeDto } from '../dtos/create-employee.dto'; import { UpdateEmployeeDto } from '../dtos/update-employee.dto'; import { RolesAllowed } from '../../../common/decorators/roles.decorators'; import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { EmployeeListItemDto } from '../dtos/list-employee.dto'; -import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto'; +import { EmployeesArchivalService } from '../services/employees-archival.service'; @ApiTags('Employees') @ApiBearerAuth('access-token') // @UseGuards() @Controller('employees') export class EmployeesController { - constructor(private readonly employeesService: EmployeesService) {} + constructor( + private readonly employeesService: EmployeesService, + private readonly archiveService: EmployeesArchivalService, + ) {} - @Post() - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Create employee' }) - @ApiResponse({ status: 201, description: 'Employee created', type: CreateEmployeeDto }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateEmployeeDto): Promise { - return this.employeesService.create(dto); - } - @Get('employee-list') //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) @ApiOperation({summary: 'Find all employees with scoped info' }) @@ -45,7 +38,7 @@ export class EmployeesController { // if last_work_day is set => archive the employee // else if employee is archived and first_work_day or last_work_day = null => restore //otherwise => standard update - const result = await this.employeesService.patchEmployee(email, dto); + const result = await this.archiveService.patchEmployee(email, dto); if(!result) { throw new NotFoundException(`Employee with email: ${ email } is not found in active or archive.`) } @@ -56,6 +49,14 @@ export class EmployeesController { // Deprecated or unused methods //_____________________________________________________________________________________________ + // @Post() + // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({summary: 'Create employee' }) + // @ApiResponse({ status: 201, description: 'Employee created', type: CreateEmployeeDto }) + // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) + // create(@Body() dto: CreateEmployeeDto): Promise { + // return this.employeesService.create(dto); + // } // @Get() // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) // @ApiOperation({summary: 'Find all employees' }) diff --git a/src/modules/employees/employees.module.ts b/src/modules/employees/employees.module.ts index 22e5cc6..b362663 100644 --- a/src/modules/employees/employees.module.ts +++ b/src/modules/employees/employees.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { EmployeesController } from './controllers/employees.controller'; import { EmployeesService } from './services/employees.service'; +import { EmployeesArchivalService } from './services/employees-archival.service'; @Module({ controllers: [EmployeesController], - providers: [EmployeesService], - exports: [EmployeesService], + providers: [EmployeesService, EmployeesArchivalService], + exports: [EmployeesService, EmployeesArchivalService], }) export class EmployeesModule {} diff --git a/src/modules/employees/services/employees-archival.service.ts b/src/modules/employees/services/employees-archival.service.ts new file mode 100644 index 0000000..b13fa74 --- /dev/null +++ b/src/modules/employees/services/employees-archival.service.ts @@ -0,0 +1,173 @@ +import { Injectable } from "@nestjs/common"; +import { Employees, EmployeesArchive, Users } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; +import { UpdateEmployeeDto } from "../dtos/update-employee.dto"; +import { toDateOrUndefined, toDateOrNull } from "../utils/employee.utils"; + +@Injectable() +export class EmployeesArchivalService { + constructor(private readonly prisma: PrismaService) { } + + async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise { + // 1) Tenter sur employés actifs + const active = await this.prisma.employees.findFirst({ + where: { user: { email } }, + include: { user: true }, + }); + + if (active) { + // Archivage : si on reçoit un last_work_day défini et que l'employé n’est pas déjà terminé + if (dto.last_work_day !== undefined && active.last_work_day == null && dto.last_work_day !== null) { + return this.archiveOnTermination(active, dto); + } + + // Sinon, update standard (split Users/Employees) + const { + first_name, + last_name, + email: new_email, + phone_number, + residence, + external_payroll_id, + company_code, + job_title, + first_work_day, + last_work_day, + supervisor_id, + is_supervisor, + } = dto as any; + + const first_work_d = toDateOrUndefined(first_work_day); + const last_work_d = Object.prototype.hasOwnProperty('last_work_day') + ? toDateOrNull(last_work_day ?? null) + : undefined; + + await this.prisma.$transaction(async (transaction) => { + if ( + first_name !== undefined || + last_name !== undefined || + new_email !== undefined || + phone_number !== undefined || + residence !== undefined + ) { + await transaction.users.update({ + where: { id: active.user_id }, + data: { + ...(first_name !== undefined ? { first_name } : {}), + ...(last_name !== undefined ? { last_name } : {}), + ...(email !== undefined ? { email: new_email } : {}), + ...(phone_number !== undefined ? { phone_number } : {}), + ...(residence !== undefined ? { residence } : {}), + }, + }); + + } + + const updated = await transaction.employees.update({ + where: { id: active.id }, + data: { + ...(external_payroll_id !== undefined ? { external_payroll_id } : {}), + ...(company_code !== undefined ? { company_code } : {}), + ...(job_title !== undefined ? { job_title } : {}), + ...(first_work_d !== undefined ? { first_work_day: first_work_d } : {}), + ...(last_work_d !== undefined ? { last_work_day: last_work_d } : {}), + ...(is_supervisor !== undefined ? { is_supervisor } : {}), + ...(supervisor_id !== undefined ? { supervisor_id } : {}), + }, + include: { user: true }, + }); + + return updated; + }); + + return this.prisma.employees.findFirst({ where: { user: { email } } }); + } + + const user = await this.prisma.users.findUnique({ where: { email } }); + if (!user) return null; + // 2) Pas trouvé en actifs → regarder en archive (pour restauration) + const archived = await this.prisma.employeesArchive.findFirst({ + where: { user_id: user.id }, + include: { user: true }, + }); + + if (archived) { + // Condition de restauration : last_work_day === null ou first_work_day fourni + const restore = dto.last_work_day === null || dto.first_work_day != null; + if (restore) { + return this.restoreEmployee(archived, dto); + } + } + // 3) Ni actif, ni archivé → 404 dans le controller + return null; + } + + //transfers the employee to archive and then delete from employees table + private async archiveOnTermination(active: Employees & { user: Users }, dto: UpdateEmployeeDto): Promise { + const last_work_d = toDateOrNull(dto.last_work_day!); + if (!last_work_d) throw new Error('invalide last_work_day for archive'); + return this.prisma.$transaction(async transaction => { + //detach crew from supervisor if employee is a supervisor + await transaction.employees.updateMany({ + where: { supervisor_id: active.id }, + data: { supervisor_id: null }, + }) + const archived = await transaction.employeesArchive.create({ + data: { + employee_id: active.id, + user_id: active.user_id, + first_name: active.user.first_name, + last_name: active.user.last_name, + company_code: active.company_code, + job_title: active.job_title, + first_work_day: active.first_work_day, + last_work_day: last_work_d, + supervisor_id: active.supervisor_id ?? null, + is_supervisor: active.is_supervisor, + external_payroll_id: active.external_payroll_id, + }, + include: { user: true } + }); + //delete from employees table + await transaction.employees.delete({ where: { id: active.id } }); + //return archived employee + return archived + }); + } + + //transfers the employee from archive to the employees table + private async restoreEmployee(archived: EmployeesArchive & { user: Users }, dto: UpdateEmployeeDto): Promise { + // const first_work_d = toDateOrUndefined(dto.first_work_day); + return this.prisma.$transaction(async transaction => { + //restores the archived employee into the employees table + const restored = await transaction.employees.create({ + data: { + user_id: archived.user_id, + company_code: archived.company_code, + job_title: archived.job_title, + first_work_day: archived.first_work_day, + last_work_day: null, + is_supervisor: archived.is_supervisor ?? false, + external_payroll_id: archived.external_payroll_id, + }, + }); + //deleting archived entry by id + await transaction.employeesArchive.delete({ where: { id: archived.id } }); + + //return restored employee + return restored; + }); + } + + //fetches all archived employees + async findAllArchived(): Promise { + return this.prisma.employeesArchive.findMany(); + } + + //fetches an archived employee + async findOneArchived(id: number): Promise { + return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } }); + } + +} + diff --git a/src/modules/employees/services/employees.service.ts b/src/modules/employees/services/employees.service.ts index fd7dab7..2833bff 100644 --- a/src/modules/employees/services/employees.service.ts +++ b/src/modules/employees/services/employees.service.ts @@ -1,69 +1,11 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; -import { CreateEmployeeDto } from '../dtos/create-employee.dto'; -import { UpdateEmployeeDto } from '../dtos/update-employee.dto'; -import { Employees, EmployeesArchive, Users } from '@prisma/client'; import { EmployeeListItemDto } from '../dtos/list-employee.dto'; import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto'; -function toDateOrNull(v?: string | null): Date | null { - if (!v) return null; - const day = new Date(v); - return isNaN(day.getTime()) ? null : day; -} -function toDateOrUndefined(v?: string | null): Date | undefined { - const day = toDateOrNull(v ?? undefined); - return day === null ? undefined : day; -} - @Injectable() export class EmployeesService { - constructor(private readonly prisma: PrismaService) {} - - async create(dto: CreateEmployeeDto): Promise { - const { - first_name, - last_name, - email, - phone_number, - residence, - external_payroll_id, - company_code, - job_title, - first_work_day, - last_work_day, - is_supervisor, - } = dto; - - return this.prisma.$transaction(async (transaction) => { - const user: Users = await transaction.users.create({ - data: { - first_name, - last_name, - email, - phone_number, - residence, - }, - }); - return transaction.employees.create({ - data: { - user_id: user.id, - external_payroll_id, - company_code, - job_title, - first_work_day, - last_work_day, - is_supervisor, - }, - }); - }); - } - - findAll(): Promise { - return this.prisma.employees.findMany({ - include: { user: true }, - }); - } + constructor(private readonly prisma: PrismaService) { } findListEmployees(): Promise { return this.prisma.employees.findMany({ @@ -71,331 +13,220 @@ export class EmployeesService { user: { select: { first_name: true, - last_name: true, - email: true, - }, + last_name: true, + email: true, + }, }, supervisor: { select: { user: { select: { first_name: true, - last_name: true, + last_name: true, }, }, }, }, - job_title: true, + job_title: true, company_code: true, } }).then(rows => rows.map(r => ({ - first_name: r.user.first_name, - last_name: r.user.last_name, - employee_full_name: `${r.user.first_name} ${r.user.last_name}`, - email: r.user.email, + first_name: r.user.first_name, + last_name: r.user.last_name, + email: r.user.email, + company_name: r.company_code, + job_title: r.job_title, + employee_full_name: `${r.user.first_name} ${r.user.last_name}`, supervisor_full_name: r.supervisor ? `${r.supervisor.user.first_name} ${r.supervisor.user.last_name}` : null, - company_name: r.company_code, - job_title: r.job_title, - })), + })), ); } - async findOne(email: string): Promise { - const emp = await this.prisma.employees.findFirst({ - where: { user: { email } }, - include: { user: true }, - }); - //add search for archived employees - if (!emp) { - throw new NotFoundException(`Employee with email: ${email} not found`); - } - return emp; - } - async findOneProfile(email:string): Promise { + async findOneProfile(email: string): Promise { const emp = await this.prisma.employees.findFirst({ where: { user: { email } }, select: { user: { select: { - first_name: true, - last_name: true, - email: true, + first_name: true, + last_name: true, + email: true, phone_number: true, - residence: true, - }, + residence: true, + }, }, supervisor: { select: { user: { select: { first_name: true, - last_name: true, + last_name: true, }, }, }, }, - job_title: true, - company_code: true, + job_title: true, + company_code: true, first_work_day: true, - last_work_day: true, + last_work_day: true, } }); - if (!emp) throw new NotFoundException(`Employee with email ${email} not found`); + if (!emp) throw new NotFoundException(`Employee with email ${email} not found`); - return { - first_name: emp.user.first_name, - last_name: emp.user.last_name, - employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`, - email: emp.user.email, - residence: emp.user.residence, - phone_number: emp.user.phone_number, - supervisor_full_name: emp.supervisor ? `${emp.supervisor.user.first_name}, ${emp.supervisor.user.last_name}` : null, - company_name: emp.company_code, - job_title: emp.job_title, - first_work_day: emp.first_work_day.toISOString().slice(0,10), - last_work_day: emp.last_work_day ? emp.last_work_day.toISOString().slice(0,10) : null, - }; + return { + first_name: emp.user.first_name, + last_name: emp.user.last_name, + email: emp.user.email, + residence: emp.user.residence, + phone_number: emp.user.phone_number, + company_name: emp.company_code, + job_title: emp.job_title, + employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`, + first_work_day: emp.first_work_day.toISOString().slice(0, 10), + last_work_day: emp.last_work_day ? emp.last_work_day.toISOString().slice(0, 10) : null, + supervisor_full_name: emp.supervisor ? `${emp.supervisor.user.first_name}, ${emp.supervisor.user.last_name}` : null, + }; } - async update( - email: string, - dto: UpdateEmployeeDto, - ): Promise { - const emp = await this.findOne(email); + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - const { - first_name, - last_name, - phone_number, - residence, - external_payroll_id, - company_code, - job_title, - first_work_day, - last_work_day, - is_supervisor, - email: new_email, - } = dto; + // async create(dto: CreateEmployeeDto): Promise { + // const { + // first_name, + // last_name, + // email, + // phone_number, + // residence, + // external_payroll_id, + // company_code, + // job_title, + // first_work_day, + // last_work_day, + // is_supervisor, + // } = dto; - return this.prisma.$transaction(async (transaction) => { - if( - first_name !== undefined || - last_name !== undefined || - new_email !== undefined || - phone_number !== undefined || - residence !== undefined - ){ - await transaction.users.update({ - where: { id: emp.user_id }, - data: { - ...(first_name !== undefined && { first_name }), - ...(last_name !== undefined && { last_name }), - ...(email !== undefined && { email }), - ...(phone_number !== undefined && { phone_number }), - ...(residence !== undefined && { residence }), - }, - }); - } + // return this.prisma.$transaction(async (transaction) => { + // const user: Users = await transaction.users.create({ + // data: { + // first_name, + // last_name, + // email, + // phone_number, + // residence, + // }, + // }); + // return transaction.employees.create({ + // data: { + // user_id: user.id, + // external_payroll_id, + // company_code, + // job_title, + // first_work_day, + // last_work_day, + // is_supervisor, + // }, + // }); + // }); + // } - const updated = await transaction.employees.update({ - where: { id: emp.id }, - data: { - ...(external_payroll_id !== undefined && { external_payroll_id }), - ...(company_code !== undefined && { company_code }), - ...(first_work_day !== undefined && { first_work_day }), - ...(last_work_day !== undefined && { last_work_day }), - ...(job_title !== undefined && { job_title }), - ...(is_supervisor !== undefined && { is_supervisor }), - }, - }); - return updated; - }); - } + // findAll(): Promise { + // return this.prisma.employees.findMany({ + // include: { user: true }, + // }); + // } + + // async findOne(email: string): Promise { + // const emp = await this.prisma.employees.findFirst({ + // where: { user: { email } }, + // include: { user: true }, + // }); + + // //add search for archived employees + // if (!emp) { + // throw new NotFoundException(`Employee with email: ${email} not found`); + // } + // return emp; + // } + + // async update( + // email: string, + // dto: UpdateEmployeeDto, + // ): Promise { + // const emp = await this.findOne(email); + + // const { + // first_name, + // last_name, + // phone_number, + // residence, + // external_payroll_id, + // company_code, + // job_title, + // first_work_day, + // last_work_day, + // is_supervisor, + // email: new_email, + // } = dto; + + // return this.prisma.$transaction(async (transaction) => { + // if( + // first_name !== undefined || + // last_name !== undefined || + // new_email !== undefined || + // phone_number !== undefined || + // residence !== undefined + // ){ + // await transaction.users.update({ + // where: { id: emp.user_id }, + // data: { + // ...(first_name !== undefined && { first_name }), + // ...(last_name !== undefined && { last_name }), + // ...(email !== undefined && { email }), + // ...(phone_number !== undefined && { phone_number }), + // ...(residence !== undefined && { residence }), + // }, + // }); + // } + + // const updated = await transaction.employees.update({ + // where: { id: emp.id }, + // data: { + // ...(external_payroll_id !== undefined && { external_payroll_id }), + // ...(company_code !== undefined && { company_code }), + // ...(first_work_day !== undefined && { first_work_day }), + // ...(last_work_day !== undefined && { last_work_day }), + // ...(job_title !== undefined && { job_title }), + // ...(is_supervisor !== undefined && { is_supervisor }), + // }, + // }); + // return updated; + // }); + // } - async remove(email: string): Promise { + // async remove(email: string): Promise { - const emp = await this.findOne(email); + // const emp = await this.findOne(email); - return this.prisma.$transaction(async (transaction) => { - await transaction.employees.updateMany({ - where: { supervisor_id: emp.id }, - data: { supervisor_id: null }, - }); - const deleted_employee = await transaction.employees.delete({ - where: {id: emp.id }, - }); - await transaction.users.delete({ - where: { id: emp.user_id }, - }); - return deleted_employee; - }); - } + // return this.prisma.$transaction(async (transaction) => { + // await transaction.employees.updateMany({ + // where: { supervisor_id: emp.id }, + // data: { supervisor_id: null }, + // }); + // const deleted_employee = await transaction.employees.delete({ + // where: {id: emp.id }, + // }); + // await transaction.users.delete({ + // where: { id: emp.user_id }, + // }); + // return deleted_employee; + // }); + // } - //archivation functions ****************************************************** - -async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise { - // 1) Tenter sur employés actifs - const active = await this.prisma.employees.findFirst({ - where: { user: { email } }, - include: { user: true }, - }); - - if (active) { - // Archivage : si on reçoit un last_work_day défini et que l'employé n’est pas déjà terminé - if (dto.last_work_day !== undefined && active.last_work_day == null && dto.last_work_day !== null) { - return this.archiveOnTermination(active, dto); - } - - // Sinon, update standard (split Users/Employees) - const { - first_name, - last_name, - email: new_email, - phone_number, - residence, - external_payroll_id, - company_code, - job_title, - first_work_day, - last_work_day, - supervisor_id, - is_supervisor, - } = dto as any; - - const first_work_d = toDateOrUndefined(first_work_day); - const last_work_d = Object.prototype.hasOwnProperty('last_work_day') - ? toDateOrNull(last_work_day ?? null) - : undefined; - - await this.prisma.$transaction(async (transaction) => { - if( - first_name !== undefined || - last_name !== undefined || - new_email !== undefined || - phone_number !== undefined || - residence !== undefined - ) { - await transaction.users.update({ - where: { id: active.user_id }, - data: { - ...(first_name !== undefined ? { first_name } : {}), - ...(last_name !== undefined ? { last_name } : {}), - ...(email !== undefined ? { email: new_email }: {}), - ...(phone_number !== undefined ? { phone_number } : {}), - ...(residence !== undefined ? { residence } : {}), - }, - }); - - } - - const updated = await transaction.employees.update({ - where: { id: active.id }, - data: { - ...(external_payroll_id !== undefined ? { external_payroll_id } : {}), - ...(company_code !== undefined ? { company_code } : {}), - ...(job_title !== undefined ? { job_title } : {}), - ...(first_work_d !== undefined ? { first_work_day: first_work_d } : {}), - ...(last_work_d !== undefined ? { last_work_day: last_work_d } : {}), - ...(is_supervisor !== undefined ? { is_supervisor } : {}), - ...(supervisor_id !== undefined ? { supervisor_id } : {}), - }, - include: { user: true }, - }); - - return updated; - }); - - return this.prisma.employees.findFirst({ where: { user: {email} } }); - } - - const user = await this.prisma.users.findUnique({where: {email}}); - if(!user) return null; - // 2) Pas trouvé en actifs → regarder en archive (pour restauration) - const archived = await this.prisma.employeesArchive.findFirst({ - where: { user_id: user.id }, - include: { user: true }, - }); - - if (archived) { - // Condition de restauration : last_work_day === null ou first_work_day fourni - const restore = dto.last_work_day === null || dto.first_work_day != null; - if (restore) { - return this.restoreEmployee(archived, dto); - } - } - // 3) Ni actif, ni archivé → 404 dans le controller - return null; - } - - //transfers the employee to archive and then delete from employees table - private async archiveOnTermination(active: Employees & {user: Users }, dto: UpdateEmployeeDto): Promise { - const last_work_d = toDateOrNull(dto.last_work_day!); - if(!last_work_d) throw new Error('invalide last_work_day for archive'); - return this.prisma.$transaction(async transaction => { - //detach crew from supervisor if employee is a supervisor - await transaction.employees.updateMany({ - where: { supervisor_id: active.id }, - data: { supervisor_id: null }, - }) - const archived = await transaction.employeesArchive.create({ - data: { - employee_id: active.id, - user_id: active.user_id, - first_name: active.user.first_name, - last_name: active.user.last_name, - external_payroll_id: active.external_payroll_id, - company_code: active.company_code, - job_title: active.job_title, - first_work_day: active.first_work_day, - last_work_day: last_work_d, - supervisor_id: active.supervisor_id ?? null, - is_supervisor: active.is_supervisor, - }, - include: { user: true} - }); - //delete from employees table - await transaction.employees.delete({ where: { id: active.id } }); - //return archived employee - return archived - }); - } - //transfers the employee from archive to the employees table - private async restoreEmployee(archived: EmployeesArchive & { user:Users }, dto: UpdateEmployeeDto): Promise { - // const first_work_d = toDateOrUndefined(dto.first_work_day); - return this.prisma.$transaction(async transaction => { - //restores the archived employee into the employees table - const restored = await transaction.employees.create({ - data: { - user_id: archived.user_id, - external_payroll_id: archived.external_payroll_id, - company_code: archived.company_code, - job_title: archived.job_title, - first_work_day: archived.first_work_day, - last_work_day: null, - is_supervisor: archived.is_supervisor ?? false, - }, - }); - //deleting archived entry by id - await transaction.employeesArchive.delete({ where: { id: archived.id } }); - - //return restored employee - return restored; - }); - } - - //fetches all archived employees - async findAllArchived(): Promise { - return this.prisma.employeesArchive.findMany(); - } - - //fetches an archived employee - async findOneArchived(id: number): Promise { - return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } }); - } - -} +} \ No newline at end of file diff --git a/src/modules/employees/utils/employee.utils.ts b/src/modules/employees/utils/employee.utils.ts new file mode 100644 index 0000000..3534f3d --- /dev/null +++ b/src/modules/employees/utils/employee.utils.ts @@ -0,0 +1,9 @@ +export function toDateOrNull(v?: string | null): Date | null { + if (!v) return null; + const day = new Date(v); + return isNaN(day.getTime()) ? null : day; +} +export function toDateOrUndefined(v?: string | null): Date | undefined { + const day = toDateOrNull(v ?? undefined); + return day === null ? undefined : day; +} \ No newline at end of file diff --git a/src/modules/expenses/controllers/expenses.controller.ts b/src/modules/expenses/controllers/expenses.controller.ts index c352394..03173e9 100644 --- a/src/modules/expenses/controllers/expenses.controller.ts +++ b/src/modules/expenses/controllers/expenses.controller.ts @@ -1,13 +1,8 @@ -import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; -import { ExpensesQueryService } from "../services/expenses-query.service"; -import { CreateExpenseDto } from "../dtos/create-expense.dto"; -import { Expenses } from "@prisma/client"; +import { Body, Controller, Param, Put, } from "@nestjs/common"; import { Roles as RoleEnum } from '.prisma/client'; -import { UpdateExpenseDto } from "../dtos/update-expense.dto"; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { ExpensesCommandService } from "../services/expenses-command.service"; -import { SearchExpensesDto } from "../dtos/search-expense.dto"; import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces"; @@ -17,7 +12,7 @@ import { UpsertExpenseResult } from "../types and interfaces/expenses.types.inte @Controller('Expenses') export class ExpensesController { constructor( - private readonly query: ExpensesQueryService, + // private readonly query: ExpensesQueryService, private readonly command: ExpensesCommandService, ) {} diff --git a/src/modules/expenses/expenses.module.ts b/src/modules/expenses/expenses.module.ts index 3948e70..39f1357 100644 --- a/src/modules/expenses/expenses.module.ts +++ b/src/modules/expenses/expenses.module.ts @@ -6,12 +6,14 @@ import { ExpensesCommandService } from "./services/expenses-command.service"; import { BankCodesRepo } from "./repos/bank-codes.repo"; import { TimesheetsRepo } from "./repos/timesheets.repo"; import { EmployeesRepo } from "./repos/employee.repo"; +import { ExpensesArchivalService } from "./services/expenses-archival.service"; @Module({ imports: [BusinessLogicsModule], controllers: [ExpensesController], providers: [ - ExpensesQueryService, + ExpensesQueryService, + ExpensesArchivalService, ExpensesCommandService, BankCodesRepo, TimesheetsRepo, @@ -19,6 +21,7 @@ import { EmployeesRepo } from "./repos/employee.repo"; ], exports: [ ExpensesQueryService, + ExpensesArchivalService, BankCodesRepo, TimesheetsRepo, EmployeesRepo, diff --git a/src/modules/expenses/services/expenses-archival.service.ts b/src/modules/expenses/services/expenses-archival.service.ts new file mode 100644 index 0000000..fc17c63 --- /dev/null +++ b/src/modules/expenses/services/expenses-archival.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from "@nestjs/common"; +import { ExpensesArchive } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class ExpensesArchivalService { + constructor(private readonly prisma: PrismaService){} + + async archiveOld(): Promise { + //fetches archived timesheet's Ids + const archived_timesheets = await this.prisma.timesheetsArchive.findMany({ + select: { timesheet_id: true }, + }); + + const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id); + if(timesheet_ids.length === 0) { + return; + } + + // copy/delete transaction + await this.prisma.$transaction(async transaction => { + //fetches expenses to move to archive + const expenses_to_archive = await transaction.expenses.findMany({ + where: { timesheet_id: { in: timesheet_ids } }, + }); + if(expenses_to_archive.length === 0) { + return; + } + + //copies sent to archive table + await transaction.expensesArchive.createMany({ + data: expenses_to_archive.map(exp => ({ + expense_id: exp.id, + timesheet_id: exp.timesheet_id, + bank_code_id: exp.bank_code_id, + date: exp.date, + amount: exp.amount, + attachment: exp.attachment, + comment: exp.comment, + is_approved: exp.is_approved, + supervisor_comment: exp.supervisor_comment, + })), + }); + + //delete from expenses table + await transaction.expenses.deleteMany({ + where: { id: { in: expenses_to_archive.map(exp => exp.id) } }, + }) + + }) + } + + //fetches all archived timesheets + async findAllArchived(): Promise { + return this.prisma.expensesArchive.findMany(); + } + + //fetches an archived timesheet + async findOneArchived(id: number): Promise { + return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } }); + } +} \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index ae8af6b..9ec2604 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -14,9 +14,11 @@ import { import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; import { assertAndTrimComment, + computeAmountDecimal, computeMileageAmount, mapDbExpenseToDayResponse, - normalizeType as normalizeTypeUtil + normalizeType, + parseAttachmentId } from "../utils/expenses.utils"; @Injectable() @@ -25,9 +27,13 @@ export class ExpensesCommandService extends BaseApprovalService { prisma: PrismaService, private readonly bankCodesRepo: BankCodesRepo, private readonly timesheetsRepo: TimesheetsRepo, - private readonly employeesRepo: EmployeesRepo, + private readonly employeesRepo: EmployeesRepo, ) { super(prisma); } + //_____________________________________________________________________________________________ + // APPROVAL TX-DELEGATE METHODS + //_____________________________________________________________________________________________ + protected get delegate() { return this.prisma.expenses; } @@ -42,7 +48,9 @@ export class ExpensesCommandService extends BaseApprovalService { ); } - //-------------------- Master CRUD function -------------------- + //_____________________________________________________________________________________________ + // MASTER CRUD FUNCTION + //_____________________________________________________________________________________________ readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto, ): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => { @@ -82,7 +90,7 @@ export class ExpensesCommandService extends BaseApprovalService { }); return rows.map((r) => - this.mapDbToDayResponse({ + mapDbExpenseToDayResponse({ date: r.date, amount: r.amount ?? 0, mileage: r.mileage ?? 0, @@ -106,12 +114,12 @@ export class ExpensesCommandService extends BaseApprovalService { comment: string; attachment: number | null; }> => { - const type = this.normalizeType(payload.type); - const comment = this.assertAndTrimComment(payload.comment); - const attachment = this.parseAttachmentId(payload.attachment); + const type = normalizeType(payload.type); + const comment = assertAndTrimComment(payload.comment); + const attachment = parseAttachmentId(payload.attachment); - const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type); - let amount = this.computeAmountDecimal(type, payload, modifier); + const { id: bank_code_id, modifier } = await this.resolveBankCodeIdByType(tx, type); + let amount = computeAmountDecimal(type, payload, modifier); let mileage: number | null = null; if (type === 'MILEAGE') { @@ -172,7 +180,9 @@ export class ExpensesCommandService extends BaseApprovalService { }; let action : UpsertAction; - //-------------------- DELETE -------------------- + //_____________________________________________________________________________________________ + // DELETE + //_____________________________________________________________________________________________ if(old_expense && !new_expense) { const oldNorm = await normalizePayload(old_expense); const existing = await findExactOld(oldNorm); @@ -185,7 +195,9 @@ export class ExpensesCommandService extends BaseApprovalService { await tx.expenses.delete({where: { id: existing.id } }); action = 'delete'; } - //-------------------- CREATE -------------------- + //_____________________________________________________________________________________________ + // CREATE + //_____________________________________________________________________________________________ else if (!old_expense && new_expense) { const new_exp = await normalizePayload(new_expense); await tx.expenses.create({ @@ -202,7 +214,9 @@ export class ExpensesCommandService extends BaseApprovalService { }); action = 'create'; } - //-------------------- UPDATE -------------------- + //_____________________________________________________________________________________________ + // UPDATE + //_____________________________________________________________________________________________ else if(old_expense && new_expense) { const oldNorm = await normalizePayload(old_expense); const existing = await findExactOld(oldNorm); @@ -236,40 +250,9 @@ export class ExpensesCommandService extends BaseApprovalService { }); } - - //-------------------- helpers -------------------- - private readonly normalizeType = (type: string): string => - normalizeTypeUtil(type); - - private readonly assertAndTrimComment = (comment: string): string => - assertAndTrimComment(comment); - - private readonly parseAttachmentId = (value: unknown): number | null => { - if (value == null) { - return null; - } - - if (typeof value === 'number') { - if (!Number.isInteger(value) || value <= 0) { - throw new BadRequestException('Invalid attachment id'); - } - return value; - } - - if (typeof value === 'string') { - - const trimmed = value.trim(); - if (!trimmed.length) return null; - if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id'); - - const parsed = Number(trimmed); - if (parsed <= 0) throw new BadRequestException('Invalid attachment id'); - - return parsed; - } - throw new BadRequestException('Invalid attachment id'); - }; - + //_____________________________________________________________________________________________ + // HELPERS + //_____________________________________________________________________________________________ private readonly resolveEmployeeIdByEmail = async (email: string): Promise => this.employeesRepo.findIdByEmail(email); @@ -280,34 +263,8 @@ export class ExpensesCommandService extends BaseApprovalService { return id; }; - private readonly lookupBankCodeOrThrow = async ( transaction: Prisma.TransactionClient, type: string + private readonly resolveBankCodeIdByType = async ( transaction: Prisma.TransactionClient, type: string ): Promise<{id: number; modifier: number}> => this.bankCodesRepo.findByType(type, transaction); - - private readonly computeAmountDecimal = ( - type: string, - payload: { - amount?: number; - mileage?: number; - }, - modifier: number, - ): Prisma.Decimal => { - if(type === 'MILEAGE') { - const km = payload.mileage ?? 0; - const amountNumber = computeMileageAmount(km, modifier); - return new Prisma.Decimal(amountNumber); - } - return new Prisma.Decimal(payload.amount!); - }; - - private readonly mapDbToDayResponse = (row: { - date: Date; - amount: Prisma.Decimal | number | string; - mileage: Prisma.Decimal | number | string; - comment: string; - is_approved: boolean; - bank_code: { type: string } | null; - }): ExpenseResponse => mapDbExpenseToDayResponse(row); - } \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index b719a79..e82e0a4 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -1,148 +1,93 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; -import { PrismaService } from "src/prisma/prisma.service"; -import { CreateExpenseDto } from "../dtos/create-expense.dto"; -import { Expenses, ExpensesArchive } from "@prisma/client"; -import { UpdateExpenseDto } from "../dtos/update-expense.dto"; -import { MileageService } from "src/modules/business-logics/services/mileage.service"; -import { SearchExpensesDto } from "../dtos/search-expense.dto"; -import { buildPrismaWhere } from "src/common/shared/build-prisma-where"; +import { Injectable } from "@nestjs/common"; @Injectable() export class ExpensesQueryService { - constructor( - private readonly prisma: PrismaService, - private readonly mileageService: MileageService, - ) {} + // constructor( + // private readonly prisma: PrismaService, + // private readonly mileageService: MileageService, + // ) {} + + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - async create(dto: CreateExpenseDto): Promise { - const { timesheet_id, bank_code_id, date, amount:rawAmount, - comment, is_approved,supervisor_comment} = dto; + // async create(dto: CreateExpenseDto): Promise { + // const { timesheet_id, bank_code_id, date, amount:rawAmount, + // comment, is_approved,supervisor_comment} = dto; + // //fetches type and modifier + // const bank_code = await this.prisma.bankCodes.findUnique({ + // where: { id: bank_code_id }, + // select: { type: true, modifier: true }, + // }); + // if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`); + // //if mileage -> service, otherwise the ratio is amount:1 + // let final_amount: number; + // if(bank_code.type === 'mileage') { + // final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id); + // }else { + // final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2)); + // } - //fetches type and modifier - const bank_code = await this.prisma.bankCodes.findUnique({ - where: { id: bank_code_id }, - select: { type: true, modifier: true }, - }); - if(!bank_code) { - throw new NotFoundException(`bank_code #${bank_code_id} not found`) - } + // return this.prisma.expenses.create({ + // data: { + // timesheet_id, + // bank_code_id, + // date, + // amount: final_amount, + // comment, + // is_approved, + // supervisor_comment + // }, + // include: { timesheet: { include: { employee: { include: { user: true }}}}, + // bank_code: true, + // }, + // }) + // } - //if mileage -> service, otherwise the ratio is amount:1 - let final_amount: number; - if(bank_code.type === 'mileage') { - final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id); - }else { - final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2)); - } + // async findAll(filters: SearchExpensesDto): Promise { + // const where = buildPrismaWhere(filters); + // const expenses = await this.prisma.expenses.findMany({ where }) + // return expenses; + // } - return this.prisma.expenses.create({ - data: { timesheet_id, bank_code_id, date, amount: final_amount, comment, is_approved, supervisor_comment}, - include: { timesheet: { include: { employee: { include: { user: true }}}}, - bank_code: true, - }, - }) - } + // async findOne(id: number): Promise { + // const expense = await this.prisma.expenses.findUnique({ + // where: { id }, + // include: { timesheet: { include: { employee: { include: { user:true } } } }, + // bank_code: true, + // }, + // }); + // if (!expense) { + // throw new NotFoundException(`Expense #${id} not found`); + // } + // return expense; + // } - async findAll(filters: SearchExpensesDto): Promise { - const where = buildPrismaWhere(filters); - const expenses = await this.prisma.expenses.findMany({ where }) - return expenses; - } + // async update(id: number, dto: UpdateExpenseDto): Promise { + // await this.findOne(id); + // const { timesheet_id, bank_code_id, date, amount, + // comment, is_approved, supervisor_comment} = dto; + // return this.prisma.expenses.update({ + // where: { id }, + // data: { + // ...(timesheet_id !== undefined && { timesheet_id}), + // ...(bank_code_id !== undefined && { bank_code_id }), + // ...(date !== undefined && { date }), + // ...(amount !== undefined && { amount }), + // ...(comment !== undefined && { comment }), + // ...(is_approved !== undefined && { is_approved }), + // ...(supervisor_comment !== undefined && { supervisor_comment }), + // }, + // include: { timesheet: { include: { employee: { include: { user: true } } } }, + // bank_code: true, + // }, + // }); + // } - async findOne(id: number): Promise { - const expense = await this.prisma.expenses.findUnique({ - where: { id }, - include: { timesheet: { include: { employee: { include: { user:true } } } }, - bank_code: true, - }, - }); - if (!expense) { - throw new NotFoundException(`Expense #${id} not found`); - } - return expense; - } + // async remove(id: number): Promise { + // await this.findOne(id); + // return this.prisma.expenses.delete({ where: { id } }); + // } - async update(id: number, dto: UpdateExpenseDto): Promise { - await this.findOne(id); - const { timesheet_id, bank_code_id, date, amount, - comment, is_approved, supervisor_comment} = dto; - return this.prisma.expenses.update({ - where: { id }, - data: { - ...(timesheet_id !== undefined && { timesheet_id}), - ...(bank_code_id !== undefined && { bank_code_id }), - ...(date !== undefined && { date }), - ...(amount !== undefined && { amount }), - ...(comment !== undefined && { comment }), - ...(is_approved !== undefined && { is_approved }), - ...(supervisor_comment !== undefined && { supervisor_comment }), - }, - include: { timesheet: { include: { employee: { include: { user: true } } } }, - bank_code: true, - }, - }); - } - - async remove(id: number): Promise { - await this.findOne(id); - return this.prisma.expenses.delete({ where: { id } }); - } - - - //archivation functions ****************************************************** - - async archiveOld(): Promise { - //fetches archived timesheet's Ids - const archived_timesheets = await this.prisma.timesheetsArchive.findMany({ - select: { timesheet_id: true }, - }); - - const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id); - if(timesheet_ids.length === 0) { - return; - } - - // copy/delete transaction - await this.prisma.$transaction(async transaction => { - //fetches expenses to move to archive - const expenses_to_archive = await transaction.expenses.findMany({ - where: { timesheet_id: { in: timesheet_ids } }, - }); - if(expenses_to_archive.length === 0) { - return; - } - - //copies sent to archive table - await transaction.expensesArchive.createMany({ - data: expenses_to_archive.map(exp => ({ - expense_id: exp.id, - timesheet_id: exp.timesheet_id, - bank_code_id: exp.bank_code_id, - date: exp.date, - amount: exp.amount, - attachment: exp.attachment, - comment: exp.comment, - is_approved: exp.is_approved, - supervisor_comment: exp.supervisor_comment, - })), - }); - - //delete from expenses table - await transaction.expenses.deleteMany({ - where: { id: { in: expenses_to_archive.map(exp => exp.id) } }, - }) - - }) - } - - //fetches all archived timesheets - async findAllArchived(): Promise { - return this.prisma.expensesArchive.findMany(); - } - - //fetches an archived timesheet - async findOneArchived(id: number): Promise { - return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } }); - } } \ No newline at end of file diff --git a/src/modules/expenses/utils/expenses.utils.ts b/src/modules/expenses/utils/expenses.utils.ts index 87e2120..6959bde 100644 --- a/src/modules/expenses/utils/expenses.utils.ts +++ b/src/modules/expenses/utils/expenses.utils.ts @@ -46,6 +46,32 @@ export function toNumberSafe(value: DecimalLike): number { ); } +export const parseAttachmentId = (value: unknown): number | null => { + if (value == null) { + return null; + } + + if (typeof value === 'number') { + if (!Number.isInteger(value) || value <= 0) { + throw new BadRequestException('Invalid attachment id'); + } + return value; + } + + if (typeof value === 'string') { + + const trimmed = value.trim(); + if (!trimmed.length) return null; + if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id'); + + const parsed = Number(trimmed); + if (parsed <= 0) throw new BadRequestException('Invalid attachment id'); + + return parsed; + } + throw new BadRequestException('Invalid attachment id'); +}; + //map of a row for DayExpenseResponse export function mapDbExpenseToDayResponse(row: { @@ -66,4 +92,20 @@ export function mapDbExpenseToDayResponse(row: { is_approved: row.is_approved, ...(row.mileage !== null ? { mileage: toNum(row.mileage) }: {}), }; -} \ No newline at end of file +} + + export const computeAmountDecimal = ( + type: string, + payload: { + amount?: number; + mileage?: number; + }, + modifier: number, + ): Prisma.Decimal => { + if(type === 'MILEAGE') { + const km = payload.mileage ?? 0; + const amountNumber = computeMileageAmount(km, modifier); + return new Prisma.Decimal(amountNumber); + } + return new Prisma.Decimal(payload.amount!); + }; diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index c936026..f0bd218 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -1,15 +1,12 @@ -import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; -import { Shifts } from "@prisma/client"; -import { CreateShiftDto } from "../dtos/create-shift.dto"; -import { UpdateShiftsDto } from "../dtos/update-shift.dto"; +import { Body, Controller, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { Roles as RoleEnum } from '.prisma/client'; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { ShiftsCommandService } from "../services/shifts-command.service"; -import { SearchShiftsDto } from "../dtos/search-shift.dto"; -import { OverviewRow, ShiftsQueryService } from "../services/shifts-query.service"; +import { ShiftsQueryService } from "../services/shifts-query.service"; import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto"; import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; +import { OverviewRow } from "../types and interfaces/shifts-overview-row.interface"; @ApiTags('Shifts') @ApiBearerAuth('access-token') diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts index 94ecf5e..3e9e7f6 100644 --- a/src/modules/shifts/helpers/shifts-date-time-helpers.ts +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -16,3 +16,10 @@ export function toDateOnlyUTC(input: string | Date): Date { const date = new Date(input); return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); } + +export function formatHHmm(time: Date): string { + const hh = String(time.getUTCHours()).padStart(2,'0'); + const mm = String(time.getUTCMinutes()).padStart(2,'0'); + return `${hh}:${mm}`; +} + diff --git a/src/modules/shifts/services/shifts-archival.service.ts b/src/modules/shifts/services/shifts-archival.service.ts new file mode 100644 index 0000000..667ba3a --- /dev/null +++ b/src/modules/shifts/services/shifts-archival.service.ts @@ -0,0 +1,59 @@ +import { ShiftsArchive } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + +export class ShiftsArchivalService { + constructor(private readonly prisma: PrismaService){} + + async archiveOld(): Promise { + //fetches archived timesheet's Ids + const archived_timesheets = await this.prisma.timesheetsArchive.findMany({ + select: { timesheet_id: true }, + }); + + const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id); + if(timesheet_ids.length === 0) { + return; + } + + // copy/delete transaction + await this.prisma.$transaction(async transaction => { + //fetches shifts to move to archive + const shifts_to_archive = await transaction.shifts.findMany({ + where: { timesheet_id: { in: timesheet_ids } }, + }); + if(shifts_to_archive.length === 0) { + return; + } + + //copies sent to archive table + await transaction.shiftsArchive.createMany({ + data: shifts_to_archive.map(shift => ({ + shift_id: shift.id, + timesheet_id: shift.timesheet_id, + bank_code_id: shift.bank_code_id, + comment: shift.comment ?? undefined, + date: shift.date, + start_time: shift.start_time, + end_time: shift.end_time, + })), + }); + + //delete from shifts table + await transaction.shifts.deleteMany({ + where: { id: { in: shifts_to_archive.map(shift => shift.id) } }, + }) + + }) + } + + //fetches all archived timesheets + async findAllArchived(): Promise { + return this.prisma.shiftsArchive.findMany(); + } + + //fetches an archived timesheet + async findOneArchived(id: number): Promise { + return this.prisma.shiftsArchive.findUniqueOrThrow({ where: { id } }); + } + +} \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index cccfbcc..023196d 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,276 +1,19 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; -import { Prisma, Shifts } from "@prisma/client"; +import { formatHHmm, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers"; +import { normalizeShiftPayload, overlaps, resolveBankCodeByType } from "../utils/shifts.utils"; +import { DayShiftResponse, UpsertAction } from "../types and interfaces/shifts-upsert.types"; +import { Prisma, Shifts } from "@prisma/client"; +import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; -import { PrismaService } from "src/prisma/prisma.service"; -import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto"; -import { timeFromHHMMUTC, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers"; - -type DayShiftResponse = { - start_time: string; - end_time: string; - type: string; - is_remote: boolean; - comment: string | null; -} - -type UpsertAction = 'created' | 'updated' | 'deleted'; +import { PrismaService } from "src/prisma/prisma.service"; @Injectable() export class ShiftsCommandService extends BaseApprovalService { constructor(prisma: PrismaService) { super(prisma); } -//create/update/delete master method -async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto): - Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { - const { old_shift, new_shift } = dto; - - if(!dto.old_shift && !dto.new_shift) { - throw new BadRequestException('At least one of old or new shift must be provided'); - } - - const date_only = toDateOnlyUTC(date_string); - - //Resolve employee by email - const employee = await this.prisma.employees.findFirst({ - where: { user: {email } }, - select: { id: true }, - }); - if(!employee) throw new NotFoundException(`Employee not found for email : ${ email }`); - - //making sure a timesheet exist in selected week - const start_of_week = weekStartMondayUTC(date_only); - let timesheet = await this.prisma.timesheets.findFirst({ - where: { - employee_id: employee.id, - start_date: start_of_week - }, - select: { - id: true - }, - }); - if(!timesheet) { - timesheet = await this.prisma.timesheets.create({ - data: { - employee_id: employee.id, - start_date: start_of_week - }, - select: { - id: true - }, - }); - } - - //normalization of data to ensure a valid comparison between DB and payload - const old_norm = dto.old_shift - ? this.normalize_shift_payload(dto.old_shift) - : undefined; - const new_norm = dto.new_shift - ? this.normalize_shift_payload(dto.new_shift) - : undefined; - - if (old_norm && old_norm.end_time.getTime() <= old_norm.start_time.getTime()) { - throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); - } - if (new_norm && new_norm.end_time.getTime() <= new_norm.start_time.getTime()) { - throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); - } - - //Resolve bank_code_id with type - const old_bank_code_id = old_norm - ? await this.lookup_bank_code_id_or_throw(old_norm.type) - : undefined; - const new_bank_code_id = new_norm - ? await this.lookup_bank_code_id_or_throw(new_norm.type) - : undefined; - - //fetch all shifts in a single day - const day_shifts = await this.prisma.shifts.findMany({ - where: { - timesheet_id: timesheet.id, - date: date_only - }, - include: { - bank_code: true - }, - orderBy: { - start_time: 'asc' - }, - }); - - const result = await this.prisma.$transaction(async (transaction)=> { - let action: UpsertAction; - - const find_exact_old_shift = async ()=> { - if(!old_norm || old_bank_code_id === undefined) return undefined; - const old_comment = old_norm.comment ?? null; - - return transaction.shifts.findFirst({ - where: { - timesheet_id: timesheet.id, - date: date_only, - start_time: old_norm.start_time, - end_time: old_norm.end_time, - is_remote: old_norm.is_remote, - comment: old_comment, - bank_code_id: old_bank_code_id, - }, - select: { id: true }, - }); - }; - - //checks for overlaping shifts - const assert_no_overlap = (exclude_shift_id?: number)=> { - if (!new_norm) return; - - const overlap_with = day_shifts.filter((shift)=> { - if(exclude_shift_id && shift.id === exclude_shift_id) return false; - return this.overlaps( - new_norm.start_time.getTime(), - new_norm.end_time.getTime(), - shift.start_time.getTime(), - shift.end_time.getTime(), - ); - }); - - if(overlap_with.length > 0) { - const conflicts = overlap_with.map((shift)=> ({ - start_time: this.format_hhmm(shift.start_time), - end_time: this.format_hhmm(shift.end_time), - type: shift.bank_code?.type ?? 'UNKNOWN', - })); - throw new ConflictException({ - error_code: 'SHIFT_OVERLAP', - message: 'New shift overlaps with existing shift(s)', - conflicts, - }); - } - }; - - // DELETE - if ( old_shift && !new_shift ) { - const existing = await find_exact_old_shift(); - if(!existing) { - throw new NotFoundException({ - error_code: 'SHIFT_STALE', - message: 'The shift was modified or deleted by someone else', - }); - } - await transaction.shifts.delete({ where: { id: existing.id } } ); - action = 'deleted'; - } - // CREATE - else if (!old_shift && new_shift) { - assert_no_overlap(); - await transaction.shifts.create({ - data: { - timesheet_id: timesheet.id, - date: date_only, - start_time: new_norm!.start_time, - end_time: new_norm!.end_time, - is_remote: new_norm!.is_remote, - comment: new_norm!.comment ?? null, - bank_code_id: new_bank_code_id!, - }, - }); - action = 'created'; - } - //UPDATE - else if (old_shift && new_shift){ - const existing = await find_exact_old_shift(); - if(!existing) { - throw new NotFoundException({ - error_code: 'SHIFT_STALE', - message: 'The shift was modified or deleted by someone else', - }); - } - assert_no_overlap(existing.id); - await transaction.shifts.update({ - where: { - id: existing.id - }, - data: { - start_time: new_norm!.start_time, - end_time: new_norm!.end_time, - is_remote: new_norm!.is_remote, - comment: new_norm!.comment ?? null, - bank_code_id: new_bank_code_id, - }, - }); - action = 'updated'; - } else { - throw new BadRequestException('At least one of old_shift or new_shift must be provided'); - } - - //Reload the day (truth source) - const fresh_day = await transaction.shifts.findMany({ - where: { - timesheet_id: timesheet.id, - date: date_only, - }, - include: { - bank_code: true - }, - orderBy: { - start_time: 'asc' - }, - }); - - return { - action, - day: fresh_day.map((shift)=> ({ - start_time: this.format_hhmm(shift.start_time), - end_time: this.format_hhmm(shift.end_time), - type: shift.bank_code?.type ?? 'UNKNOWN', - is_remote: shift.is_remote, - comment: shift.comment ?? null, - })), - }; - }); - return result; - } - - private normalize_shift_payload(payload: ShiftPayloadDto) { - //normalize shift's infos - const start_time = timeFromHHMMUTC(payload.start_time); - const end_time = timeFromHHMMUTC(payload.end_time ); - const type = (payload.type || '').trim().toUpperCase(); - const is_remote = payload.is_remote === true; - //normalize comment - const raw_comment = payload.comment ?? null; - const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null; - const comment = trimmed && trimmed.length > 0 ? trimmed: null; - - return { start_time, end_time, type, is_remote, comment }; - } - - private async lookup_bank_code_id_or_throw(type: string): Promise { - const bank = await this.prisma.bankCodes.findFirst({ - where: { type }, - select: { id: true }, - }); - if (!bank) { - throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` }); - } - return bank.id; - } - - private overlaps( - a_start_ms: number, - a_end_ms: number, - b_start_ms: number, - b_end_ms: number, - ): boolean { - return a_start_ms < b_end_ms && b_start_ms < a_end_ms; - } - - private format_hhmm(time: Date): string { - const hh = String(time.getUTCHours()).padStart(2,'0'); - const mm = String(time.getUTCMinutes()).padStart(2,'0'); - return `${hh}:${mm}`; - } - - //approval methods - +//_____________________________________________________________________________________________ +// APPROVAL AND DELEGATE METHODS +//_____________________________________________________________________________________________ protected get delegate() { return this.prisma.shifts; } @@ -284,4 +27,221 @@ async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto) this.updateApprovalWithTransaction(transaction, id, is_approved), ); } + +//_____________________________________________________________________________________________ +// MASTER CRUD METHOD +//_____________________________________________________________________________________________ + async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto): + Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { + const { old_shift, new_shift } = dto; + + if(!dto.old_shift && !dto.new_shift) { + throw new BadRequestException('At least one of old or new shift must be provided'); + } + + const date_only = toDateOnlyUTC(date_string); + + //Resolve employee by email + const employee = await this.prisma.employees.findFirst({ + where: { user: {email } }, + select: { id: true }, + }); + if(!employee) throw new NotFoundException(`Employee not found for email : ${ email }`); + + //making sure a timesheet exist in selected week + const start_of_week = weekStartMondayUTC(date_only); + let timesheet = await this.prisma.timesheets.findFirst({ + where: { + employee_id: employee.id, + start_date: start_of_week + }, + select: { + id: true + }, + }); + if(!timesheet) { + timesheet = await this.prisma.timesheets.create({ + data: { + employee_id: employee.id, + start_date: start_of_week + }, + select: { + id: true + }, + }); + } + + //normalization of data to ensure a valid comparison between DB and payload + const old_norm = dto.old_shift + ? normalizeShiftPayload(dto.old_shift) + : undefined; + const new_norm = dto.new_shift + ? normalizeShiftPayload(dto.new_shift) + : undefined; + + if (old_norm && old_norm.end_time.getTime() <= old_norm.start_time.getTime()) { + throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); + } + if (new_norm && new_norm.end_time.getTime() <= new_norm.start_time.getTime()) { + throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); + } + + //Resolve bank_code_id with type + const old_bank_code_id = old_norm + ? await resolveBankCodeByType(old_norm.type) + : undefined; + const new_bank_code_id = new_norm + ? await resolveBankCodeByType(new_norm.type) + : undefined; + + //fetch all shifts in a single day + const day_shifts = await this.prisma.shifts.findMany({ + where: { + timesheet_id: timesheet.id, + date: date_only + }, + include: { + bank_code: true + }, + orderBy: { + start_time: 'asc' + }, + }); + + const result = await this.prisma.$transaction(async (transaction)=> { + let action: UpsertAction; + + const findExactOldShift = async ()=> { + if(!old_norm || old_bank_code_id === undefined) return undefined; + const old_comment = old_norm.comment ?? null; + + return transaction.shifts.findFirst({ + where: { + timesheet_id: timesheet.id, + date: date_only, + start_time: old_norm.start_time, + end_time: old_norm.end_time, + is_remote: old_norm.is_remote, + comment: old_comment, + bank_code_id: old_bank_code_id, + }, + select: { id: true }, + }); + }; + + //checks for overlaping shifts + const assertNoOverlap = (exclude_shift_id?: number)=> { + if (!new_norm) return; + const overlap_with = day_shifts.filter((shift)=> { + if(exclude_shift_id && shift.id === exclude_shift_id) return false; + return overlaps( + new_norm.start_time.getTime(), + new_norm.end_time.getTime(), + shift.start_time.getTime(), + shift.end_time.getTime(), + ); + }); + + if(overlap_with.length > 0) { + const conflicts = overlap_with.map((shift)=> ({ + start_time: formatHHmm(shift.start_time), + end_time: formatHHmm(shift.end_time), + type: shift.bank_code?.type ?? 'UNKNOWN', + })); + throw new ConflictException({ + error_code: 'SHIFT_OVERLAP', + message: 'New shift overlaps with existing shift(s)', + conflicts, + }); + } + }; + + //_____________________________________________________________________________________________ + // DELETE + //_____________________________________________________________________________________________ + if ( old_shift && !new_shift ) { + const existing = await findExactOldShift(); + if(!existing) { + throw new NotFoundException({ + error_code: 'SHIFT_STALE', + message: 'The shift was modified or deleted by someone else', + }); + } + await transaction.shifts.delete({ where: { id: existing.id } } ); + action = 'deleted'; + } + //_____________________________________________________________________________________________ + // CREATE + //_____________________________________________________________________________________________ + else if (!old_shift && new_shift) { + assertNoOverlap(); + await transaction.shifts.create({ + data: { + timesheet_id: timesheet.id, + date: date_only, + start_time: new_norm!.start_time, + end_time: new_norm!.end_time, + is_remote: new_norm!.is_remote, + comment: new_norm!.comment ?? null, + bank_code_id: new_bank_code_id!, + }, + }); + action = 'created'; + } + //_____________________________________________________________________________________________ + // UPDATE + //_____________________________________________________________________________________________ + else if (old_shift && new_shift){ + const existing = await findExactOldShift(); + if(!existing) { + throw new NotFoundException({ + error_code: 'SHIFT_STALE', + message: 'The shift was modified or deleted by someone else', + }); + } + assertNoOverlap(existing.id); + await transaction.shifts.update({ + where: { + id: existing.id + }, + data: { + start_time: new_norm!.start_time, + end_time: new_norm!.end_time, + is_remote: new_norm!.is_remote, + comment: new_norm!.comment ?? null, + bank_code_id: new_bank_code_id, + }, + }); + action = 'updated'; + } else { + throw new BadRequestException('At least one of old_shift or new_shift must be provided'); + } + + //Reload the day (truth source) + const fresh_day = await transaction.shifts.findMany({ + where: { + date: date_only, + timesheet_id: timesheet.id, + }, + include: { + bank_code: true + }, + orderBy: { + start_time: 'asc' + }, + }); + + return { + action, + day: fresh_day.map((shift)=> ({ + start_time: formatHHmm(shift.start_time), + end_time: formatHHmm(shift.end_time), + type: shift.bank_code?.type ?? 'UNKNOWN', + is_remote: shift.is_remote, + comment: shift.comment ?? null, + })), + }; + }); + return result; + } } \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-query.service.ts b/src/modules/shifts/services/shifts-query.service.ts index cd1c286..0d6bc6f 100644 --- a/src/modules/shifts/services/shifts-query.service.ts +++ b/src/modules/shifts/services/shifts-query.service.ts @@ -1,25 +1,10 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; -import { CreateShiftDto } from "../dtos/create-shift.dto"; -import { Shifts, ShiftsArchive } from "@prisma/client"; -import { UpdateShiftsDto } from "../dtos/update-shift.dto"; -import { buildPrismaWhere } from "src/common/shared/build-prisma-where"; -import { SearchShiftsDto } from "../dtos/search-shift.dto"; import { NotificationsService } from "src/modules/notifications/services/notifications.service"; -import { computeHours, hoursBetweenSameDay } from "src/common/utils/date-utils"; +import { computeHours } from "src/common/utils/date-utils"; +import { OverviewRow } from "../types and interfaces/shifts-overview-row.interface"; -const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12); - -export interface OverviewRow { - full_name: string; - supervisor: string; - total_regular_hrs: number; - total_evening_hrs: number; - total_overtime_hrs: number; - total_expenses: number; - total_mileage: number; - is_approved: boolean; -} +// const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12); @Injectable() export class ShiftsQueryService { @@ -28,93 +13,6 @@ export class ShiftsQueryService { private readonly notifs: NotificationsService, ) {} - async create(dto: CreateShiftDto): Promise { - const { timesheet_id, bank_code_id, date, start_time, end_time, comment } = dto; - - //shift creation - const shift = await this.prisma.shifts.create({ - data: { timesheet_id, bank_code_id, date, start_time, end_time, comment }, - include: { timesheet: { include: { employee: { include: { user: true } } } }, - bank_code: true, - }, - }); - - //fetches all shifts of the same day to check for daily overtime - const same_day_shifts = await this.prisma.shifts.findMany({ - where: { timesheet_id, date }, - select: { id: true, date: true, start_time: true, end_time: true }, - }); - - //sums hours of the day - const total_hours = same_day_shifts.reduce((sum, s) => { - return sum + hoursBetweenSameDay(s.date, s.start_time, s.end_time); - }, 0 ); - - //Notify if total hours > 8 for a single day - if(total_hours > DAILY_LIMIT_HOURS ) { - const user_id = String(shift.timesheet.employee.user.id); - const date_label = new Date(date).toLocaleDateString('fr-CA'); - this.notifs.notify(user_id, { - type: 'shift.overtime.daily', - severity: 'warn', - message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${date_label} - (total: ${total_hours.toFixed(2)}h).`, - ts: new Date().toISOString(), - meta: { - timesheet_id, - date: new Date(date).toISOString(), - total_hours, - threshold: DAILY_LIMIT_HOURS, - last_shift_id: shift.id - }, - }); - } - return shift; - } - - async findAll(filters: SearchShiftsDto): Promise { - const where = buildPrismaWhere(filters); - const shifts = await this.prisma.shifts.findMany({ where }) - return shifts; - } - - async findOne(id: number): Promise { - const shift = await this.prisma.shifts.findUnique({ - where: { id }, - include: { timesheet: { include: { employee: { include: { user: true } } } }, - bank_code: true, - }, - }); - if(!shift) { - throw new NotFoundException(`Shift #${id} not found`); - } - return shift; - } - - async update(id: number, dto: UpdateShiftsDto): Promise { - await this.findOne(id); - const { timesheet_id, bank_code_id, date,start_time,end_time, comment} = dto; - return this.prisma.shifts.update({ - where: { id }, - data: { - ...(timesheet_id !== undefined && { timesheet_id }), - ...(bank_code_id !== undefined && { bank_code_id }), - ...(date !== undefined && { date }), - ...(start_time !== undefined && { start_time }), - ...(end_time !== undefined && { end_time }), - ...(comment !== undefined && { comment }), - }, - include: { timesheet: { include: { employee: { include: { user: true } } } }, - bank_code: true, - }, - }); - } - - async remove(id: number): Promise { - await this.findOne(id); - return this.prisma.shifts.delete({ where: { id } }); - } - async getSummary(period_id: number): Promise { //fetch pay-period to display const period = await this.prisma.payPeriods.findFirst({ @@ -214,58 +112,94 @@ export class ShiftsQueryService { return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name)); } - //archivation functions ****************************************************** + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - async archiveOld(): Promise { - //fetches archived timesheet's Ids - const archived_timesheets = await this.prisma.timesheetsArchive.findMany({ - select: { timesheet_id: true }, - }); + // async update(id: number, dto: UpdateShiftsDto): Promise { + // await this.findOne(id); + // const { timesheet_id, bank_code_id, date,start_time,end_time, comment} = dto; + // return this.prisma.shifts.update({ + // where: { id }, + // data: { + // ...(timesheet_id !== undefined && { timesheet_id }), + // ...(bank_code_id !== undefined && { bank_code_id }), + // ...(date !== undefined && { date }), + // ...(start_time !== undefined && { start_time }), + // ...(end_time !== undefined && { end_time }), + // ...(comment !== undefined && { comment }), + // }, + // include: { timesheet: { include: { employee: { include: { user: true } } } }, + // bank_code: true, + // }, + // }); + // } - const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id); - if(timesheet_ids.length === 0) { - return; - } + // async remove(id: number): Promise { + // await this.findOne(id); + // return this.prisma.shifts.delete({ where: { id } }); + // } - // copy/delete transaction - await this.prisma.$transaction(async transaction => { - //fetches shifts to move to archive - const shifts_to_archive = await transaction.shifts.findMany({ - where: { timesheet_id: { in: timesheet_ids } }, - }); - if(shifts_to_archive.length === 0) { - return; - } + // async create(dto: CreateShiftDto): Promise { +// const { timesheet_id, bank_code_id, date, start_time, end_time, comment } = dto; - //copies sent to archive table - await transaction.shiftsArchive.createMany({ - data: shifts_to_archive.map(shift => ({ - shift_id: shift.id, - timesheet_id: shift.timesheet_id, - bank_code_id: shift.bank_code_id, - comment: shift.comment ?? undefined, - date: shift.date, - start_time: shift.start_time, - end_time: shift.end_time, - })), - }); +// //shift creation +// const shift = await this.prisma.shifts.create({ +// data: { timesheet_id, bank_code_id, date, start_time, end_time, comment }, +// include: { timesheet: { include: { employee: { include: { user: true } } } }, +// bank_code: true, +// }, +// }); - //delete from shifts table - await transaction.shifts.deleteMany({ - where: { id: { in: shifts_to_archive.map(shift => shift.id) } }, - }) +// //fetches all shifts of the same day to check for daily overtime +// const same_day_shifts = await this.prisma.shifts.findMany({ +// where: { timesheet_id, date }, +// select: { id: true, date: true, start_time: true, end_time: true }, +// }); - }) - } +// //sums hours of the day +// const total_hours = same_day_shifts.reduce((sum, s) => { +// return sum + hoursBetweenSameDay(s.date, s.start_time, s.end_time); +// }, 0 ); - //fetches all archived timesheets - async findAllArchived(): Promise { - return this.prisma.shiftsArchive.findMany(); - } +// //Notify if total hours > 8 for a single day +// if(total_hours > DAILY_LIMIT_HOURS ) { +// const user_id = String(shift.timesheet.employee.user.id); +// const date_label = new Date(date).toLocaleDateString('fr-CA'); +// this.notifs.notify(user_id, { +// type: 'shift.overtime.daily', +// severity: 'warn', +// message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${date_label} +// (total: ${total_hours.toFixed(2)}h).`, +// ts: new Date().toISOString(), +// meta: { +// timesheet_id, +// date: new Date(date).toISOString(), +// total_hours, +// threshold: DAILY_LIMIT_HOURS, +// last_shift_id: shift.id +// }, +// }); +// } +// return shift; +// } +// async findAll(filters: SearchShiftsDto): Promise { +// const where = buildPrismaWhere(filters); +// const shifts = await this.prisma.shifts.findMany({ where }) +// return shifts; +// } - //fetches an archived timesheet - async findOneArchived(id: number): Promise { - return this.prisma.shiftsArchive.findUniqueOrThrow({ where: { id } }); - } +// async findOne(id: number): Promise { +// const shift = await this.prisma.shifts.findUnique({ +// where: { id }, +// include: { timesheet: { include: { employee: { include: { user: true } } } }, +// bank_code: true, +// }, +// }); +// if(!shift) { +// throw new NotFoundException(`Shift #${id} not found`); +// } +// return shift; +// } } \ No newline at end of file diff --git a/src/modules/shifts/shifts.module.ts b/src/modules/shifts/shifts.module.ts index 7c0e3ef..103442a 100644 --- a/src/modules/shifts/shifts.module.ts +++ b/src/modules/shifts/shifts.module.ts @@ -4,11 +4,12 @@ import { BusinessLogicsModule } from 'src/modules/business-logics/business-logic import { ShiftsCommandService } from './services/shifts-command.service'; import { NotificationsModule } from '../notifications/notifications.module'; import { ShiftsQueryService } from './services/shifts-query.service'; +import { ShiftsArchivalService } from './services/shifts-archival.service'; @Module({ imports: [BusinessLogicsModule, NotificationsModule], controllers: [ShiftsController], - providers: [ShiftsQueryService, ShiftsCommandService], - exports: [ShiftsQueryService, ShiftsCommandService], + providers: [ShiftsQueryService, ShiftsCommandService, ShiftsArchivalService], + exports: [ShiftsQueryService, ShiftsCommandService, ShiftsArchivalService], }) export class ShiftsModule {} diff --git a/src/modules/shifts/types and interfaces/shifts-overview-row.interface.ts b/src/modules/shifts/types and interfaces/shifts-overview-row.interface.ts new file mode 100644 index 0000000..145885b --- /dev/null +++ b/src/modules/shifts/types and interfaces/shifts-overview-row.interface.ts @@ -0,0 +1,10 @@ +export interface OverviewRow { + full_name: string; + supervisor: string; + total_regular_hrs: number; + total_evening_hrs: number; + total_overtime_hrs: number; + total_expenses: number; + total_mileage: number; + is_approved: boolean; +} \ No newline at end of file diff --git a/src/modules/shifts/types and interfaces/shifts-upsert.types.ts b/src/modules/shifts/types and interfaces/shifts-upsert.types.ts new file mode 100644 index 0000000..85e6212 --- /dev/null +++ b/src/modules/shifts/types and interfaces/shifts-upsert.types.ts @@ -0,0 +1,9 @@ +export type DayShiftResponse = { + start_time: string; + end_time: string; + type: string; + is_remote: boolean; + comment: string | null; +} + +export type UpsertAction = 'created' | 'updated' | 'deleted'; \ No newline at end of file diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts new file mode 100644 index 0000000..cec997f --- /dev/null +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -0,0 +1,37 @@ +import { NotFoundException } from "@nestjs/common"; +import { ShiftPayloadDto } from "../dtos/upsert-shift.dto"; +import { timeFromHHMMUTC } from "../helpers/shifts-date-time-helpers"; + +export function overlaps( + a_start_ms: number, + a_end_ms: number, + b_start_ms: number, + b_end_ms: number, + ): boolean { + return a_start_ms < b_end_ms && b_start_ms < a_end_ms; +} + +export function resolveBankCodeByType(type: string): Promise { + const bank = this.prisma.bankCodes.findFirst({ + where: { type }, + select: { id: true }, + }); + if (!bank) { + throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` }); + } + return bank.id; +} + + export function normalizeShiftPayload(payload: ShiftPayloadDto) { + //normalize shift's infos + const start_time = timeFromHHMMUTC(payload.start_time); + const end_time = timeFromHHMMUTC(payload.end_time ); + const type = (payload.type || '').trim().toUpperCase(); + const is_remote = payload.is_remote === true; + //normalize comment + const raw_comment = payload.comment ?? null; + const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null; + const comment = trimmed && trimmed.length > 0 ? trimmed: null; + + return { start_time, end_time, type, is_remote, comment }; + } \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheet-archive.service.ts b/src/modules/timesheets/services/timesheet-archive.service.ts new file mode 100644 index 0000000..4988c75 --- /dev/null +++ b/src/modules/timesheets/services/timesheet-archive.service.ts @@ -0,0 +1,52 @@ +import { TimesheetsArchive } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + + +export class TimesheetArchiveService { + constructor(private readonly prisma: PrismaService){} + + async archiveOld(): Promise { + //calcul du cutoff pour archivation + const cutoff = new Date(); + cutoff.setMonth(cutoff.getMonth() - 6) + + await this.prisma.$transaction(async transaction => { + //fetches all timesheets to cutoff + const oldSheets = await transaction.timesheets.findMany({ + where: { shift: { some: { date: { lt: cutoff } } }, + }, + select: { + id: true, + employee_id: true, + is_approved: true, + }, + }); + if( oldSheets.length === 0) { + return; + } + + //preping data for archivation + const archiveDate = oldSheets.map(sheet => ({ + timesheet_id: sheet.id, + employee_id: sheet.employee_id, + is_approved: sheet.is_approved, + })); + + //copying data from timesheets table to archive table + await transaction.timesheetsArchive.createMany({ data: archiveDate }); + + //removing data from timesheets table + await transaction.timesheets.deleteMany({ where: { id: { in: oldSheets.map(s => s.id) } } }); + }); + } + + //fetches all archived timesheets + async findAllArchived(): Promise { + return this.prisma.timesheetsArchive.findMany(); + } + + //fetches an archived timesheet + async findOneArchived(id: number): Promise { + return this.prisma.timesheetsArchive.findUniqueOrThrow({ where: { id } }); + } +} \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 142fced..3f88d40 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -1,4 +1,3 @@ - import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { Prisma, Timesheets } from "@prisma/client"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; @@ -7,6 +6,7 @@ import { TimesheetsQueryService } from "./timesheets-query.service"; import { CreateTimesheetDto } from "../dtos/create-timesheet.dto"; import { TimesheetDto } from "../dtos/overview-timesheet.dto"; import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils"; +import { parseISODate, parseHHmm } from "../utils/timesheet.helpers"; @Injectable() export class TimesheetsCommandService extends BaseApprovalService{ @@ -14,7 +14,9 @@ export class TimesheetsCommandService extends BaseApprovalService{ prisma: PrismaService, private readonly query: TimesheetsQueryService, ) {super(prisma);} - +//_____________________________________________________________________________________________ +// APPROVAL AND DELEGATE METHODS +//_____________________________________________________________________________________________ protected get delegate() { return this.prisma.timesheets; } @@ -46,16 +48,7 @@ export class TimesheetsCommandService extends BaseApprovalService{ } - //create shifts within timesheet's week - employee overview functions - private parseISODate(iso: string): Date { - const [ y, m, d ] = iso.split('-').map(Number); - return new Date(y, (m ?? 1) - 1, d ?? 1); - } - private parseHHmm(t: string): Date { - const [ hh, mm ] = t.split(':').map(Number); - return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0); - } async createWeekShiftsAndReturnOverview( email:string, @@ -101,7 +94,7 @@ export class TimesheetsCommandService extends BaseApprovalService{ //validations and insertions for(const shift of shifts) { - const date = this.parseISODate(shift.date); + const date = parseISODate(shift.date); if (date < start_week || date > end_week) throw new BadRequestException(`date ${shift.date} not in current week`); const bank_code = await this.prisma.bankCodes.findFirst({ @@ -115,8 +108,8 @@ export class TimesheetsCommandService extends BaseApprovalService{ timesheet_id: timesheet.id, bank_code_id: bank_code.id, date: date, - start_time: this.parseHHmm(shift.start_time), - end_time: this.parseHHmm(shift.end_time), + start_time: parseHHmm(shift.start_time), + end_time: parseHHmm(shift.end_time), comment: shift.comment ?? null, is_approved: false, is_remote: false, diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 11961c9..f000277 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -1,7 +1,6 @@ -import { Timesheets, TimesheetsArchive } from '@prisma/client'; -import { Injectable, NotFoundException } from '@nestjs/common'; -import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; -import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; +import { buildPeriod, endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; import { PrismaService } from 'src/prisma/prisma.service'; import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; import { TimesheetDto } from '../dtos/overview-timesheet.dto'; @@ -13,7 +12,7 @@ import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers'; export class TimesheetsQueryService { constructor( private readonly prisma: PrismaService, - private readonly overtime: OvertimeService, + // private readonly overtime: OvertimeService, ) {} async findAll(year: number, period_no: number, email: string): Promise { @@ -67,7 +66,7 @@ export class TimesheetsQueryService { orderBy: { date: 'asc' }, }); - const to_num = (value: any) => + const toNum = (value: any) => value && typeof value.toNumber === 'function' ? value.toNumber() : typeof value === 'number' ? value : value ? Number(value) : 0; @@ -85,7 +84,7 @@ export class TimesheetsQueryService { const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ date: expense.date, - amount: to_num(expense.amount), + amount: toNum(expense.amount), comment: expense.comment ?? '', supervisor_comment: expense.supervisor_comment ?? '', is_approved: expense.is_approved ?? true, @@ -158,28 +157,28 @@ export class TimesheetsQueryService { } //small helper to format hours:minutes - const to_HH_mm = (date: Date) => date.toISOString().slice(11, 16); + //maps all shifts of selected timesheet const shifts = timesheet.shift.map((shift_row) => ({ - bank_type: shift_row.bank_code?.type ?? '', - date: formatDateISO(shift_row.date), - start_time: to_HH_mm(shift_row.start_time), - end_time: to_HH_mm(shift_row.end_time), - comment: shift_row.comment ?? '', + bank_type: shift_row.bank_code?.type ?? '', + date: formatDateISO(shift_row.date), + start_time: toHHmm(shift_row.start_time), + end_time: toHHmm(shift_row.end_time), + comment: shift_row.comment ?? '', is_approved: shift_row.is_approved ?? false, - is_remote: shift_row.is_remote ?? false, + is_remote: shift_row.is_remote ?? false, })); //maps all expenses of selected timsheet const expenses = timesheet.expense.map((exp) => ({ - bank_type: exp.bank_code?.type ?? '', - date: formatDateISO(exp.date), - amount: Number(exp.amount) || 0, - km: 0, - comment: exp.comment ?? '', - supervisor_comment: exp.supervisor_comment ?? '', + bank_type: exp.bank_code?.type ?? '', + date: formatDateISO(exp.date), + amount: Number(exp.amount) || 0, + comment: exp.comment ?? '', is_approved: exp.is_approved ?? false, + km: 0, + supervisor_comment: exp.supervisor_comment ?? '', })); return { @@ -191,85 +190,40 @@ export class TimesheetsQueryService { expenses, } as TimesheetDto; } + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - async findOne(id: number): Promise { - const timesheet = await this.prisma.timesheets.findUnique({ - where: { id }, - include: { - shift: { include: { bank_code: true } }, - expense: { include: { bank_code: true } }, - employee: { include: { user: true } }, - }, - }); - if(!timesheet) { - throw new NotFoundException(`Timesheet #${id} not found`); - } + // async findOne(id: number): Promise { + // const timesheet = await this.prisma.timesheets.findUnique({ + // where: { id }, + // include: { + // shift: { include: { bank_code: true } }, + // expense: { include: { bank_code: true } }, + // employee: { include: { user: true } }, + // }, + // }); + // if(!timesheet) { + // throw new NotFoundException(`Timesheet #${id} not found`); + // } - const detailedShifts = timesheet.shift.map( s => { - const hours = computeHours(s.start_time, s.end_time); - const regularHours = Math.min(8, hours); - const dailyOvertime = this.overtime.getDailyOvertimeHours(s.start_time, s.end_time); - const payRegular = regularHours * s.bank_code.modifier; - const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, s.bank_code.modifier); - return { ...s, hours, payRegular, payOvertime }; - }); - const weeklyOvertimeHours = detailedShifts.length - ? await this.overtime.getWeeklyOvertimeHours( - timesheet.employee_id, - timesheet.shift[0].date): 0; - return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours }; - } + // const detailedShifts = timesheet.shift.map( s => { + // const hours = computeHours(s.start_time, s.end_time); + // const regularHours = Math.min(8, hours); + // const dailyOvertime = this.overtime.getDailyOvertimeHours(s.start_time, s.end_time); + // const payRegular = regularHours * s.bank_code.modifier; + // const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, s.bank_code.modifier); + // return { ...s, hours, payRegular, payOvertime }; + // }); + // const weeklyOvertimeHours = detailedShifts.length + // ? await this.overtime.getWeeklyOvertimeHours( + // timesheet.employee_id, + // timesheet.shift[0].date): 0; + // return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours }; + // } - async remove(id: number): Promise { - await this.findOne(id); - return this.prisma.timesheets.delete({ where: { id } }); - } - - -//archivation functions ****************************************************** - - async archiveOld(): Promise { - //calcul du cutoff pour archivation - const cutoff = new Date(); - cutoff.setMonth(cutoff.getMonth() - 6) - - await this.prisma.$transaction(async transaction => { - //fetches all timesheets to cutoff - const oldSheets = await transaction.timesheets.findMany({ - where: { shift: { some: { date: { lt: cutoff } } }, - }, - select: { - id: true, - employee_id: true, - is_approved: true, - }, - }); - if( oldSheets.length === 0) { - return; - } - - //preping data for archivation - const archiveDate = oldSheets.map(sheet => ({ - timesheet_id: sheet.id, - employee_id: sheet.employee_id, - is_approved: sheet.is_approved, - })); - - //copying data from timesheets table to archive table - await transaction.timesheetsArchive.createMany({ data: archiveDate }); - - //removing data from timesheets table - await transaction.timesheets.deleteMany({ where: { id: { in: oldSheets.map(s => s.id) } } }); - }); - } - - //fetches all archived timesheets - async findAllArchived(): Promise { - return this.prisma.timesheetsArchive.findMany(); - } - - //fetches an archived timesheet - async findOneArchived(id: number): Promise { - return this.prisma.timesheetsArchive.findUniqueOrThrow({ where: { id } }); - } + // async remove(id: number): Promise { + // await this.findOne(id); + // return this.prisma.timesheets.delete({ where: { id } }); + // } } diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index b957fe6..450c7b3 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -1,13 +1,14 @@ -import { Module } from '@nestjs/common'; -import { TimesheetsController } from './controllers/timesheets.controller'; -import { TimesheetsQueryService } from './services/timesheets-query.service'; -import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; +import { TimesheetsController } from './controllers/timesheets.controller'; +import { TimesheetsQueryService } from './services/timesheets-query.service'; +import { TimesheetArchiveService } from './services/timesheet-archive.service'; import { TimesheetsCommandService } from './services/timesheets-command.service'; -import { ShiftsCommandService } from '../shifts/services/shifts-command.service'; -import { ExpensesCommandService } from '../expenses/services/expenses-command.service'; -import { BankCodesRepo } from '../expenses/repos/bank-codes.repo'; +import { ShiftsCommandService } from '../shifts/services/shifts-command.service'; +import { ExpensesCommandService } from '../expenses/services/expenses-command.service'; +import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; +import { BankCodesRepo } from '../expenses/repos/bank-codes.repo'; import { TimesheetsRepo } from '../expenses/repos/timesheets.repo'; -import { EmployeesRepo } from '../expenses/repos/employee.repo'; +import { EmployeesRepo } from '../expenses/repos/employee.repo'; +import { Module } from '@nestjs/common'; @Module({ imports: [BusinessLogicsModule], @@ -17,10 +18,15 @@ import { EmployeesRepo } from '../expenses/repos/employee.repo'; TimesheetsCommandService, ShiftsCommandService, ExpensesCommandService, + TimesheetArchiveService, BankCodesRepo, TimesheetsRepo, EmployeesRepo, ], - exports: [TimesheetsQueryService], + exports: [ + TimesheetsQueryService, + TimesheetArchiveService, + TimesheetsCommandService + ], }) export class TimesheetsModule {} diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 95ee5f3..6a3ead1 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -10,6 +10,19 @@ export function dayKeyFromDate(date: Date, useUTC = true): DayKey { return DAY_KEYS[index]; } +export const toHHmm = (date: Date) => date.toISOString().slice(11, 16); + +//create shifts within timesheet's week - employee overview functions +export function parseISODate(iso: string): Date { + const [ y, m, d ] = iso.split('-').map(Number); + return new Date(y, (m ?? 1) - 1, d ?? 1); +} + +export function parseHHmm(t: string): Date { + const [ hh, mm ] = t.split(':').map(Number); + return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0); +} + //Date & Format const MS_PER_DAY = 86_400_000; const MS_PER_HOUR = 3_600_000; From 791a95374468a83328bf8aa2e0cb45c54b5d784a Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 7 Oct 2025 09:49:42 -0400 Subject: [PATCH 50/69] fix(timesheets): modified returns to show total expense, total mileage and an array of all expenses per date --- .../expenses/dtos/upsert-expense.dto.ts | 2 +- .../timesheets/dtos/overview-timesheet.dto.ts | 1 + .../timesheets/dtos/timesheet-period.dto.ts | 35 +- .../timesheets/mappers/timesheet.mappers.ts | 56 ++++ .../services/timesheets-command.service.ts | 5 +- .../services/timesheets-query.service.ts | 82 +++-- .../timesheets/types/timesheet.types.ts | 73 +++++ .../timesheets/utils/timesheet.helpers.ts | 302 +----------------- .../timesheets/utils/timesheet.utils.ts | 151 +++++++++ 9 files changed, 367 insertions(+), 340 deletions(-) create mode 100644 src/modules/timesheets/mappers/timesheet.mappers.ts create mode 100644 src/modules/timesheets/types/timesheet.types.ts create mode 100644 src/modules/timesheets/utils/timesheet.utils.ts diff --git a/src/modules/expenses/dtos/upsert-expense.dto.ts b/src/modules/expenses/dtos/upsert-expense.dto.ts index 6ec007e..5bea2c3 100644 --- a/src/modules/expenses/dtos/upsert-expense.dto.ts +++ b/src/modules/expenses/dtos/upsert-expense.dto.ts @@ -6,7 +6,7 @@ import { Matches, MaxLength, Min, - ValidateIf, + ValidateIf, ValidateNested } from "class-validator"; diff --git a/src/modules/timesheets/dtos/overview-timesheet.dto.ts b/src/modules/timesheets/dtos/overview-timesheet.dto.ts index 95aad54..ff86de8 100644 --- a/src/modules/timesheets/dtos/overview-timesheet.dto.ts +++ b/src/modules/timesheets/dtos/overview-timesheet.dto.ts @@ -21,6 +21,7 @@ export class ExpensesDto { bank_type: string; date: string; amount: number; + mileage: number; km: number; comment: string; supervisor_comment: string; diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index cfd0194..6b9275c 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -1,20 +1,22 @@ export class ShiftDto { - date: string; - type: string; - start_time: string; - end_time : string; - comment: string; + date: string; + type: string; + start_time: string; + end_time : string; + comment: string; is_approved: boolean; - is_remote: boolean; + is_remote: boolean; } export class ExpenseDto { - amount: number; - comment: string; - supervisor_comment: string; + type: string; + amount: number; + mileage: number; + comment: string; total_mileage: number; total_expense: number; - is_approved: boolean; + is_approved: boolean; + supervisor_comment: string; } export type DayShiftsDto = ShiftDto[]; @@ -31,9 +33,10 @@ export class DetailedShifts { } export class DayExpensesDto { - cash: ExpenseDto[] = []; - km : ExpenseDto[] = []; - [otherType:string]: ExpenseDto[] | any; + expense: ExpenseDto[] = []; + mileage: ExpenseDto[] = []; + per_diem: ExpenseDto[] = []; + on_call: ExpenseDto[] = []; } export class WeekDto { @@ -59,6 +62,8 @@ export class WeekDto { } export class TimesheetPeriodDto { - week1: WeekDto; - week2: WeekDto; + weeks: WeekDto[]; + employee_full_name: string; } + + diff --git a/src/modules/timesheets/mappers/timesheet.mappers.ts b/src/modules/timesheets/mappers/timesheet.mappers.ts new file mode 100644 index 0000000..f3f3234 --- /dev/null +++ b/src/modules/timesheets/mappers/timesheet.mappers.ts @@ -0,0 +1,56 @@ +import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "../dtos/timesheet-period.dto"; +import { ExpensesAmount } from "../types/timesheet.types"; +import { addDays, shortDate } from "../utils/timesheet.helpers"; + +// Factories +export function makeEmptyDayExpenses(): DayExpensesDto { + return { + expense: [], + mileage: [], + per_diem: [], + on_call: [], + }; +} + +export function makeEmptyWeek(week_start: Date): WeekDto { + const make_empty_shifts = (offset: number): DetailedShifts => ({ + shifts: [], + regular_hours: 0, + evening_hours: 0, + emergency_hours: 0, + overtime_hours: 0, + comment: '', + short_date: shortDate(addDays(week_start, offset)), + break_durations: 0, + }); + return { + is_approved: true, + shifts: { + sun: make_empty_shifts(0), + mon: make_empty_shifts(1), + tue: make_empty_shifts(2), + wed: make_empty_shifts(3), + thu: make_empty_shifts(4), + fri: make_empty_shifts(5), + sat: make_empty_shifts(6), + }, + expenses: { + sun: makeEmptyDayExpenses(), + mon: makeEmptyDayExpenses(), + tue: makeEmptyDayExpenses(), + wed: makeEmptyDayExpenses(), + thu: makeEmptyDayExpenses(), + fri: makeEmptyDayExpenses(), + sat: makeEmptyDayExpenses(), + }, + }; +} + +export function makeEmptyPeriod(): TimesheetPeriodDto { + return { weeks: [makeEmptyWeek(new Date()), makeEmptyWeek(new Date())], employee_full_name: '' }; +} + +export const makeAmounts = (): ExpensesAmount => ({ + expense: 0, + mileage: 0, +}); \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 3f88d40..5564f0d 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -47,8 +47,9 @@ export class TimesheetsCommandService extends BaseApprovalService{ return timesheet; } - - +//_____________________________________________________________________________________________ +// +//_____________________________________________________________________________________________ async createWeekShiftsAndReturnOverview( email:string, diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index f000277..511331f 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -1,11 +1,12 @@ -import { buildPeriod, endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers'; +import { endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers'; import { Injectable, NotFoundException } from '@nestjs/common'; import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; import { PrismaService } from 'src/prisma/prisma.service'; import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; import { TimesheetDto } from '../dtos/overview-timesheet.dto'; import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; -import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers'; +import { ShiftRow, ExpenseRow } from '../types/timesheet.types'; +import { buildPeriod } from '../utils/timesheet.utils'; @Injectable() @@ -18,15 +19,25 @@ export class TimesheetsQueryService { 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 }, + where: { + user: { is: { email } } + }, + select: { + id: true + }, }); if(!employee) throw new NotFoundException(`no employee with email ${email} found`); //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 }, + 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`); @@ -39,12 +50,12 @@ export class TimesheetsQueryService { date: { gte: from, lte: to }, }, select: { - date: true, - start_time: true, - end_time: true, - comment: true, + date: true, + start_time: true, + end_time: true, + comment: true, is_approved: true, - is_remote: true, + is_remote: true, bank_code: { select: { type: true } }, }, orderBy:[ { date:'asc'}, { start_time: 'asc'} ], @@ -56,15 +67,16 @@ export class TimesheetsQueryService { date: { gte: from, lte: to }, }, select: { - date: true, - amount: true, - comment: true, - supervisor_comment: true, + date: true, + amount: true, + mileage: true, + comment: true, is_approved: true, + supervisor_comment: true, bank_code: { select: { type: true } }, }, orderBy: { date: 'asc' }, - }); + }); const toNum = (value: any) => value && typeof value.toNumber === 'function' ? value.toNumber() : @@ -73,22 +85,23 @@ export class TimesheetsQueryService { // data mapping const shifts: ShiftRow[] = raw_shifts.map(shift => ({ - date: shift.date, - start_time: shift.start_time, - end_time: shift.end_time, - comment: shift.comment ?? '', + date: shift.date, + start_time: shift.start_time, + end_time: shift.end_time, + comment: shift.comment ?? '', is_approved: shift.is_approved ?? true, - is_remote: shift.is_remote ?? true, - type: String(shift.bank_code?.type ?? '').toUpperCase(), + is_remote: shift.is_remote ?? true, + type: String(shift.bank_code?.type ?? '').toUpperCase(), })); const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ - date: expense.date, - amount: toNum(expense.amount), - comment: expense.comment ?? '', - supervisor_comment: expense.supervisor_comment ?? '', + type: String(expense.bank_code?.type ?? '').toUpperCase(), + date: expense.date, + amount: toNum(expense.amount), + mileage: toNum(expense.mileage), + comment: expense.comment ?? '', is_approved: expense.is_approved ?? true, - type: String(expense.bank_code?.type ?? '').toUpperCase(), + supervisor_comment: expense.supervisor_comment ?? '', })); return buildPeriod(period.period_start, period.period_end, shifts , expenses); @@ -98,27 +111,27 @@ export class TimesheetsQueryService { //fetch user related to email const user = await this.prisma.users.findUnique({ - where: { email }, + where: { email }, select: { id: true }, }); if(!user) throw new NotFoundException(`user with email ${email} not found`); //fetch employee_id matching the email const employee = await this.prisma.employees.findFirst({ - where: { user_id: user.id }, + where: { user_id: user.id }, select: { id: true }, }); if(!employee) throw new NotFoundException(`Employee with email: ${email} not found`); //sets current week Sunday -> Saturday - const base = new Date(); + const base = new Date(); const offset = new Date(base); offset.setDate(offset.getDate() + (week_offset * 7)); const start_date_week = getWeekStart(offset, 0); const end_date_week = getWeekEnd(start_date_week); - const start_day = formatDateISO(start_date_week); - const end_day = formatDateISO(end_date_week); + const start_day = formatDateISO(start_date_week); + const end_day = formatDateISO(end_date_week); //build the label MM/DD/YYYY.MM/DD.YYYY const mm_dd = (date: Date) => `${String(date.getFullYear())}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2,'0')}`; @@ -160,7 +173,7 @@ export class TimesheetsQueryService { //maps all shifts of selected timesheet - const shifts = timesheet.shift.map((shift_row) => ({ + const shifts = timesheet.shift.map((shift_row) => ({ bank_type: shift_row.bank_code?.type ?? '', date: formatDateISO(shift_row.date), start_time: toHHmm(shift_row.start_time), @@ -175,9 +188,10 @@ export class TimesheetsQueryService { bank_type: exp.bank_code?.type ?? '', date: formatDateISO(exp.date), amount: Number(exp.amount) || 0, + mileage: exp.mileage != null ? Number(exp.mileage) : 0, + km: exp.mileage != null ? Number(exp.mileage) : 0, comment: exp.comment ?? '', is_approved: exp.is_approved ?? false, - km: 0, supervisor_comment: exp.supervisor_comment ?? '', })); diff --git a/src/modules/timesheets/types/timesheet.types.ts b/src/modules/timesheets/types/timesheet.types.ts new file mode 100644 index 0000000..e253909 --- /dev/null +++ b/src/modules/timesheets/types/timesheet.types.ts @@ -0,0 +1,73 @@ +export type ShiftRow = { + date: Date; + start_time: Date; + end_time: Date; + comment: string; + is_approved?: boolean; + is_remote: boolean; + type: string +}; +export type ExpenseRow = { + date: Date; + amount: number; + mileage?: number | null; + comment: string; + type: string; + is_approved?: boolean; + supervisor_comment: string; +}; + +//Date & Format +export const MS_PER_DAY = 86_400_000; +export const MS_PER_HOUR = 3_600_000; + +// Types +export const SHIFT_TYPES = { + REGULAR: 'REGULAR', + EVENING: 'EVENING', + OVERTIME: 'OVERTIME', + EMERGENCY: 'EMERGENCY', + HOLIDAY: 'HOLIDAY', + VACATION: 'VACATION', + SICK: 'SICK', +} as const; + +export const EXPENSE_TYPES = { + MILEAGE: 'MILEAGE', + EXPENSE: 'EXPENSES', + PER_DIEM: 'PER_DIEM', + ON_CALL: 'ON_CALL', +} as const; + +//makes the strings indexes for arrays +export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; +export type DayKey = typeof DAY_KEYS[number]; + +export const EXPENSE_BUCKETS = ['expense', 'mileage'] as const; +export type ExpenseBucketKey = typeof EXPENSE_BUCKETS[number]; + + +//shifts's hour by type +export type ShiftsHours = { + regular: number; + evening: number; + overtime: number; + emergency: number; + sick: number; + vacation: number; + holiday: number; +}; +export const make_hours = (): ShiftsHours => ({ + regular: 0, + evening: 0, + overtime: 0, + emergency: 0, + sick: 0, + vacation: 0, + holiday: 0, +}); + +export type ExpensesAmount = { + expense: number; + mileage: number; +}; \ No newline at end of file diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 6a3ead1..3ba862d 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -1,71 +1,6 @@ -import { toDateString } from "src/modules/pay-periods/utils/pay-year.util"; -import { DayExpensesDto, DetailedShifts, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto"; +import { MS_PER_DAY, DayKey, DAY_KEYS } from "../types/timesheet.types"; -//makes the strings indexes for arrays -export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; -export type DayKey = typeof DAY_KEYS[number]; -export function dayKeyFromDate(date: Date, useUTC = true): DayKey { - const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday - return DAY_KEYS[index]; -} - -export const toHHmm = (date: Date) => date.toISOString().slice(11, 16); - -//create shifts within timesheet's week - employee overview functions -export function parseISODate(iso: string): Date { - const [ y, m, d ] = iso.split('-').map(Number); - return new Date(y, (m ?? 1) - 1, d ?? 1); -} - -export function parseHHmm(t: string): Date { - const [ hh, mm ] = t.split(':').map(Number); - return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0); -} - -//Date & Format -const MS_PER_DAY = 86_400_000; -const MS_PER_HOUR = 3_600_000; - -// Types -const SHIFT_TYPES = { - REGULAR: 'REGULAR', - EVENING: 'EVENING', - OVERTIME: 'OVERTIME', - EMERGENCY: 'EMERGENCY', - HOLIDAY: 'HOLIDAY', - VACATION: 'VACATION', - SICK: 'SICK', -} as const; - -const EXPENSE_TYPES = { - MILEAGE: 'MILEAGE', - EXPENSE: 'EXPENSES', - PER_DIEM: 'PER_DIEM', - COMMISSION: 'COMMISSION', - PRIME_DISPO: 'PRIME_DISPO', -} as const; - -//DB line types -export type ShiftRow = { - date: Date; - start_time: Date; - end_time: Date; - comment: string; - is_approved?: boolean; - is_remote: boolean; - type: string -}; -export type ExpenseRow = { - date: Date; - amount: number; - comment: string; - supervisor_comment: string; - is_approved?: boolean; - type: string; -}; - -//helper functions export function toUTCDateOnly(date: Date | string): Date { const d = new Date(date); return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); @@ -95,236 +30,27 @@ export function round2(num: number) { return Math.round(num * 100) / 100; } - -function shortDate(date:Date): string { +export function shortDate(date:Date): string { const mm = String(date.getUTCMonth()+1).padStart(2,'0'); const dd = String(date.getUTCDate()).padStart(2,'0'); return `${mm}/${dd}`; } -// Factories -export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; } - -export function makeEmptyWeek(week_start: Date): WeekDto { - const make_empty_shifts = (offset: number): DetailedShifts => ({ - shifts: [], - regular_hours: 0, - evening_hours: 0, - emergency_hours: 0, - overtime_hours: 0, - comment: '', - short_date: shortDate(addDays(week_start, offset)), - break_durations: 0, - }); - return { - is_approved: true, - shifts: { - sun: make_empty_shifts(0), - mon: make_empty_shifts(1), - tue: make_empty_shifts(2), - wed: make_empty_shifts(3), - thu: make_empty_shifts(4), - fri: make_empty_shifts(5), - sat: make_empty_shifts(6), - }, - expenses: { - sun: makeEmptyDayExpenses(), - mon: makeEmptyDayExpenses(), - tue: makeEmptyDayExpenses(), - wed: makeEmptyDayExpenses(), - thu: makeEmptyDayExpenses(), - fri: makeEmptyDayExpenses(), - sat: makeEmptyDayExpenses(), - }, - }; +export function dayKeyFromDate(date: Date, useUTC = true): DayKey { + const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday + return DAY_KEYS[index]; } -export function makeEmptyPeriod(): TimesheetPeriodDto { - return { week1: makeEmptyWeek(new Date()), week2: makeEmptyWeek(new Date()) }; +export const toHHmm = (date: Date) => date.toISOString().slice(11, 16); + +//create shifts within timesheet's week - employee overview functions +export function parseISODate(iso: string): Date { + const [ y, m, d ] = iso.split('-').map(Number); + return new Date(y, (m ?? 1) - 1, d ?? 1); } -export function buildWeek( - week_start: Date, - week_end: Date, - shifts: ShiftRow[], - expenses: ExpenseRow[], - ): WeekDto { - const week = makeEmptyWeek(week_start); - let all_approved = true; - - //breaks - const day_times: Record> = DAY_KEYS.reduce((acc, key) => { - acc[key] = []; return acc; - }, {} as Record>); - - //shifts's hour by type - type ShiftsHours = { - regular: number; - evening: number; - overtime: number; - emergency: number; - sick: number; - vacation: number; - holiday: number; - }; - const make_hours = (): ShiftsHours => ({ - regular: 0, - evening: 0, - overtime: 0, - emergency: 0, - sick: 0, - vacation: 0, - holiday: 0 - }); - const day_hours: Record = DAY_KEYS.reduce((acc, key) => { - acc[key] = make_hours(); return acc; - }, {} as Record); - - //expenses's amount by type - type ExpensesAmount = { - mileage: number; - expense: number; - per_diem: number; - commission: number; - prime_dispo: number - }; - - const make_amounts = (): ExpensesAmount => ({ - mileage: 0, - expense: 0, - per_diem: 0, - commission: 0, - prime_dispo: 0 - }); - const day_amounts: Record = DAY_KEYS.reduce((acc, key) => { - acc[key] = make_amounts(); return acc; - }, {} as Record); - - const dayExpenseRows: Record = DAY_KEYS.reduce((acc, key) => { - acc[key] = {km: [], cash: [] }; return acc; - }, {} as Record); - - //regroup hours per type of shifts - 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].shifts.push({ - date: toDateString(shift.date), - type: shift.type, - start_time: toTimeString(shift.start_time), - end_time: toTimeString(shift.end_time), - comment: shift.comment, - is_approved: shift.is_approved ?? true, - is_remote: shift.is_remote, - } as ShiftDto); - - day_times[key].push({ start: shift.start_time, end: shift.end_time}); - - const duration = Math.max(0, (shift.end_time.getTime() - shift.start_time.getTime())/ MS_PER_HOUR); - const type = (shift.type || '').toUpperCase(); - - if ( type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration; - else if( type === SHIFT_TYPES.EVENING) day_hours[key].evening += duration; - else if( type === SHIFT_TYPES.EMERGENCY) day_hours[key].emergency += duration; - else if( type === SHIFT_TYPES.OVERTIME) day_hours[key].overtime += duration; - else if( type === SHIFT_TYPES.SICK) day_hours[key].sick += duration; - else if( type === SHIFT_TYPES.VACATION) day_hours[key].vacation += duration; - else if( type === SHIFT_TYPES.HOLIDAY) day_hours[key].holiday += duration; - - all_approved = all_approved && (shift.is_approved ?? true ); - } - - //regroupe amounts to type of expenses - 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 type = (expense.type || '').toUpperCase(); - - if (type === EXPENSE_TYPES.MILEAGE) { - day_amounts[key].mileage += expense.amount; - dayExpenseRows[key].km.push(expense); - } else if (type === EXPENSE_TYPES.EXPENSE) { - day_amounts[key].expense += expense.amount; - dayExpenseRows[key].cash.push(expense) - } else if (type === EXPENSE_TYPES.PER_DIEM) { - day_amounts[key].per_diem += expense.amount; - dayExpenseRows[key].cash.push(expense) - } else if (type === EXPENSE_TYPES.COMMISSION) { - day_amounts[key].commission += expense.amount; - dayExpenseRows[key].cash.push(expense) - } else if (type === EXPENSE_TYPES.PRIME_DISPO) { - day_amounts[key].prime_dispo += expense.amount; - dayExpenseRows[key].cash.push(expense) - } - all_approved = all_approved && (expense.is_approved ?? true ); - } - - - for (const key of DAY_KEYS) { - //return exposed dto data - week.shifts[key].regular_hours = round2(day_hours[key].regular); - week.shifts[key].evening_hours = round2(day_hours[key].evening); - week.shifts[key].overtime_hours = round2(day_hours[key].overtime); - week.shifts[key].emergency_hours = round2(day_hours[key].emergency); - - //calculate gaps between shifts - const times = day_times[key].sort((a,b) => a.start.getTime() - b.start.getTime()); - let gaps = 0; - for (let i = 1; i < times.length; i++) { - const gap = (times[i].start.getTime() - times[i - 1].end.getTime()) / MS_PER_HOUR; - if(gap > 0) gaps += gap; - } - week.shifts[key].break_durations = round2(gaps); - - //daily totals - const totals = day_amounts[key]; - const total_mileage = totals.mileage; - const total_expense = totals.expense + totals.per_diem + totals.commission + totals.prime_dispo; - - //pushing mileage rows - for(const row of dayExpenseRows[key].km) { - week.expenses[key].km.push({ - amount: round2(row.amount), - comment: row.comment, - supervisor_comment: row.supervisor_comment, - total_mileage: round2(total_mileage), - total_expense: round2(total_expense), - is_approved: row.is_approved ?? true, - }); - } - - //pushing expense rows - for(const row of dayExpenseRows[key].cash) { - week.expenses[key].cash.push({ - amount: round2(row.amount), - comment: row.comment, - supervisor_comment: row.supervisor_comment, - total_mileage: round2(total_mileage), - total_expense: round2(total_expense), - is_approved: row.is_approved ?? true, - }); - } - } - - week.is_approved = all_approved; - return week; +export function parseHHmm(t: string): Date { + const [ hh, mm ] = t.split(':').map(Number); + return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0); } -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), - }; -} - - diff --git a/src/modules/timesheets/utils/timesheet.utils.ts b/src/modules/timesheets/utils/timesheet.utils.ts new file mode 100644 index 0000000..6e22366 --- /dev/null +++ b/src/modules/timesheets/utils/timesheet.utils.ts @@ -0,0 +1,151 @@ +import { DayKey, DAY_KEYS, EXPENSE_BUCKETS, EXPENSE_TYPES, ExpenseBucketKey, ExpenseRow, MS_PER_HOUR, SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount } from "../types/timesheet.types"; +import { isBetweenUTC, dayKeyFromDate, toTimeString, round2, toUTCDateOnly, endOfDayUTC, addDays } from "./timesheet.helpers"; +import { WeekDto, ShiftDto, ExpenseDto, TimesheetPeriodDto } from "../dtos/timesheet-period.dto"; +import { makeAmounts, makeEmptyWeek } from "../mappers/timesheet.mappers"; +import { toDateString } from "src/modules/pay-periods/utils/pay-year.util"; + +export function buildWeek( + week_start: Date, + week_end: Date, + shifts: ShiftRow[], + expenses: ExpenseRow[], + ): WeekDto { + const week = makeEmptyWeek(week_start); + let all_approved = true; + + const day_times: Record> = DAY_KEYS.reduce((acc, key) => { + acc[key] = []; return acc; + }, {} as Record>); + + const day_hours: Record = DAY_KEYS.reduce((acc, key) => { + acc[key] = make_hours(); return acc; + }, {} as Record); + + const day_amounts: Record = DAY_KEYS.reduce((acc, key) => { + acc[key] = makeAmounts(); return acc; + }, {} as Record); + + const day_expense_rows: Record> = DAY_KEYS.reduce((acc, key) => { + acc[key] = { + expense: [], + mileage: [], + }; + return acc; + }, {} as Record>); + + //regroup hours per type of shifts + 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].shifts.push({ + date: toDateString(shift.date), + type: shift.type, + start_time: toTimeString(shift.start_time), + end_time: toTimeString(shift.end_time), + comment: shift.comment, + is_approved: shift.is_approved ?? true, + is_remote: shift.is_remote, + } as ShiftDto); + + day_times[key].push({ start: shift.start_time, end: shift.end_time}); + + const duration = Math.max(0, (shift.end_time.getTime() - shift.start_time.getTime())/ MS_PER_HOUR); + const type = (shift.type || '').toUpperCase(); + + if ( type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration; + else if( type === SHIFT_TYPES.EVENING) day_hours[key].evening += duration; + else if( type === SHIFT_TYPES.EMERGENCY) day_hours[key].emergency += duration; + else if( type === SHIFT_TYPES.OVERTIME) day_hours[key].overtime += duration; + else if( type === SHIFT_TYPES.SICK) day_hours[key].sick += duration; + else if( type === SHIFT_TYPES.VACATION) day_hours[key].vacation += duration; + else if( type === SHIFT_TYPES.HOLIDAY) day_hours[key].holiday += duration; + + all_approved = all_approved && (shift.is_approved ?? true ); + } + + //regroupe amounts to type of expenses + 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 type = (expense.type || '').toUpperCase(); + + let bucket: ExpenseBucketKey; + + if(type === EXPENSE_TYPES.MILEAGE) { + bucket = 'mileage'; + day_amounts[key].mileage += expense.mileage ?? 0; + } else { + bucket = 'expense'; + day_amounts[key].expense += expense.amount; + } + + day_expense_rows[key][bucket].push(expense); + all_approved = all_approved && (expense.is_approved ?? true ); + } + + for (const key of DAY_KEYS) { + //return exposed dto data + week.shifts[key].regular_hours = round2(day_hours[key].regular); + week.shifts[key].evening_hours = round2(day_hours[key].evening); + week.shifts[key].overtime_hours = round2(day_hours[key].overtime); + week.shifts[key].emergency_hours = round2(day_hours[key].emergency); + + //calculate gaps between shifts + const times = day_times[key].sort((a,b) => a.start.getTime() - b.start.getTime()); + let gaps = 0; + for (let i = 1; i < times.length; i++) { + const gap = (times[i].start.getTime() - times[i - 1].end.getTime()) / MS_PER_HOUR; + if(gap > 0) gaps += gap; + } + week.shifts[key].break_durations = round2(gaps); + + //daily totals + const totals = day_amounts[key]; + const total_mileage = round2(totals.mileage); + const total_expense = round2(totals.expense); + + const target_buckets = week.expenses[key] as Record; + const source_buckets = day_expense_rows[key]; + + for (const bucket of EXPENSE_BUCKETS) { + for (const row of source_buckets[bucket]) { + target_buckets[bucket].push({ + type: (row.type || '').toUpperCase(), + amount: round2(row.amount), + mileage: round2(row.mileage ?? 0), + comment: row.comment, + is_approved: row.is_approved ?? true, + supervisor_comment: row.supervisor_comment, + total_mileage, + total_expense, + }); + } + } + } + + week.is_approved = all_approved; + return week; +} + +export function buildPeriod( + period_start: Date, + period_end: Date, + shifts: ShiftRow[], + expenses: ExpenseRow[], + employeeFullName = '' +): 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); + + const weeks: WeekDto[] = [ + buildWeek(week1_start, week1_end, shifts, expenses), + buildWeek(week2_start, week2_end, shifts, expenses), + ]; + + return { + weeks, + employee_full_name: employeeFullName, + }; +} \ No newline at end of file From 9821b81afdf4c21fe12f5d53b2789f7a176dc5be Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 7 Oct 2025 09:55:44 -0400 Subject: [PATCH 51/69] fizx(archive): fix import services --- .../controllers/employees-archive.controller.ts | 8 ++++---- .../controllers/expenses-archive.controller.ts | 4 ++-- .../controllers/shifts-archive.controller.ts | 4 ++-- .../controllers/timesheets-archive.controller.ts | 4 ++-- src/modules/archival/services/archival.service.ts | 12 ++++++------ 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/modules/archival/controllers/employees-archive.controller.ts b/src/modules/archival/controllers/employees-archive.controller.ts index fa9e911..375a351 100644 --- a/src/modules/archival/controllers/employees-archive.controller.ts +++ b/src/modules/archival/controllers/employees-archive.controller.ts @@ -2,20 +2,20 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { EmployeesArchive, Roles as RoleEnum } from '@prisma/client'; -import { EmployeesService } from "src/modules/employees/services/employees.service"; +import { EmployeesArchivalService } from "src/modules/employees/services/employees-archival.service"; @ApiTags('Employee Archives') // @UseGuards() @Controller('archives/employees') export class EmployeesArchiveController { - constructor(private readonly employeesService: EmployeesService) {} + constructor(private readonly employeesArchiveService: EmployeesArchivalService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) @ApiOperation({ summary: 'List of archived employees'}) @ApiResponse({ status: 200, description: 'List of archived employees', isArray: true }) async findAllArchived(): Promise { - return this.employeesService.findAllArchived(); + return this.employeesArchiveService.findAllArchived(); } @Get() @@ -24,7 +24,7 @@ export class EmployeesArchiveController { @ApiResponse({ status: 200, description: 'Archived employee found'}) async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise { try{ - return await this.employeesService.findOneArchived(id); + return await this.employeesArchiveService.findOneArchived(id); }catch { throw new NotFoundException(`Archived employee #${id} not found`); } diff --git a/src/modules/archival/controllers/expenses-archive.controller.ts b/src/modules/archival/controllers/expenses-archive.controller.ts index 7c270fe..e6bd935 100644 --- a/src/modules/archival/controllers/expenses-archive.controller.ts +++ b/src/modules/archival/controllers/expenses-archive.controller.ts @@ -2,13 +2,13 @@ import { UseGuards, Controller, Get, Param, ParseIntPipe, NotFoundException } fr import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; import { ExpensesArchive,Roles as RoleEnum } from "@prisma/client"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; -import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; +import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service"; @ApiTags('Expense Archives') // @UseGuards() @Controller('archives/expenses') export class ExpensesArchiveController { - constructor(private readonly expensesService: ExpensesQueryService) {} + constructor(private readonly expensesService: ExpensesArchivalService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) diff --git a/src/modules/archival/controllers/shifts-archive.controller.ts b/src/modules/archival/controllers/shifts-archive.controller.ts index fb7204b..e8f92f2 100644 --- a/src/modules/archival/controllers/shifts-archive.controller.ts +++ b/src/modules/archival/controllers/shifts-archive.controller.ts @@ -2,13 +2,13 @@ import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } fr import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ShiftsArchive, Roles as RoleEnum } from "@prisma/client"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; -import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; +import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service"; @ApiTags('Shift Archives') // @UseGuards() @Controller('archives/shifts') export class ShiftsArchiveController { - constructor(private readonly shiftsService:ShiftsQueryService) {} + constructor(private readonly shiftsService: ShiftsArchivalService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) diff --git a/src/modules/archival/controllers/timesheets-archive.controller.ts b/src/modules/archival/controllers/timesheets-archive.controller.ts index 0c9d607..7505b66 100644 --- a/src/modules/archival/controllers/timesheets-archive.controller.ts +++ b/src/modules/archival/controllers/timesheets-archive.controller.ts @@ -2,13 +2,13 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { TimesheetsArchive, Roles as RoleEnum } from '@prisma/client'; -import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; +import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service"; @ApiTags('Timesheet Archives') // @UseGuards() @Controller('archives/timesheets') export class TimesheetsArchiveController { - constructor(private readonly timesheetsService: TimesheetsQueryService) {} + constructor(private readonly timesheetsService: TimesheetArchiveService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) diff --git a/src/modules/archival/services/archival.service.ts b/src/modules/archival/services/archival.service.ts index 7dbf567..66be2d0 100644 --- a/src/modules/archival/services/archival.service.ts +++ b/src/modules/archival/services/archival.service.ts @@ -1,17 +1,17 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; -import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; -import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; -import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; +import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service"; +import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service"; +import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service"; @Injectable() export class ArchivalService { private readonly logger = new Logger(ArchivalService.name); constructor( - private readonly timesheetsService: TimesheetsQueryService, - private readonly expensesService: ExpensesQueryService, - private readonly shiftsService: ShiftsQueryService, + private readonly timesheetsService: TimesheetArchiveService, + private readonly expensesService: ExpensesArchivalService, + private readonly shiftsService: ShiftsArchivalService, ) {} @Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00 From c58e8db59f03acc79c841132d6df582e32fe42b2 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 7 Oct 2025 10:07:47 -0400 Subject: [PATCH 52/69] fix(timesheets: minor fix --- docs/swagger/swagger-spec.json | 190 +++++++----------- .../services/timesheets-query.service.ts | 1 - 2 files changed, 75 insertions(+), 116 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index e8045ac..8170ec6 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -125,46 +125,6 @@ ] } }, - "/employees": { - "post": { - "operationId": "EmployeesController_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateEmployeeDto" - } - } - } - }, - "responses": { - "201": { - "description": "Employee created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateEmployeeDto" - } - } - } - }, - "400": { - "description": "Incomplete task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create employee", - "tags": [ - "Employees" - ] - } - }, "/employees/employee-list": { "get": { "operationId": "EmployeesController_findListEmployees", @@ -1127,6 +1087,81 @@ } }, "schemas": { + "EmployeeListItemDto": { + "type": "object", + "properties": {} + }, + "UpdateEmployeeDto": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1, + "description": "Unique ID of an employee(primary-key, auto-incremented)" + }, + "user_id": { + "type": "string", + "example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d", + "description": "UUID of the user linked to that employee" + }, + "first_name": { + "type": "string", + "example": "Frodo", + "description": "Employee`s first name" + }, + "last_name": { + "type": "string", + "example": "Baggins", + "description": "Employee`s last name" + }, + "email": { + "type": "string", + "example": "i_cant_do_this_sam@targointernet.com", + "description": "Employee`s email" + }, + "phone_number": { + "type": "string", + "example": "82538437464", + "description": "Employee`s phone number" + }, + "residence": { + "type": "string", + "example": "1 Bagshot Row, Hobbiton, The Shire, Middle-earth", + "description": "Employee`s residence" + }, + "external_payroll_id": { + "type": "number", + "example": 7464, + "description": "external ID for the pay system" + }, + "company_code": { + "type": "number", + "example": 335567447, + "description": "Employee`s company code" + }, + "job_title": { + "type": "string", + "example": "technicient", + "description": "employee`s job title" + }, + "first_work_day": { + "format": "date-time", + "type": "string", + "example": "23/09/3018", + "description": "New hire date or undefined" + }, + "last_work_day": { + "format": "date-time", + "type": "string", + "example": "25/03/3019", + "description": "Termination date (null to restore)" + }, + "supervisor_id": { + "type": "number", + "description": "Supervisor ID" + } + } + }, "CreateEmployeeDto": { "type": "object", "properties": { @@ -1204,81 +1239,6 @@ "first_work_day" ] }, - "EmployeeListItemDto": { - "type": "object", - "properties": {} - }, - "UpdateEmployeeDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of an employee(primary-key, auto-incremented)" - }, - "user_id": { - "type": "string", - "example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d", - "description": "UUID of the user linked to that employee" - }, - "first_name": { - "type": "string", - "example": "Frodo", - "description": "Employee`s first name" - }, - "last_name": { - "type": "string", - "example": "Baggins", - "description": "Employee`s last name" - }, - "email": { - "type": "string", - "example": "i_cant_do_this_sam@targointernet.com", - "description": "Employee`s email" - }, - "phone_number": { - "type": "string", - "example": "82538437464", - "description": "Employee`s phone number" - }, - "residence": { - "type": "string", - "example": "1 Bagshot Row, Hobbiton, The Shire, Middle-earth", - "description": "Employee`s residence" - }, - "external_payroll_id": { - "type": "number", - "example": 7464, - "description": "external ID for the pay system" - }, - "company_code": { - "type": "number", - "example": 335567447, - "description": "Employee`s company code" - }, - "job_title": { - "type": "string", - "example": "technicient", - "description": "employee`s job title" - }, - "first_work_day": { - "format": "date-time", - "type": "string", - "example": "23/09/3018", - "description": "New hire date or undefined" - }, - "last_work_day": { - "format": "date-time", - "type": "string", - "example": "25/03/3019", - "description": "Termination date (null to restore)" - }, - "supervisor_id": { - "type": "number", - "description": "Supervisor ID" - } - } - }, "CreateWeekShiftsDto": { "type": "object", "properties": {} diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 511331f..edbc830 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -189,7 +189,6 @@ export class TimesheetsQueryService { date: formatDateISO(exp.date), amount: Number(exp.amount) || 0, mileage: exp.mileage != null ? Number(exp.mileage) : 0, - km: exp.mileage != null ? Number(exp.mileage) : 0, comment: exp.comment ?? '', is_approved: exp.is_approved ?? false, supervisor_comment: exp.supervisor_comment ?? '', From 0fc80f50f949d3ab9ca9df2febd934469e6da554 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 7 Oct 2025 10:28:13 -0400 Subject: [PATCH 53/69] fix(timesheets): minor fix --- src/modules/timesheets/controllers/timesheets.controller.ts | 3 --- src/modules/timesheets/dtos/overview-timesheet.dto.ts | 1 - src/modules/timesheets/services/timesheets-query.service.ts | 3 --- 3 files changed, 7 deletions(-) diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index b5d2176..c0ff293 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -48,9 +48,6 @@ export class TimesheetsController { const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0; return this.timesheetsCommand.createWeekShiftsAndReturnOverview(email, dto.shifts, week_offset); } - - - //_____________________________________________________________________________________________ // Deprecated or unused methods diff --git a/src/modules/timesheets/dtos/overview-timesheet.dto.ts b/src/modules/timesheets/dtos/overview-timesheet.dto.ts index ff86de8..417f913 100644 --- a/src/modules/timesheets/dtos/overview-timesheet.dto.ts +++ b/src/modules/timesheets/dtos/overview-timesheet.dto.ts @@ -22,7 +22,6 @@ export class ExpensesDto { date: string; amount: number; mileage: number; - km: number; comment: string; supervisor_comment: string; is_approved: boolean; diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index edbc830..c5644a7 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -169,9 +169,6 @@ export class TimesheetsQueryService { } as TimesheetDto; } - //small helper to format hours:minutes - - //maps all shifts of selected timesheet const shifts = timesheet.shift.map((shift_row) => ({ bank_type: shift_row.bank_code?.type ?? '', From a750f79107b531fa6ba4b0d8176e971b37346abf Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 7 Oct 2025 12:00:52 -0400 Subject: [PATCH 54/69] fix(timesheets): fix backend return to send an array of expenses with total mileage and total expense --- .../services/holiday.service.ts | 1 + .../timesheets/dtos/timesheet-period.dto.ts | 9 +-- .../timesheets/mappers/timesheet.mappers.ts | 7 +- .../timesheets/types/timesheet.types.ts | 4 -- .../timesheets/utils/timesheet.utils.ts | 69 ++++++++++--------- 5 files changed, 43 insertions(+), 47 deletions(-) diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index 48e602c..15bf4d2 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -8,6 +8,7 @@ const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000; le calcul est 1/20 des 4 dernières semaines, précédent la semaine incluant le férier. Un maximum de 08h00 est allouable pour le férier Un maximum de 40hrs par semaine est retenue pour faire le calcul. + le bank-code à soumettre à Desjardins doit être le G104 */ @Injectable() diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index 6b9275c..c4ef385 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -13,8 +13,6 @@ export class ExpenseDto { amount: number; mileage: number; comment: string; - total_mileage: number; - total_expense: number; is_approved: boolean; supervisor_comment: string; } @@ -33,10 +31,9 @@ export class DetailedShifts { } export class DayExpensesDto { - expense: ExpenseDto[] = []; - mileage: ExpenseDto[] = []; - per_diem: ExpenseDto[] = []; - on_call: ExpenseDto[] = []; + expenses: ExpenseDto[]; + total_mileage: number; + total_expense: number; } export class WeekDto { diff --git a/src/modules/timesheets/mappers/timesheet.mappers.ts b/src/modules/timesheets/mappers/timesheet.mappers.ts index f3f3234..c9f04c4 100644 --- a/src/modules/timesheets/mappers/timesheet.mappers.ts +++ b/src/modules/timesheets/mappers/timesheet.mappers.ts @@ -5,10 +5,9 @@ import { addDays, shortDate } from "../utils/timesheet.helpers"; // Factories export function makeEmptyDayExpenses(): DayExpensesDto { return { - expense: [], - mileage: [], - per_diem: [], - on_call: [], + expenses: [], + total_expense: -1, + total_mileage: -1, }; } diff --git a/src/modules/timesheets/types/timesheet.types.ts b/src/modules/timesheets/types/timesheet.types.ts index e253909..e33f86b 100644 --- a/src/modules/timesheets/types/timesheet.types.ts +++ b/src/modules/timesheets/types/timesheet.types.ts @@ -43,10 +43,6 @@ export const EXPENSE_TYPES = { export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; export type DayKey = typeof DAY_KEYS[number]; -export const EXPENSE_BUCKETS = ['expense', 'mileage'] as const; -export type ExpenseBucketKey = typeof EXPENSE_BUCKETS[number]; - - //shifts's hour by type export type ShiftsHours = { regular: number; diff --git a/src/modules/timesheets/utils/timesheet.utils.ts b/src/modules/timesheets/utils/timesheet.utils.ts index 6e22366..fa09eac 100644 --- a/src/modules/timesheets/utils/timesheet.utils.ts +++ b/src/modules/timesheets/utils/timesheet.utils.ts @@ -1,6 +1,12 @@ -import { DayKey, DAY_KEYS, EXPENSE_BUCKETS, EXPENSE_TYPES, ExpenseBucketKey, ExpenseRow, MS_PER_HOUR, SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount } from "../types/timesheet.types"; -import { isBetweenUTC, dayKeyFromDate, toTimeString, round2, toUTCDateOnly, endOfDayUTC, addDays } from "./timesheet.helpers"; -import { WeekDto, ShiftDto, ExpenseDto, TimesheetPeriodDto } from "../dtos/timesheet-period.dto"; +import { + DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow, MS_PER_HOUR, + SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount +} from "../types/timesheet.types"; +import { + isBetweenUTC, dayKeyFromDate, toTimeString, round2, + toUTCDateOnly, endOfDayUTC, addDays +} from "./timesheet.helpers"; +import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "../dtos/timesheet-period.dto"; import { makeAmounts, makeEmptyWeek } from "../mappers/timesheet.mappers"; import { toDateString } from "src/modules/pay-periods/utils/pay-year.util"; @@ -25,13 +31,21 @@ export function buildWeek( acc[key] = makeAmounts(); return acc; }, {} as Record); - const day_expense_rows: Record> = DAY_KEYS.reduce((acc, key) => { + const day_expense_rows: Record = DAY_KEYS.reduce((acc, key) => { acc[key] = { - expense: [], - mileage: [], + expenses: [{ + type: '', + amount: -1, + mileage: -1, + comment: '', + is_approved: false, + supervisor_comment: '', + }], + total_expense: -1, + total_mileage: -1, }; return acc; - }, {} as Record>); + }, {} as Record); //regroup hours per type of shifts const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end)); @@ -69,18 +83,24 @@ export function buildWeek( const key = dayKeyFromDate(expense.date, true); const type = (expense.type || '').toUpperCase(); - let bucket: ExpenseBucketKey; + const row: ExpenseDto = { + type, + amount: round2(expense.amount ?? 0), + mileage: round2(expense.mileage ?? 0), + comment: expense.comment ?? '', + is_approved: expense.is_approved ?? true, + supervisor_comment: expense.supervisor_comment ?? '', + }; + + day_expense_rows[key].expenses.push(row); if(type === EXPENSE_TYPES.MILEAGE) { - bucket = 'mileage'; - day_amounts[key].mileage += expense.mileage ?? 0; + day_amounts[key].mileage += row.mileage ?? 0; } else { - bucket = 'expense'; - day_amounts[key].expense += expense.amount; + day_amounts[key].expense += row.amount; } - day_expense_rows[key][bucket].push(expense); - all_approved = all_approved && (expense.is_approved ?? true ); + all_approved = all_approved && row.is_approved; } for (const key of DAY_KEYS) { @@ -101,26 +121,9 @@ export function buildWeek( //daily totals const totals = day_amounts[key]; - const total_mileage = round2(totals.mileage); - const total_expense = round2(totals.expense); - const target_buckets = week.expenses[key] as Record; - const source_buckets = day_expense_rows[key]; - - for (const bucket of EXPENSE_BUCKETS) { - for (const row of source_buckets[bucket]) { - target_buckets[bucket].push({ - type: (row.type || '').toUpperCase(), - amount: round2(row.amount), - mileage: round2(row.mileage ?? 0), - comment: row.comment, - is_approved: row.is_approved ?? true, - supervisor_comment: row.supervisor_comment, - total_mileage, - total_expense, - }); - } - } + day_expense_rows[key].total_mileage = round2(totals.mileage); + day_expense_rows[key].total_expense = round2(totals.expense); } week.is_approved = all_approved; From cc310e286d50a8d1fa483db60850787bb2d36c21 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 7 Oct 2025 13:51:35 -0400 Subject: [PATCH 55/69] feat(expenses): method implementation to show a list of all expenses made by an employee using email, year and period_no --- docs/swagger/swagger-spec.json | 44 +++++++++ .../controllers/expenses.controller.ts | 15 ++- .../services/expenses-query.service.ts | 91 ++++++++++++++++++- 3 files changed, 143 insertions(+), 7 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 8170ec6..106add4 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -393,6 +393,50 @@ ] } }, + "/Expenses/list/{email}/{year}/{period_no}": { + "get": { + "operationId": "ExpensesController_findExpenseListByPayPeriodAndEmail", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "year", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "period_no", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "access-token": [] + } + ], + "tags": [ + "Expenses" + ] + } + }, "/shifts/upsert/{email}/{date}": { "put": { "operationId": "ShiftsController_upsert_by_date", diff --git a/src/modules/expenses/controllers/expenses.controller.ts b/src/modules/expenses/controllers/expenses.controller.ts index 03173e9..11bef7f 100644 --- a/src/modules/expenses/controllers/expenses.controller.ts +++ b/src/modules/expenses/controllers/expenses.controller.ts @@ -1,10 +1,12 @@ -import { Body, Controller, Param, Put, } from "@nestjs/common"; +import { Body, Controller, Get, Param, Put, } from "@nestjs/common"; import { Roles as RoleEnum } from '.prisma/client'; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { ExpensesCommandService } from "../services/expenses-command.service"; import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces"; +import { DayExpensesDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; +import { ExpensesQueryService } from "../services/expenses-query.service"; @ApiTags('Expenses') @ApiBearerAuth('access-token') @@ -12,7 +14,7 @@ import { UpsertExpenseResult } from "../types and interfaces/expenses.types.inte @Controller('Expenses') export class ExpensesController { constructor( - // private readonly query: ExpensesQueryService, + private readonly query: ExpensesQueryService, private readonly command: ExpensesCommandService, ) {} @@ -25,6 +27,15 @@ export class ExpensesController { return this.command.upsertExpensesByDate(email, date, dto); } + @Get('list/:email/:year/:period_no') + async findExpenseListByPayPeriodAndEmail( + @Param('email') email:string, + @Param('year') year: number, + @Param('period_no') period_no: number, + ): Promise { + return this.query.findExpenseListByPayPeriodAndEmail(email, year, period_no); + } + //_____________________________________________________________________________________________ // Deprecated or unused methods //_____________________________________________________________________________________________ diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index e82e0a4..9bfdca6 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -1,11 +1,92 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "src/prisma/prisma.service"; +import { DayExpensesDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; +import { EmployeesRepo } from "../repos/employee.repo"; +import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers"; +import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types"; @Injectable() export class ExpensesQueryService { - // constructor( - // private readonly prisma: PrismaService, - // private readonly mileageService: MileageService, - // ) {} + constructor( + private readonly prisma: PrismaService, + private readonly employeeRepo: EmployeesRepo, + ) {} + + + //fetchs all expenses for a selected employee using email, pay-period-year and number + async findExpenseListByPayPeriodAndEmail( + email: string, + year: number, + period_no: number + ): Promise { + //fetch employe_id using email + const employee_id = await this.employeeRepo.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); + + //fetch pay-period using year and period_no + const pay_period = await this.prisma.payPeriods.findFirst({ + where: { + pay_year: year, + pay_period_no: period_no + }, + select: { period_start: true, period_end: true }, + }); + if(!pay_period) throw new NotFoundException(`Pay period ${year}- ${period_no} not found`); + + const start = toUTCDateOnly(pay_period.period_start); + const end = toUTCDateOnly(pay_period.period_end); + + //sets rows data + const rows = await this.prisma.expenses.findMany({ + where: { + date: { gte: start, lte: end }, + timesheet: { is: { employee_id } }, + }, + orderBy: { date: 'asc'}, + select: { + amount: true, + mileage: true, + comment: true, + is_approved: true, + supervisor_comment: true, + bank_code: {select: { type: true } }, + }, + }); + + //declare return values + const expenses: ExpenseDto[] = []; + let total_amount = 0; + let total_mileage = 0; + + //set rows + for(const row of rows) { + const type = (row.bank_code?.type ?? '').toUpperCase(); + const amount = round2(Number(row.amount ?? 0)); + const mileage = round2(Number(row.mileage ?? 0)); + + if(type === EXPENSE_TYPES.MILEAGE) { + total_mileage += mileage; + } else { + total_amount += amount; + } + + //fills rows array + expenses.push({ + type, + amount, + mileage, + comment: row.comment ?? '', + is_approved: row.is_approved ?? false, + supervisor_comment: row.supervisor_comment ?? '', + }); + } + + return { + expenses, + total_expense: round2(total_amount), + total_mileage: round2(total_mileage), + }; +} //_____________________________________________________________________________________________ // Deprecated or unused methods From 6254410f663f402f91a1313f55009dd7f0c7333e Mon Sep 17 00:00:00 2001 From: Nicolas Drolet Date: Tue, 7 Oct 2025 16:27:30 -0400 Subject: [PATCH 56/69] fix(expense): rename import to avoid confusion --- src/modules/expenses/services/expenses-query.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index 9bfdca6..15c950e 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; -import { DayExpensesDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; +import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; import { EmployeesRepo } from "../repos/employee.repo"; import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers"; import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types"; @@ -18,7 +18,7 @@ export class ExpensesQueryService { email: string, year: number, period_no: number - ): Promise { + ): Promise { //fetch employe_id using email const employee_id = await this.employeeRepo.findIdByEmail(email); if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); From f6c5b2a73c5c11d76262157f7145114c8257d6ff Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 8 Oct 2025 08:54:43 -0400 Subject: [PATCH 57/69] refactor(shifts): refactor main upsert function to use shared utils and helpers --- .../employees/services/employees.service.ts | 2 - src/modules/expenses/expenses.module.ts | 12 +- .../services/expenses-command.service.ts | 66 ++-- .../services/expenses-query.service.ts | 4 +- src/modules/pay-periods/pay-periods.module.ts | 9 +- src/modules/shared/shared.module.ts | 22 ++ .../utils/resolve-bank-type-id.utils.ts} | 21 +- .../utils/resolve-email-id.utils.ts} | 21 +- .../resolve-employee-timesheet.utils.ts} | 2 +- .../shared/utils/resolve-full-name.utils.ts | 22 ++ .../shifts/controllers/shifts.controller.ts | 2 +- .../helpers/shifts-date-time-helpers.ts | 10 +- .../shifts/services/shifts-command.service.ts | 348 ++++++++---------- .../shifts/services/shifts-query.service.ts | 2 +- .../shifts-overview-row.interface.ts | 0 .../shifts-upsert.types.ts | 0 .../controllers/timesheets.controller.ts | 11 +- .../timesheets/dtos/create-timesheet.dto.ts | 2 +- .../timesheets/dtos/overview-timesheet.dto.ts | 28 -- .../timesheets/dtos/timesheet-period.dto.ts | 11 +- .../timesheets/dtos/update-timesheet.dto.ts | 4 - .../services/timesheets-command.service.ts | 60 +-- .../services/timesheets-query.service.ts | 67 ++-- src/modules/timesheets/timesheets.module.ts | 24 +- 24 files changed, 312 insertions(+), 438 deletions(-) create mode 100644 src/modules/shared/shared.module.ts rename src/modules/{expenses/repos/bank-codes.repo.ts => shared/utils/resolve-bank-type-id.utils.ts} (59%) rename src/modules/{expenses/repos/employee.repo.ts => shared/utils/resolve-email-id.utils.ts} (60%) rename src/modules/{expenses/repos/timesheets.repo.ts => shared/utils/resolve-employee-timesheet.utils.ts} (96%) create mode 100644 src/modules/shared/utils/resolve-full-name.utils.ts rename src/modules/shifts/{types and interfaces => types-and-interfaces}/shifts-overview-row.interface.ts (100%) rename src/modules/shifts/{types and interfaces => types-and-interfaces}/shifts-upsert.types.ts (100%) delete mode 100644 src/modules/timesheets/dtos/overview-timesheet.dto.ts delete mode 100644 src/modules/timesheets/dtos/update-timesheet.dto.ts diff --git a/src/modules/employees/services/employees.service.ts b/src/modules/employees/services/employees.service.ts index 2833bff..3627476 100644 --- a/src/modules/employees/services/employees.service.ts +++ b/src/modules/employees/services/employees.service.ts @@ -42,8 +42,6 @@ export class EmployeesService { ); } - - async findOneProfile(email: string): Promise { const emp = await this.prisma.employees.findFirst({ where: { user: { email } }, diff --git a/src/modules/expenses/expenses.module.ts b/src/modules/expenses/expenses.module.ts index 39f1357..6201b91 100644 --- a/src/modules/expenses/expenses.module.ts +++ b/src/modules/expenses/expenses.module.ts @@ -3,28 +3,20 @@ import { Module } from "@nestjs/common"; import { ExpensesQueryService } from "./services/expenses-query.service"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; import { ExpensesCommandService } from "./services/expenses-command.service"; -import { BankCodesRepo } from "./repos/bank-codes.repo"; -import { TimesheetsRepo } from "./repos/timesheets.repo"; -import { EmployeesRepo } from "./repos/employee.repo"; import { ExpensesArchivalService } from "./services/expenses-archival.service"; +import { SharedModule } from "../shared/shared.module"; @Module({ - imports: [BusinessLogicsModule], + imports: [BusinessLogicsModule, SharedModule], controllers: [ExpensesController], providers: [ ExpensesQueryService, ExpensesArchivalService, ExpensesCommandService, - BankCodesRepo, - TimesheetsRepo, - EmployeesRepo, ], exports: [ ExpensesQueryService, ExpensesArchivalService, - BankCodesRepo, - TimesheetsRepo, - EmployeesRepo, ], }) diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 9ec2604..7c80eca 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -2,16 +2,16 @@ import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { Expenses, Prisma } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; -import { BankCodesRepo } from "../repos/bank-codes.repo"; -import { TimesheetsRepo } from "../repos/timesheets.repo"; -import { EmployeesRepo } from "../repos/employee.repo"; import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; +import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils"; import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; import { assertAndTrimComment, computeAmountDecimal, @@ -25,9 +25,9 @@ import { export class ExpensesCommandService extends BaseApprovalService { constructor( prisma: PrismaService, - private readonly bankCodesRepo: BankCodesRepo, - private readonly timesheetsRepo: TimesheetsRepo, - private readonly employeesRepo: EmployeesRepo, + private readonly bankCodesResolver: BankCodesResolver, + private readonly timesheetsResolver: EmployeeTimesheetResolver, + private readonly emailResolver: EmployeeIdEmailResolver, ) { super(prisma); } //_____________________________________________________________________________________________ @@ -56,27 +56,25 @@ export class ExpensesCommandService extends BaseApprovalService { //validates if there is an existing expense, at least 1 old or new const { old_expense, new_expense } = dto ?? {}; - if(!old_expense && !new_expense) { - throw new BadRequestException('At least one expense must be provided'); - } + if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided'); //validate date format const date_only = toDateOnlyUTC(date); - if(Number.isNaN(date_only.getTime())) { - throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)'); - } + if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)'); //resolve employee_id by email - const employee_id = await this.resolveEmployeeIdByEmail(email); + const employee_id = await this.emailResolver.findIdByEmail(email); //make sure a timesheet existes - const timesheet_id = await this.ensureTimesheetForDate(employee_id, date_only); + const timesheet_id = await this.timesheetsResolver.ensureForDate(employee_id, date_only); + if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`) + const {id} = timesheet_id; return this.prisma.$transaction(async (tx) => { const loadDay = async (): Promise => { const rows = await tx.expenses.findMany({ where: { - timesheet_id: timesheet_id, + timesheet_id: id, date: date_only, }, include: { @@ -118,7 +116,7 @@ export class ExpensesCommandService extends BaseApprovalService { const comment = assertAndTrimComment(payload.comment); const attachment = parseAttachmentId(payload.attachment); - const { id: bank_code_id, modifier } = await this.resolveBankCodeIdByType(tx, type); + const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type); let amount = computeAmountDecimal(type, payload, modifier); let mileage: number | null = null; @@ -139,11 +137,11 @@ export class ExpensesCommandService extends BaseApprovalService { } if (attachment !== null) { - const attachmentRow = await tx.attachments.findUnique({ + const attachment_row = await tx.attachments.findUnique({ where: { id: attachment }, select: { status: true }, }); - if (!attachmentRow || attachmentRow.status !== 'ACTIVE') { + if (!attachment_row || attachment_row.status !== 'ACTIVE') { throw new BadRequestException('Attachment not found or inactive'); } } @@ -167,7 +165,7 @@ export class ExpensesCommandService extends BaseApprovalService { }) => { return tx.expenses.findFirst({ where: { - timesheet_id: timesheet_id, + timesheet_id: id, date: date_only, bank_code_id: norm.bank_code_id, amount: norm.amount, @@ -184,8 +182,8 @@ export class ExpensesCommandService extends BaseApprovalService { // DELETE //_____________________________________________________________________________________________ if(old_expense && !new_expense) { - const oldNorm = await normalizePayload(old_expense); - const existing = await findExactOld(oldNorm); + const old_norm = await normalizePayload(old_expense); + const existing = await findExactOld(old_norm); if(!existing) { throw new NotFoundException({ error_code: 'EXPENSE_STALE', @@ -202,7 +200,7 @@ export class ExpensesCommandService extends BaseApprovalService { const new_exp = await normalizePayload(new_expense); await tx.expenses.create({ data: { - timesheet_id: timesheet_id, + timesheet_id: id, date: date_only, bank_code_id: new_exp.bank_code_id, amount: new_exp.amount, @@ -218,8 +216,8 @@ export class ExpensesCommandService extends BaseApprovalService { // UPDATE //_____________________________________________________________________________________________ else if(old_expense && new_expense) { - const oldNorm = await normalizePayload(old_expense); - const existing = await findExactOld(oldNorm); + const old_norm = await normalizePayload(old_expense); + const existing = await findExactOld(old_norm); if(!existing) { throw new NotFoundException({ error_code: 'EXPENSE_STALE', @@ -249,22 +247,4 @@ export class ExpensesCommandService extends BaseApprovalService { return { action, day }; }); } - - //_____________________________________________________________________________________________ - // HELPERS - //_____________________________________________________________________________________________ - - private readonly resolveEmployeeIdByEmail = async (email: string): Promise => - this.employeesRepo.findIdByEmail(email); - - private readonly ensureTimesheetForDate = async ( employee_id: number, date: Date - ): Promise => { - const { id } = await this.timesheetsRepo.ensureForDate(employee_id, date); - return id; - }; - - private readonly resolveBankCodeIdByType = async ( transaction: Prisma.TransactionClient, type: string - ): Promise<{id: number; modifier: number}> => - this.bankCodesRepo.findByType(type, transaction); - } \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index 9bfdca6..c2fa4a8 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -1,15 +1,15 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; import { DayExpensesDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; -import { EmployeesRepo } from "../repos/employee.repo"; import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers"; import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; @Injectable() export class ExpensesQueryService { constructor( private readonly prisma: PrismaService, - private readonly employeeRepo: EmployeesRepo, + private readonly employeeRepo: EmployeeIdEmailResolver, ) {} diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index fd9106b..80dd614 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -7,21 +7,16 @@ import { TimesheetsModule } from "../timesheets/timesheets.module"; import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service"; import { ExpensesCommandService } from "../expenses/services/expenses-command.service"; import { ShiftsCommandService } from "../shifts/services/shifts-command.service"; -import { BankCodesRepo } from "../expenses/repos/bank-codes.repo"; -import { EmployeesRepo } from "../expenses/repos/employee.repo"; -import { TimesheetsRepo } from "../expenses/repos/timesheets.repo"; +import { SharedModule } from "../shared/shared.module"; @Module({ - imports: [PrismaModule, TimesheetsModule], + imports: [PrismaModule, TimesheetsModule, SharedModule], providers: [ PayPeriodsQueryService, PayPeriodsCommandService, TimesheetsCommandService, ExpensesCommandService, ShiftsCommandService, - BankCodesRepo, - TimesheetsRepo, - EmployeesRepo, ], controllers: [PayPeriodsController], exports: [ diff --git a/src/modules/shared/shared.module.ts b/src/modules/shared/shared.module.ts new file mode 100644 index 0000000..4e71c92 --- /dev/null +++ b/src/modules/shared/shared.module.ts @@ -0,0 +1,22 @@ +import { Module } from "@nestjs/common"; +import { EmployeeIdEmailResolver } from "./utils/resolve-email-id.utils"; +import { EmployeeTimesheetResolver } from "./utils/resolve-employee-timesheet.utils"; +import { FullNameResolver } from "./utils/resolve-full-name.utils"; +import { BankCodesResolver } from "./utils/resolve-bank-type-id.utils"; +import { PrismaModule } from "src/prisma/prisma.module"; + +@Module({ +imports: [PrismaModule], +providers: [ + FullNameResolver, + EmployeeIdEmailResolver, + BankCodesResolver, + EmployeeTimesheetResolver, +], +exports: [ + FullNameResolver, + EmployeeIdEmailResolver, + BankCodesResolver, + EmployeeTimesheetResolver, +], +}) export class SharedModule {} \ No newline at end of file diff --git a/src/modules/expenses/repos/bank-codes.repo.ts b/src/modules/shared/utils/resolve-bank-type-id.utils.ts similarity index 59% rename from src/modules/expenses/repos/bank-codes.repo.ts rename to src/modules/shared/utils/resolve-bank-type-id.utils.ts index 1de277d..039543f 100644 --- a/src/modules/expenses/repos/bank-codes.repo.ts +++ b/src/modules/shared/utils/resolve-bank-type-id.utils.ts @@ -2,11 +2,10 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { Prisma, PrismaClient } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; - type Tx = Prisma.TransactionClient | PrismaClient; @Injectable() -export class BankCodesRepo { +export class BankCodesResolver { constructor(private readonly prisma: PrismaService) {} //find id and modifier by type @@ -14,21 +13,11 @@ export class BankCodesRepo { ): Promise<{id:number; modifier: number }> => { const db = client ?? this.prisma; const bank = await db.bankCodes.findFirst({ - where: { - type, - }, - select: { - id: true, - modifier: true, - }, + where: { type }, + select: { id: true, modifier: true }, }); - if(!bank) { - throw new NotFoundException(`Unknown bank code type: ${type}`); - } - return { - id: bank.id, - modifier: bank.modifier, - }; + if(!bank) throw new NotFoundException(`Unknown bank code type: ${type}`); + return { id: bank.id, modifier: bank.modifier }; }; } \ No newline at end of file diff --git a/src/modules/expenses/repos/employee.repo.ts b/src/modules/shared/utils/resolve-email-id.utils.ts similarity index 60% rename from src/modules/expenses/repos/employee.repo.ts rename to src/modules/shared/utils/resolve-email-id.utils.ts index aeefe53..c232fbe 100644 --- a/src/modules/expenses/repos/employee.repo.ts +++ b/src/modules/shared/utils/resolve-email-id.utils.ts @@ -2,31 +2,22 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { Prisma, PrismaClient } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; - type Tx = Prisma.TransactionClient | PrismaClient; @Injectable() -export class EmployeesRepo { +export class EmployeeIdEmailResolver { + constructor(private readonly prisma: PrismaService) {} - // find employee id by email + // find employee_id using email readonly findIdByEmail = async ( email: string, client?: Tx ): Promise => { const db = client ?? this.prisma; const employee = await db.employees.findFirst({ - where: { - user: { - email, - }, - }, - select: { - id: true, - }, + where: { user: { email } }, + select: { id: true }, }); - - if(!employee) { - throw new NotFoundException(`Employee with email: ${email} not found`); - } + if(!employee)throw new NotFoundException(`Employee with email: ${email} not found`); return employee.id; } } \ No newline at end of file diff --git a/src/modules/expenses/repos/timesheets.repo.ts b/src/modules/shared/utils/resolve-employee-timesheet.utils.ts similarity index 96% rename from src/modules/expenses/repos/timesheets.repo.ts rename to src/modules/shared/utils/resolve-employee-timesheet.utils.ts index e140402..5fb7877 100644 --- a/src/modules/expenses/repos/timesheets.repo.ts +++ b/src/modules/shared/utils/resolve-employee-timesheet.utils.ts @@ -7,7 +7,7 @@ import { PrismaService } from "src/prisma/prisma.service"; type Tx = Prisma.TransactionClient | PrismaClient; @Injectable() -export class TimesheetsRepo { +export class EmployeeTimesheetResolver { constructor(private readonly prisma: PrismaService) {} //find an existing timesheet linked to the employee diff --git a/src/modules/shared/utils/resolve-full-name.utils.ts b/src/modules/shared/utils/resolve-full-name.utils.ts new file mode 100644 index 0000000..ef6669b --- /dev/null +++ b/src/modules/shared/utils/resolve-full-name.utils.ts @@ -0,0 +1,22 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class FullNameResolver { + constructor(private readonly prisma: PrismaService){} + + readonly resolveFullName = async (employee_id: number, client?: Tx): Promise =>{ + const db = client ?? this.prisma; + const employee = await db.employees.findUnique({ + where: { id: employee_id }, + select: { user: { select: {first_name: true, last_name: true} } }, + }); + if(!employee) throw new NotFoundException(`Unknown user with name: ${employee_id}`) + + const full_name = ( employee.user.first_name + " " + employee.user.last_name ) || " "; + return full_name; + } +} \ No newline at end of file diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index f0bd218..f12d9ad 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -6,7 +6,7 @@ import { ShiftsCommandService } from "../services/shifts-command.service"; import { ShiftsQueryService } from "../services/shifts-query.service"; import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto"; import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; -import { OverviewRow } from "../types and interfaces/shifts-overview-row.interface"; +import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface"; @ApiTags('Shifts') @ApiBearerAuth('access-token') diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts index 3e9e7f6..b00cf7b 100644 --- a/src/modules/shifts/helpers/shifts-date-time-helpers.ts +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -3,13 +3,11 @@ export function timeFromHHMMUTC(hhmm: string): Date { return new Date(Date.UTC(1970,0,1,hour, min,0)); } -export function weekStartMondayUTC(date: Date): Date { - const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +export function weekStartSundayUTC(d: Date): Date { const day = d.getUTCDay(); - const diff = (day + 6) % 7; - d.setUTCDate(d.getUTCDate() - diff); - d.setUTCHours(0,0,0,0); - return d; + const start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + start.setUTCDate(start.getUTCDate()- day); + return start; } export function toDateOnlyUTC(input: string | Date): Date { diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 023196d..f7b59f3 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,7 +1,9 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; -import { formatHHmm, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers"; -import { normalizeShiftPayload, overlaps, resolveBankCodeByType } from "../utils/shifts.utils"; -import { DayShiftResponse, UpsertAction } from "../types and interfaces/shifts-upsert.types"; +import { formatHHmm, toDateOnlyUTC, weekStartSundayUTC } from "../helpers/shifts-date-time-helpers"; +import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils"; +import { DayShiftResponse, UpsertAction } from "../types-and-interfaces/shifts-upsert.types"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; import { Prisma, Shifts } from "@prisma/client"; import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; @@ -9,7 +11,11 @@ import { PrismaService } from "src/prisma/prisma.service"; @Injectable() export class ShiftsCommandService extends BaseApprovalService { - constructor(prisma: PrismaService) { super(prisma); } + constructor( + prisma: PrismaService, + private readonly emailResolver: EmployeeIdEmailResolver, + private readonly bankTypeResolver: BankCodesResolver, + ) { super(prisma); } //_____________________________________________________________________________________________ // APPROVAL AND DELEGATE METHODS @@ -40,65 +46,147 @@ export class ShiftsCommandService extends BaseApprovalService { } const date_only = toDateOnlyUTC(date_string); + const employee_id = await this.emailResolver.findIdByEmail(email); - //Resolve employee by email - const employee = await this.prisma.employees.findFirst({ - where: { user: {email } }, - select: { id: true }, - }); - if(!employee) throw new NotFoundException(`Employee not found for email : ${ email }`); + return this.prisma.$transaction(async (tx) => { + const start_of_week = weekStartSundayUTC(date_only); - //making sure a timesheet exist in selected week - const start_of_week = weekStartMondayUTC(date_only); - let timesheet = await this.prisma.timesheets.findFirst({ - where: { - employee_id: employee.id, - start_date: start_of_week - }, - select: { - id: true - }, - }); - if(!timesheet) { - timesheet = await this.prisma.timesheets.create({ - data: { - employee_id: employee.id, - start_date: start_of_week - }, - select: { - id: true + const timesheet = await tx.timesheets.upsert({ + where: { employee_id_start_date: { employee_id, start_date: start_of_week } }, + update: {}, + create: { employee_id, start_date: start_of_week }, + select: { id: true }, + }); + + //validation/sanitation + //resolve bank_code_id using type + const old_norm_shift = old_shift ? await normalizeShiftPayload(old_shift) : undefined; + if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) { + throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); + } + const old_bank_code_id: number | undefined = old_norm_shift ? (await this.bankTypeResolver.findByType(old_norm_shift.type, tx))?.id : undefined; + + + const new_norm_shift = new_shift ? await normalizeShiftPayload(new_shift) : undefined; + if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) { + throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); + } + const new_bank_code_id: number | undefined = new_norm_shift ? (await this.bankTypeResolver.findByType(new_norm_shift.type, tx))?.id : undefined; + + + //fetch all shifts in a single day and verify possible overlaps + const day_shifts = await tx.shifts.findMany({ + where: { timesheet_id: timesheet.id, date: date_only }, + include: { bank_code: true }, + orderBy: { start_time: 'asc'}, + }); + + + const findExactOldShift = async ()=> { + if(!old_norm_shift || old_bank_code_id === undefined) return undefined; + const old_comment = old_norm_shift.comment ?? null; + + return await tx.shifts.findFirst({ + where: { + timesheet_id: timesheet.id, + date: date_only, + start_time: old_norm_shift.start_time, + end_time: old_norm_shift.end_time, + is_remote: old_norm_shift.is_remote, + comment: old_comment, + bank_code_id: old_bank_code_id, + }, + select: { id: true }, + }); + }; + + //checks for overlaping shifts + const assertNoOverlap = (exclude_shift_id?: number)=> { + if (!new_norm_shift) return; + const overlap_with = day_shifts.filter((shift)=> { + if(exclude_shift_id && shift.id === exclude_shift_id) return false; + return overlaps( + new_norm_shift.start_time.getTime(), + new_norm_shift.end_time.getTime(), + shift.start_time.getTime(), + shift.end_time.getTime(), + ); + }); + + if(overlap_with.length > 0) { + const conflicts = overlap_with.map((shift)=> ({ + start_time: formatHHmm(shift.start_time), + end_time: formatHHmm(shift.end_time), + type: shift.bank_code?.type ?? 'UNKNOWN', + })); + throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts}); + } + }; + let action: UpsertAction; + //_____________________________________________________________________________________________ + // DELETE + //_____________________________________________________________________________________________ + if ( old_shift && !new_shift ) { + if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`); + const existing = await findExactOldShift(); + if(!existing) { + throw new NotFoundException({ + error_code: 'SHIFT_STALE', + message: 'The shift was modified or deleted by someone else', + }); + } + await tx.shifts.delete({ where: { id: existing.id } } ); + action = 'deleted'; + } + //_____________________________________________________________________________________________ + // CREATE + //_____________________________________________________________________________________________ + else if (!old_shift && new_shift) { + if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); + assertNoOverlap(); + await tx.shifts.create({ + data: { + timesheet_id: timesheet.id, + date: date_only, + start_time: new_norm_shift!.start_time, + end_time: new_norm_shift!.end_time, + is_remote: new_norm_shift!.is_remote, + comment: new_norm_shift!.comment ?? null, + bank_code_id: new_bank_code_id!, }, }); + action = 'created'; } + //_____________________________________________________________________________________________ + // UPDATE + //_____________________________________________________________________________________________ + else if (old_shift && new_shift){ + if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`); + if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); + const existing = await findExactOldShift(); + if(!existing) throw new NotFoundException({ error_code: 'SHIFT_STALE', message: 'The shift was modified or deleted by someone else'}); + assertNoOverlap(existing.id); - //normalization of data to ensure a valid comparison between DB and payload - const old_norm = dto.old_shift - ? normalizeShiftPayload(dto.old_shift) - : undefined; - const new_norm = dto.new_shift - ? normalizeShiftPayload(dto.new_shift) - : undefined; + await tx.shifts.update({ + where: { + id: existing.id + }, + data: { + start_time: new_norm_shift!.start_time, + end_time: new_norm_shift!.end_time, + is_remote: new_norm_shift!.is_remote, + comment: new_norm_shift!.comment ?? null, + bank_code_id: new_bank_code_id, + }, + }); + action = 'updated'; + } else throw new BadRequestException('At least one of old_shift or new_shift must be provided'); - if (old_norm && old_norm.end_time.getTime() <= old_norm.start_time.getTime()) { - throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); - } - if (new_norm && new_norm.end_time.getTime() <= new_norm.start_time.getTime()) { - throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); - } - - //Resolve bank_code_id with type - const old_bank_code_id = old_norm - ? await resolveBankCodeByType(old_norm.type) - : undefined; - const new_bank_code_id = new_norm - ? await resolveBankCodeByType(new_norm.type) - : undefined; - - //fetch all shifts in a single day - const day_shifts = await this.prisma.shifts.findMany({ - where: { - timesheet_id: timesheet.id, - date: date_only + //Reload the day (truth source) + const fresh_day = await tx.shifts.findMany({ + where: { + date: date_only, + timesheet_id: timesheet.id, }, include: { bank_code: true @@ -108,140 +196,16 @@ export class ShiftsCommandService extends BaseApprovalService { }, }); - const result = await this.prisma.$transaction(async (transaction)=> { - let action: UpsertAction; - - const findExactOldShift = async ()=> { - if(!old_norm || old_bank_code_id === undefined) return undefined; - const old_comment = old_norm.comment ?? null; - - return transaction.shifts.findFirst({ - where: { - timesheet_id: timesheet.id, - date: date_only, - start_time: old_norm.start_time, - end_time: old_norm.end_time, - is_remote: old_norm.is_remote, - comment: old_comment, - bank_code_id: old_bank_code_id, - }, - select: { id: true }, - }); - }; - - //checks for overlaping shifts - const assertNoOverlap = (exclude_shift_id?: number)=> { - if (!new_norm) return; - const overlap_with = day_shifts.filter((shift)=> { - if(exclude_shift_id && shift.id === exclude_shift_id) return false; - return overlaps( - new_norm.start_time.getTime(), - new_norm.end_time.getTime(), - shift.start_time.getTime(), - shift.end_time.getTime(), - ); - }); - - if(overlap_with.length > 0) { - const conflicts = overlap_with.map((shift)=> ({ - start_time: formatHHmm(shift.start_time), - end_time: formatHHmm(shift.end_time), - type: shift.bank_code?.type ?? 'UNKNOWN', - })); - throw new ConflictException({ - error_code: 'SHIFT_OVERLAP', - message: 'New shift overlaps with existing shift(s)', - conflicts, - }); - } - }; - - //_____________________________________________________________________________________________ - // DELETE - //_____________________________________________________________________________________________ - if ( old_shift && !new_shift ) { - const existing = await findExactOldShift(); - if(!existing) { - throw new NotFoundException({ - error_code: 'SHIFT_STALE', - message: 'The shift was modified or deleted by someone else', - }); - } - await transaction.shifts.delete({ where: { id: existing.id } } ); - action = 'deleted'; - } - //_____________________________________________________________________________________________ - // CREATE - //_____________________________________________________________________________________________ - else if (!old_shift && new_shift) { - assertNoOverlap(); - await transaction.shifts.create({ - data: { - timesheet_id: timesheet.id, - date: date_only, - start_time: new_norm!.start_time, - end_time: new_norm!.end_time, - is_remote: new_norm!.is_remote, - comment: new_norm!.comment ?? null, - bank_code_id: new_bank_code_id!, - }, - }); - action = 'created'; - } - //_____________________________________________________________________________________________ - // UPDATE - //_____________________________________________________________________________________________ - else if (old_shift && new_shift){ - const existing = await findExactOldShift(); - if(!existing) { - throw new NotFoundException({ - error_code: 'SHIFT_STALE', - message: 'The shift was modified or deleted by someone else', - }); - } - assertNoOverlap(existing.id); - await transaction.shifts.update({ - where: { - id: existing.id - }, - data: { - start_time: new_norm!.start_time, - end_time: new_norm!.end_time, - is_remote: new_norm!.is_remote, - comment: new_norm!.comment ?? null, - bank_code_id: new_bank_code_id, - }, - }); - action = 'updated'; - } else { - throw new BadRequestException('At least one of old_shift or new_shift must be provided'); - } - - //Reload the day (truth source) - const fresh_day = await transaction.shifts.findMany({ - where: { - date: date_only, - timesheet_id: timesheet.id, - }, - include: { - bank_code: true - }, - orderBy: { - start_time: 'asc' - }, - }); - - return { - action, - day: fresh_day.map((shift)=> ({ - start_time: formatHHmm(shift.start_time), - end_time: formatHHmm(shift.end_time), - type: shift.bank_code?.type ?? 'UNKNOWN', - is_remote: shift.is_remote, - comment: shift.comment ?? null, - })), - }; - }); - return result; - } + return { + action, + day: fresh_day.map((shift)=> ({ + start_time: formatHHmm(shift.start_time), + end_time: formatHHmm(shift.end_time), + type: shift.bank_code?.type ?? 'UNKNOWN', + is_remote: shift.is_remote, + comment: shift.comment ?? null, + })), + }; + }); + } } \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-query.service.ts b/src/modules/shifts/services/shifts-query.service.ts index 0d6bc6f..bfe3fe8 100644 --- a/src/modules/shifts/services/shifts-query.service.ts +++ b/src/modules/shifts/services/shifts-query.service.ts @@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; import { NotificationsService } from "src/modules/notifications/services/notifications.service"; import { computeHours } from "src/common/utils/date-utils"; -import { OverviewRow } from "../types and interfaces/shifts-overview-row.interface"; +import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface"; // const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12); diff --git a/src/modules/shifts/types and interfaces/shifts-overview-row.interface.ts b/src/modules/shifts/types-and-interfaces/shifts-overview-row.interface.ts similarity index 100% rename from src/modules/shifts/types and interfaces/shifts-overview-row.interface.ts rename to src/modules/shifts/types-and-interfaces/shifts-overview-row.interface.ts diff --git a/src/modules/shifts/types and interfaces/shifts-upsert.types.ts b/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts similarity index 100% rename from src/modules/shifts/types and interfaces/shifts-upsert.types.ts rename to src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index c0ff293..98350ab 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -1,13 +1,12 @@ -import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common'; import { TimesheetsQueryService } from '../services/timesheets-query.service'; -import { CreateTimesheetDto, CreateWeekShiftsDto } from '../dtos/create-timesheet.dto'; -import { Timesheets } from '@prisma/client'; +import { CreateWeekShiftsDto } from '../dtos/create-timesheet.dto'; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { Roles as RoleEnum } from '.prisma/client'; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { TimesheetsCommandService } from '../services/timesheets-command.service'; -import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; -import { TimesheetDto } from '../dtos/overview-timesheet.dto'; +import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; + @ApiTags('Timesheets') @ApiBearerAuth('access-token') diff --git a/src/modules/timesheets/dtos/create-timesheet.dto.ts b/src/modules/timesheets/dtos/create-timesheet.dto.ts index 1a53a08..a6fd0b2 100644 --- a/src/modules/timesheets/dtos/create-timesheet.dto.ts +++ b/src/modules/timesheets/dtos/create-timesheet.dto.ts @@ -22,7 +22,7 @@ export class CreateTimesheetDto { @IsOptional() @IsString() @Length(0,512) - comment?: string; + comment?: string; } export class CreateWeekShiftsDto { diff --git a/src/modules/timesheets/dtos/overview-timesheet.dto.ts b/src/modules/timesheets/dtos/overview-timesheet.dto.ts deleted file mode 100644 index 417f913..0000000 --- a/src/modules/timesheets/dtos/overview-timesheet.dto.ts +++ /dev/null @@ -1,28 +0,0 @@ -export class TimesheetDto { - is_approved: boolean; - start_day: string; - end_day: string; - label: string; - shifts: ShiftsDto[]; - expenses: ExpensesDto[] -} - -export class ShiftsDto { - bank_type: string; - date: string; - start_time: string; - end_time: string; - comment: string; - is_approved: boolean; - is_remote: boolean; -} - -export class ExpensesDto { - bank_type: string; - date: string; - amount: number; - mileage: number; - comment: string; - supervisor_comment: string; - is_approved: boolean; -} \ No newline at end of file diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index c4ef385..333716b 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -1,3 +1,12 @@ +export class TimesheetDto { + start_day: string; + end_day: string; + label: string; + shifts: ShiftDto[]; + expenses: ExpenseDto[] + is_approved: boolean; +} + export class ShiftDto { date: string; type: string; @@ -31,7 +40,7 @@ export class DetailedShifts { } export class DayExpensesDto { - expenses: ExpenseDto[]; + expenses: ExpenseDto[] = []; total_mileage: number; total_expense: number; } diff --git a/src/modules/timesheets/dtos/update-timesheet.dto.ts b/src/modules/timesheets/dtos/update-timesheet.dto.ts deleted file mode 100644 index d621e6a..0000000 --- a/src/modules/timesheets/dtos/update-timesheet.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from "@nestjs/swagger"; -import { CreateTimesheetDto } from "./create-timesheet.dto"; - -export class UpdateTimesheetDto extends PartialType(CreateTimesheetDto) {} diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 5564f0d..59652e5 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -4,15 +4,21 @@ import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { PrismaService } from "src/prisma/prisma.service"; import { TimesheetsQueryService } from "./timesheets-query.service"; import { CreateTimesheetDto } from "../dtos/create-timesheet.dto"; -import { TimesheetDto } from "../dtos/overview-timesheet.dto"; import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils"; import { parseISODate, parseHHmm } from "../utils/timesheet.helpers"; +import { TimesheetDto } from "../dtos/timesheet-period.dto"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; @Injectable() export class TimesheetsCommandService extends BaseApprovalService{ constructor( prisma: PrismaService, private readonly query: TimesheetsQueryService, + private readonly emailResolver: EmployeeIdEmailResolver, + private readonly timesheetResolver: EmployeeTimesheetResolver, + private readonly bankTypeResolver: BankCodesResolver, ) {super(prisma);} //_____________________________________________________________________________________________ // APPROVAL AND DELEGATE METHODS @@ -33,17 +39,14 @@ export class TimesheetsCommandService extends BaseApprovalService{ async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise { const timesheet = await this.updateApprovalWithTransaction(transaction, timesheetId, isApproved); - await transaction.shifts.updateMany({ where: { timesheet_id: timesheetId }, data: { is_approved: isApproved }, }); - await transaction.expenses.updateManyAndReturn({ where: { timesheet_id: timesheetId }, data: { is_approved: isApproved }, }); - return timesheet; } @@ -56,20 +59,9 @@ export class TimesheetsCommandService extends BaseApprovalService{ shifts: CreateTimesheetDto[], week_offset = 0, ): Promise { - - //match user's email with email - const user = await this.prisma.users.findUnique({ - where: { email }, - select: { id: true }, - }); - if(!user) throw new NotFoundException(`user with email ${email} not found`); - //fetchs employee matchint user's email - const employee = await this.prisma.employees.findFirst({ - where: { user_id: user?.id }, - select: { id: true }, - }); - if(!employee) throw new NotFoundException(`employee for ${ email } not found`); + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`employee for ${ email } not found`); //insure that the week starts on sunday and finishes on saturday const base = new Date(); @@ -77,43 +69,27 @@ export class TimesheetsCommandService extends BaseApprovalService{ const start_week = getWeekStart(base, 0); const end_week = getWeekEnd(start_week); - const timesheet = await this.prisma.timesheets.upsert({ - where: { - employee_id_start_date: { - employee_id: employee.id, - start_date: start_week, - }, - }, - create: { - employee_id: employee.id, - start_date: start_week, - is_approved: false, - }, - update: {}, - select: { id: true }, - }); + const timesheet = await this.timesheetResolver.ensureForDate(employee_id, base) + if(!timesheet) throw new NotFoundException(`no timesheet found for employe ${employee_id}`); //validations and insertions for(const shift of shifts) { const date = parseISODate(shift.date); if (date < start_week || date > end_week) throw new BadRequestException(`date ${shift.date} not in current week`); - const bank_code = await this.prisma.bankCodes.findFirst({ - where: { type: shift.type }, - select: { id: true }, - }); + const bank_code = await this.bankTypeResolver.findByType(shift.type) if(!bank_code) throw new BadRequestException(`Invalid bank_code type: ${shift.type}`); await this.prisma.shifts.create({ data: { timesheet_id: timesheet.id, bank_code_id: bank_code.id, - date: date, - start_time: parseHHmm(shift.start_time), - end_time: parseHHmm(shift.end_time), - comment: shift.comment ?? null, - is_approved: false, - is_remote: false, + date: date, + start_time: parseHHmm(shift.start_time), + end_time: parseHHmm(shift.end_time), + comment: shift.comment ?? null, + is_approved: false, + is_remote: false, }, }); } diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 9355728..c14387f 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -2,42 +2,29 @@ import { endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers'; import { Injectable, NotFoundException } from '@nestjs/common'; import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; import { PrismaService } from 'src/prisma/prisma.service'; -import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; -import { TimesheetDto } from '../dtos/overview-timesheet.dto'; -import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; +import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; import { ShiftRow, ExpenseRow } from '../types/timesheet.types'; import { buildPeriod } from '../utils/timesheet.utils'; +import { EmployeeIdEmailResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; +import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils'; @Injectable() export class TimesheetsQueryService { constructor( private readonly prisma: PrismaService, - // private readonly overtime: OvertimeService, + private readonly emailResolver: EmployeeIdEmailResolver, + private readonly fullNameResolver: FullNameResolver ) {} 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, - user_id: true, - }, - }); - if(!employee) throw new NotFoundException(`no employee with email ${email} found`); - - //gets the employee's full name - const user = await this.prisma.users.findFirst({ - where: { id: employee.user_id }, - select: { - first_name: true, - last_name: true, - } - }); - const employee_full_name: string = ( user?.first_name + " " + user?.last_name ) || " "; + //finds the employee using email + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`employee with email : ${email} not found`); + + //finds the employee full name using employee_id + const full_name = await this.fullNameResolver.resolveFullName(employee_id); + if(!full_name) throw new NotFoundException(`employee with id: ${employee_id} not found`) //finds the period const period = await this.prisma.payPeriods.findFirst({ @@ -57,7 +44,7 @@ export class TimesheetsQueryService { const raw_shifts = await this.prisma.shifts.findMany({ where: { - timesheet: { is: { employee_id: employee.id } }, + timesheet: { is: { employee_id: employee_id } }, date: { gte: from, lte: to }, }, select: { @@ -74,7 +61,7 @@ export class TimesheetsQueryService { const raw_expenses = await this.prisma.expenses.findMany({ where: { - timesheet: { is: { employee_id: employee.id } }, + timesheet: { is: { employee_id: employee_id } }, date: { gte: from, lte: to }, }, select: { @@ -115,24 +102,12 @@ export class TimesheetsQueryService { supervisor_comment: expense.supervisor_comment ?? '', })); - return buildPeriod(period.period_start, period.period_end, shifts , expenses, employee_full_name); + return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name); } async getTimesheetByEmail(email: string, week_offset = 0): Promise { - - //fetch user related to email - const user = await this.prisma.users.findUnique({ - where: { email }, - select: { id: true }, - }); - if(!user) throw new NotFoundException(`user with email ${email} not found`); - - //fetch employee_id matching the email - const employee = await this.prisma.employees.findFirst({ - where: { user_id: user.id }, - select: { id: true }, - }); - if(!employee) throw new NotFoundException(`Employee with email: ${email} not found`); + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); //sets current week Sunday -> Saturday const base = new Date(); @@ -152,7 +127,7 @@ export class TimesheetsQueryService { const timesheet = await this.prisma.timesheets.findUnique({ where: { employee_id_start_date: { - employee_id: employee.id, + employee_id: employee_id, start_date: start_date_week, }, }, @@ -182,7 +157,7 @@ export class TimesheetsQueryService { //maps all shifts of selected timesheet const shifts = timesheet.shift.map((shift_row) => ({ - bank_type: shift_row.bank_code?.type ?? '', + type: shift_row.bank_code?.type ?? '', date: formatDateISO(shift_row.date), start_time: toHHmm(shift_row.start_time), end_time: toHHmm(shift_row.end_time), @@ -193,7 +168,7 @@ export class TimesheetsQueryService { //maps all expenses of selected timsheet const expenses = timesheet.expense.map((exp) => ({ - bank_type: exp.bank_code?.type ?? '', + type: exp.bank_code?.type ?? '', date: formatDateISO(exp.date), amount: Number(exp.amount) || 0, mileage: exp.mileage != null ? Number(exp.mileage) : 0, @@ -203,12 +178,12 @@ export class TimesheetsQueryService { })); return { - is_approved: timesheet.is_approved, start_day, end_day, label, shifts, expenses, + is_approved: timesheet.is_approved, } as TimesheetDto; } //_____________________________________________________________________________________________ diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index 450c7b3..c7636ba 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -5,24 +5,20 @@ import { TimesheetsCommandService } from './services/timesheets-command.service' import { ShiftsCommandService } from '../shifts/services/shifts-command.service'; import { ExpensesCommandService } from '../expenses/services/expenses-command.service'; import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; -import { BankCodesRepo } from '../expenses/repos/bank-codes.repo'; -import { TimesheetsRepo } from '../expenses/repos/timesheets.repo'; -import { EmployeesRepo } from '../expenses/repos/employee.repo'; -import { Module } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { Module } from '@nestjs/common'; @Module({ - imports: [BusinessLogicsModule], + imports: [BusinessLogicsModule, SharedModule], controllers: [TimesheetsController], providers: [ - TimesheetsQueryService, - TimesheetsCommandService, - ShiftsCommandService, - ExpensesCommandService, - TimesheetArchiveService, - BankCodesRepo, - TimesheetsRepo, - EmployeesRepo, - ], + TimesheetsQueryService, + TimesheetsCommandService, + ShiftsCommandService, + ExpensesCommandService, + TimesheetArchiveService, + + ], exports: [ TimesheetsQueryService, TimesheetArchiveService, From 9b169d43c80bb9b4fe22fdac3bd4ace33f942cbf Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 8 Oct 2025 08:57:24 -0400 Subject: [PATCH 58/69] resolve mergre --- .../utils/resolve-employee-timesheet.utils.ts | 4 ++-- src/modules/shifts/shifts.module.ts | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/modules/shared/utils/resolve-employee-timesheet.utils.ts b/src/modules/shared/utils/resolve-employee-timesheet.utils.ts index 5fb7877..1117dca 100644 --- a/src/modules/shared/utils/resolve-employee-timesheet.utils.ts +++ b/src/modules/shared/utils/resolve-employee-timesheet.utils.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { Prisma, PrismaClient } from "@prisma/client"; -import { weekStartMondayUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; +import { weekStartSundayUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; import { PrismaService } from "src/prisma/prisma.service"; @@ -14,7 +14,7 @@ export class EmployeeTimesheetResolver { readonly ensureForDate = async (employee_id: number, date: Date, client?: Tx, ): Promise<{id: number; start_date: Date }> => { const db = client ?? this.prisma; - const startOfWeek = weekStartMondayUTC(date); + const startOfWeek = weekStartSundayUTC(date); const existing = await db.timesheets.findFirst({ where: { employee_id: employee_id, diff --git a/src/modules/shifts/shifts.module.ts b/src/modules/shifts/shifts.module.ts index 103442a..2e507e2 100644 --- a/src/modules/shifts/shifts.module.ts +++ b/src/modules/shifts/shifts.module.ts @@ -5,11 +5,24 @@ import { ShiftsCommandService } from './services/shifts-command.service'; import { NotificationsModule } from '../notifications/notifications.module'; import { ShiftsQueryService } from './services/shifts-query.service'; import { ShiftsArchivalService } from './services/shifts-archival.service'; +import { BankCodesResolver } from '../shared/utils/resolve-bank-type-id.utils'; +import { EmployeeIdEmailResolver } from '../shared/utils/resolve-email-id.utils'; @Module({ imports: [BusinessLogicsModule, NotificationsModule], controllers: [ShiftsController], - providers: [ShiftsQueryService, ShiftsCommandService, ShiftsArchivalService], - exports: [ShiftsQueryService, ShiftsCommandService, ShiftsArchivalService], + providers: [ + ShiftsQueryService, + ShiftsCommandService, + ShiftsArchivalService, + BankCodesResolver, + EmployeeIdEmailResolver], + exports: [ + ShiftsQueryService, + ShiftsCommandService, + ShiftsArchivalService, + BankCodesResolver, + EmployeeIdEmailResolver + ], }) export class ShiftsModule {} From 83792e596a504b2b922e73bcbdf2fdc4d5d17b62 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 8 Oct 2025 16:45:37 -0400 Subject: [PATCH 59/69] feat(schedule_presets): module schedule_presets setup. Ajustments to seeders to match new realities --- docs/swagger/swagger-spec.json | 66 +++++ .../migration.sql | 48 ++++ prisma/mock-seeds-scripts/02-users.ts | 184 ++++++++++--- prisma/mock-seeds-scripts/12-expenses.ts | 14 +- prisma/schema.prisma | 257 +++++++++++------- src/app.module.ts | 4 +- .../controller/schedule-presets.controller.ts | 31 +++ .../dtos/create-schedule-preset-shifts.dto.ts | 26 ++ .../dtos/create-schedule-presets.dto.ts | 15 + .../schedule-presets.module.ts | 23 ++ .../schedule-presets-apply.service.ts | 45 +++ .../schedule-presets-command.service.ts | 238 ++++++++++++++++ .../schedule-presets-query.service.ts | 51 ++++ .../types/schedule-presets.types.ts | 21 ++ .../shared/constants/regex.constant.ts | 2 + .../shared/types/upsert-actions.types.ts | 1 + 16 files changed, 884 insertions(+), 142 deletions(-) create mode 100644 prisma/migrations/20251008152226_added_schedule_presets_and_schedule_preset_shifts_tables_and_weekday_enm/migration.sql create mode 100644 src/modules/schedule-presets/controller/schedule-presets.controller.ts create mode 100644 src/modules/schedule-presets/dtos/create-schedule-preset-shifts.dto.ts create mode 100644 src/modules/schedule-presets/dtos/create-schedule-presets.dto.ts create mode 100644 src/modules/schedule-presets/schedule-presets.module.ts create mode 100644 src/modules/schedule-presets/services/schedule-presets-apply.service.ts create mode 100644 src/modules/schedule-presets/services/schedule-presets-command.service.ts create mode 100644 src/modules/schedule-presets/services/schedule-presets-query.service.ts create mode 100644 src/modules/schedule-presets/types/schedule-presets.types.ts create mode 100644 src/modules/shared/constants/regex.constant.ts create mode 100644 src/modules/shared/types/upsert-actions.types.ts diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 106add4..01572f2 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -1072,6 +1072,68 @@ "pay-periods" ] } + }, + "/schedule-presets/{email}": { + "put": { + "operationId": "SchedulePresetsController_upsert", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "action", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchedulePresetsDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "SchedulePresets" + ] + }, + "get": { + "operationId": "SchedulePresetsController_findListByEmail", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "SchedulePresets" + ] + } } }, "info": { @@ -1584,6 +1646,10 @@ "label", "employees_overview" ] + }, + "SchedulePresetsDto": { + "type": "object", + "properties": {} } } } diff --git a/prisma/migrations/20251008152226_added_schedule_presets_and_schedule_preset_shifts_tables_and_weekday_enm/migration.sql b/prisma/migrations/20251008152226_added_schedule_presets_and_schedule_preset_shifts_tables_and_weekday_enm/migration.sql new file mode 100644 index 0000000..4bc5fd9 --- /dev/null +++ b/prisma/migrations/20251008152226_added_schedule_presets_and_schedule_preset_shifts_tables_and_weekday_enm/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "Weekday" AS ENUM ('SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'); + +-- AlterTable +ALTER TABLE "preferences" ADD COLUMN "id" SERIAL NOT NULL, +ADD CONSTRAINT "preferences_pkey" PRIMARY KEY ("id"); + +-- CreateTable +CREATE TABLE "schedule_presets" ( + "id" SERIAL NOT NULL, + "employee_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "is_default" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "schedule_presets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "schedule_preset_shifts" ( + "id" SERIAL NOT NULL, + "preset_id" INTEGER NOT NULL, + "bank_code_id" INTEGER NOT NULL, + "sort_order" INTEGER NOT NULL, + "start_time" TIME(0) NOT NULL, + "end_time" TIME(0) NOT NULL, + "is_remote" BOOLEAN NOT NULL DEFAULT false, + "week_day" "Weekday" NOT NULL, + + CONSTRAINT "schedule_preset_shifts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "schedule_presets_employee_id_name_key" ON "schedule_presets"("employee_id", "name"); + +-- CreateIndex +CREATE INDEX "schedule_preset_shifts_preset_id_week_day_idx" ON "schedule_preset_shifts"("preset_id", "week_day"); + +-- CreateIndex +CREATE UNIQUE INDEX "schedule_preset_shifts_preset_id_week_day_sort_order_key" ON "schedule_preset_shifts"("preset_id", "week_day", "sort_order"); + +-- AddForeignKey +ALTER TABLE "schedule_presets" ADD CONSTRAINT "schedule_presets_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "schedule_preset_shifts" ADD CONSTRAINT "schedule_preset_shifts_preset_id_fkey" FOREIGN KEY ("preset_id") REFERENCES "schedule_presets"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "schedule_preset_shifts" ADD CONSTRAINT "schedule_preset_shifts_bank_code_id_fkey" FOREIGN KEY ("bank_code_id") REFERENCES "bank_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/mock-seeds-scripts/02-users.ts b/prisma/mock-seeds-scripts/02-users.ts index 04ec7e4..81e30da 100644 --- a/prisma/mock-seeds-scripts/02-users.ts +++ b/prisma/mock-seeds-scripts/02-users.ts @@ -3,41 +3,46 @@ import { PrismaClient, Roles } from '@prisma/client'; const prisma = new PrismaClient(); // base sans underscore, en string -const BASE_PHONE = "1100000000"; +const BASE_PHONE = '1100000000'; function emailFor(i: number) { return `user${i + 1}@example.test`; } async function main() { - const usersData: { + type UserSeed = { first_name: string; last_name: string; email: string; phone_number: string; residence?: string | null; role: Roles; - }[] = []; + }; - const firstNames = ['Alex','Sam','Chris','Jordan','Taylor','Morgan','Jamie','Robin','Avery','Casey']; - const lastNames = ['Smith','Johnson','Williams','Brown','Jones','Miller','Davis','Wilson','Taylor','Clark']; + const usersData: UserSeed[] = []; - const pick = (arr: T[]) => arr[Math.floor(Math.random() * arr.length)]; + const firstNames = ['Alex', 'Sam', 'Chris', 'Jordan', 'Taylor', 'Morgan', 'Jamie', 'Robin', 'Avery', 'Casey']; + const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Miller', 'Davis', 'Wilson', 'Taylor', 'Clark']; - // 40 employees, avec une distribution initiale - const rolesForEmployees: Roles[] = [ - Roles.ADMIN, - ...Array(4).fill(Roles.SUPERVISOR), // 4 superviseurs - Roles.HR, - Roles.ACCOUNTING, - ...Array(33).fill(Roles.EMPLOYEE), + const pick = (arr: T[]) => arr[Math.floor(Math.random() * arr.length)]; + + /** + * Objectif total: 50 users + * - 39 employés génériques (dont ADMIN=1, SUPERVISOR=3, HR=1, ACCOUNTING=1, EMPLOYEE=33) + * - +1 superviseur spécial "User Test" (=> 40 employés) + * - 10 customers + */ + const rolesForEmployees39: Roles[] = [ + Roles.ADMIN, // 1 + ...Array(3).fill(Roles.SUPERVISOR), // 3 supervisors (le 4e sera "User Test") + Roles.HR, // 1 + Roles.ACCOUNTING, // 1 + ...Array(33).fill(Roles.EMPLOYEE), // 33 + // total = 39 ]; - // --- Normalisation : forcer user5@example.test à SUPERVISOR --- - // user5 => index 4 (i = 4) - rolesForEmployees[4] = Roles.SUPERVISOR; - - for (let i = 0; i < 40; i++) { + // --- 39 employés génériques: user1..user39@example.test + for (let i = 0; i < 39; i++) { const fn = pick(firstNames); const ln = pick(lastNames); usersData.push({ @@ -46,12 +51,12 @@ async function main() { email: emailFor(i), phone_number: BASE_PHONE + i.toString(), residence: Math.random() < 0.5 ? 'QC' : 'ON', - role: rolesForEmployees[i], + role: rolesForEmployees39[i], }); } - // 10 customers - for (let i = 40; i < 50; i++) { + // --- 10 customers: user40..user49@example.test + for (let i = 39; i < 49; i++) { const fn = pick(firstNames); const ln = pick(lastNames); usersData.push({ @@ -64,29 +69,132 @@ async function main() { }); } - // 1) Insert (sans doublons) + // 1) Insert des 49 génériques (skipDuplicates pour rejouer le seed sans erreurs) await prisma.users.createMany({ data: usersData, skipDuplicates: true }); - // 2) Validation/Correction post-insert : - // - garantir que user5@example.test est SUPERVISOR - // - si jamais le projet avait un user avec la typo, on tente aussi de le corriger (fallback) - const targetEmails = ['user5@example.test', 'user5@examplte.tset']; - for (const email of targetEmails) { - try { - await prisma.users.update({ - where: { email }, - data: { role: Roles.SUPERVISOR }, + // 2) Upsert du superviseur spécial "User Test" + const specialEmail = 'user@targointernet.com'; + const specialUser = await prisma.users.upsert({ + where: { email: specialEmail }, + update: { + first_name: 'User', + last_name: 'Test', + role: Roles.SUPERVISOR, + residence: 'QC', + phone_number: BASE_PHONE + '999', + }, + create: { + first_name: 'User', + last_name: 'Test', + email: specialEmail, + role: Roles.SUPERVISOR, + residence: 'QC', + phone_number: BASE_PHONE + '999', + }, + }); + + // 3) Créer/mettre à jour les entrées Employees pour tous les rôles employés + const employeeUsers = await prisma.users.findMany({ + where: { role: { in: [Roles.ADMIN, Roles.SUPERVISOR, Roles.HR, Roles.ACCOUNTING, Roles.EMPLOYEE] } }, + orderBy: { email: 'asc' }, + }); + + const firstWorkDay = new Date('2025-01-06'); // à adapter à ton contexte + + for (let i = 0; i < employeeUsers.length; i++) { + const u = employeeUsers[i]; + await prisma.employees.upsert({ + where: { user_id: u.id }, + update: { + is_supervisor: u.role === Roles.SUPERVISOR, + job_title: u.role, + }, + create: { + user_id: u.id, + is_supervisor: u.role === Roles.SUPERVISOR, + external_payroll_id: 1000 + i, // à adapter + company_code: 1, // à adapter + first_work_day: firstWorkDay, + job_title: u.role, + }, + }); + } + + // 4) Répartition des 33 EMPLOYEE sur 4 superviseurs: 8/8/8/9 (9 pour User Test) + const supervisors = await prisma.employees.findMany({ + where: { is_supervisor: true, user: { role: Roles.SUPERVISOR } }, + include: { user: true }, + orderBy: { id: 'asc' }, + }); + + const userTestSupervisor = supervisors.find((s) => s.user.email === specialEmail); + if (!userTestSupervisor) { + throw new Error('Employee(User Test) introuvable — vérifie le upsert Users/Employees.'); + } + + const plainEmployees = await prisma.employees.findMany({ + where: { is_supervisor: false, user: { role: Roles.EMPLOYEE } }, + orderBy: { id: 'asc' }, + }); + + // Si la configuration est bien 4 superviseurs + 33 employés, on force 8/8/8/9 avec 9 pour User Test. + if (supervisors.length === 4 && plainEmployees.length === 33) { + const others = supervisors.filter((s) => s.id !== userTestSupervisor.id); + // ordre: autres (3) puis User Test en dernier (reçoit 9) + const ordered = [...others, userTestSupervisor]; + + const chunks = [ + plainEmployees.slice(0, 8), // -> sup 0 + plainEmployees.slice(8, 16), // -> sup 1 + plainEmployees.slice(16, 24), // -> sup 2 + plainEmployees.slice(24, 33), // -> sup 3 (User Test) = 9 + ]; + + for (let b = 0; b < chunks.length; b++) { + const sup = ordered[b]; + for (const emp of chunks[b]) { + await prisma.employees.update({ + where: { id: emp.id }, + data: { supervisor_id: sup.id }, + }); + } + } + } else { + // fallback: distribution round-robin si la config diffère + console.warn( + `Répartition fallback (round-robin). Supervisors=${supervisors.length}, Employees=${plainEmployees.length}` + ); + const others = supervisors.filter((s) => s.id !== userTestSupervisor.id); + const ordered = [...others, userTestSupervisor]; + for (let i = 0; i < plainEmployees.length; i++) { + const sup = ordered[i % ordered.length]; + await prisma.employees.update({ + where: { id: plainEmployees[i].id }, + data: { supervisor_id: sup.id }, }); - console.log(`✓ Validation: ${email} est SUPERVISOR`); - break; // on s'arrête dès qu'on a corrigé l'un des deux - } catch { - // ignore si non trouvé, on tente l'autre } } - // 3) Petite vérif : compter les superviseurs pour sanity check + // 5) Sanity checks + const totalUsers = await prisma.users.count(); const supCount = await prisma.users.count({ where: { role: Roles.SUPERVISOR } }); - console.log(`✓ Users: 50 rows (40 employees, 10 customers) — SUPERVISORS: ${supCount}`); + const empCount = await prisma.users.count({ where: { role: Roles.EMPLOYEE } }); + + const countForUserTest = await prisma.employees.count({ + where: { supervisor_id: userTestSupervisor.id, is_supervisor: false }, + }); + + console.log(`✓ Users total: ${totalUsers} (attendu 50)`); + console.log(`✓ Supervisors: ${supCount} (attendu 4)`); + console.log(`✓ Employees : ${empCount} (attendu 33)`); + console.log(`✓ Employés sous User Test: ${countForUserTest} (attendu 9)`); } -main().finally(() => prisma.$disconnect()); +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 00f6f0c..622b30d 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -42,6 +42,7 @@ function centsToAmountString(cents: number): string { const c = abs % 100; return `${sign}${dollars}.${c.toString().padStart(2, '0')}`; } + // Tire un multiple de STEP_CENTS entre minCents et maxCents (inclus) function rndQuantizedCents(minCents: number, maxCents: number, step = STEP_CENTS): number { const qmin = Math.ceil(minCents / step); @@ -65,7 +66,7 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) { async function main() { // Codes d'EXPENSES (exemples) - const BANKS = ['G517', 'G503', 'G502', 'G202', 'G234'] as const; + const BANKS = ['G517', 'G503', 'G502', 'G202'] as const; // Précharger les bank codes const bcRows = await prisma.bankCodes.findMany({ @@ -119,19 +120,16 @@ async function main() { // Montants (cents) quantisés à 25¢ => aucun flottant binaire plus tard let amount: string; switch (code) { - case 'G503': // petites fournitures + case 'G503': // kilométrage amount = rndAmount(1000, 7500); // 10.00 à 75.00 break; - case 'G502': // repas + case 'G502': // per_diem amount = rndAmount(1500, 3000); // 15.00 à 30.00 break; - case 'G202': // essence + case 'G202': // allowance /prime de garde amount = rndAmount(2000, 15000); // 20.00 à 150.00 break; - case 'G234': // hébergement - amount = rndAmount(6000, 25000); // 60.00 à 250.00 - break; - case 'G517': // péages / divers + case 'G517': // expenses default: amount = rndAmount(500, 5000); // 5.00 à 50.00 break; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4a45c7b..b223fa3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,46 +23,51 @@ model Users { residence String? role Roles @default(GUEST) - employee Employees? @relation("UserEmployee") - customer Customers? @relation("UserCustomer") - oauth_sessions OAuthSessions[] @relation("UserOAuthSessions") - employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive") - customer_archive CustomersArchive[] @relation("UserToCustomersToArchive") - preferences Preferences? @relation("UserPreferences") + employee Employees? @relation("UserEmployee") + customer Customers? @relation("UserCustomer") + oauth_sessions OAuthSessions[] @relation("UserOAuthSessions") + employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive") + customer_archive CustomersArchive[] @relation("UserToCustomersToArchive") + preferences Preferences? @relation("UserPreferences") + @@map("users") } model Employees { - id Int @id @default(autoincrement()) - user Users @relation("UserEmployee", fields: [user_id], references: [id]) - user_id String @unique @db.Uuid + id Int @id @default(autoincrement()) + user Users @relation("UserEmployee", fields: [user_id], references: [id]) + user_id String @unique @db.Uuid + supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id]) + supervisor_id Int? + external_payroll_id Int company_code Int first_work_day DateTime @db.Date last_work_day DateTime? @db.Date - job_title String? - is_supervisor Boolean @default(false) + job_title String? + is_supervisor Boolean @default(false) - supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id]) - supervisor_id Int? - crew Employees[] @relation("EmployeeSupervisor") + crew Employees[] @relation("EmployeeSupervisor") archive EmployeesArchive[] @relation("EmployeeToArchive") timesheet Timesheets[] @relation("TimesheetEmployee") leave_request LeaveRequests[] @relation("LeaveRequestEmployee") supervisor_archive EmployeesArchive[] @relation("EmployeeSupervisorToArchive") + schedule_presets SchedulePresets[] @relation("SchedulePreset") @@map("employees") } model EmployeesArchive { - id Int @id @default(autoincrement()) - employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id]) - employee_id Int - archived_at DateTime @default(now()) + id Int @id @default(autoincrement()) + employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id]) + employee_id Int + user_id String @db.Uuid + user Users @relation("UsersToEmployeesToArchive", fields: [user_id], references: [id]) + supervisor Employees? @relation("EmployeeSupervisorToArchive", fields: [supervisor_id], references: [id]) + supervisor_id Int? - user_id String @db.Uuid - user Users @relation("UsersToEmployeesToArchive", fields: [user_id], references: [id]) + archived_at DateTime @default(now()) first_name String last_name String job_title String? @@ -71,8 +76,6 @@ model EmployeesArchive { company_code Int first_work_day DateTime @db.Date last_work_day DateTime @db.Date - supervisor_id Int? - supervisor Employees? @relation("EmployeeSupervisorToArchive", fields: [supervisor_id], references: [id]) @@map("employees_archive") } @@ -92,27 +95,28 @@ model CustomersArchive { id Int @id @default(autoincrement()) customer Customers @relation("CustomerToArchive", fields: [customer_id], references: [id]) customer_id Int - archived_at DateTime @default(now()) - user_id String @db.Uuid user Users @relation("UserToCustomersToArchive", fields: [user_id], references: [id]) + user_id String @db.Uuid - invoice_id Int? @unique + archived_at DateTime @default(now()) + invoice_id Int? @unique @@map("customers_archive") } model LeaveRequests { - id Int @id @default(autoincrement()) - employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id]) - employee_id Int - bank_code BankCodes @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id]) - bank_code_id Int - leave_type LeaveTypes - date DateTime @db.Date - payable_hours Decimal? @db.Decimal(5,2) - requested_hours Decimal? @db.Decimal(5,2) + id Int @id @default(autoincrement()) + employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id]) + employee_id Int + bank_code BankCodes @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id]) + bank_code_id Int + comment String + date DateTime @db.Date + payable_hours Decimal? @db.Decimal(5, 2) + requested_hours Decimal? @db.Decimal(5, 2) approval_status LeaveApprovalStatus @default(PENDING) + leave_type LeaveTypes archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive") @@ -122,16 +126,17 @@ model LeaveRequests { } model LeaveRequestsArchive { - id Int @id @default(autoincrement()) - leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id]) + id Int @id @default(autoincrement()) + leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id]) leave_request_id Int - archived_at DateTime @default(now()) + + archived_at DateTime @default(now()) employee_id Int - leave_type LeaveTypes - date DateTime @db.Date - payable_hours Decimal? @db.Decimal(5,2) - requested_hours Decimal? @db.Decimal(5,2) + date DateTime @db.Date + payable_hours Decimal? @db.Decimal(5, 2) + requested_hours Decimal? @db.Decimal(5, 2) comment String + leave_type LeaveTypes approval_status LeaveApprovalStatus @@unique([leave_request_id]) @@ -141,13 +146,13 @@ model LeaveRequestsArchive { //pay-period vue view PayPeriods { - pay_year Int - pay_period_no Int - payday DateTime @db.Date - period_start DateTime @db.Date - period_end DateTime @db.Date - label String - + pay_year Int + pay_period_no Int + label String + payday DateTime @db.Date + period_start DateTime @db.Date + period_end DateTime @db.Date + @@map("pay_period") } @@ -155,8 +160,9 @@ model Timesheets { id Int @id @default(autoincrement()) employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id]) employee_id Int - start_date DateTime @db.Date - is_approved Boolean @default(false) + + start_date DateTime @db.Date + is_approved Boolean @default(false) shift Shifts[] @relation("ShiftTimesheet") expense Expenses[] @relation("ExpensesTimesheet") @@ -170,25 +176,71 @@ model TimesheetsArchive { id Int @id @default(autoincrement()) timesheet Timesheets @relation("TimesheetsToArchive", fields: [timesheet_id], references: [id]) timesheet_id Int - archive_at DateTime @default(now()) + employee_id Int is_approved Boolean + archive_at DateTime @default(now()) @@map("timesheets_archive") } + + + + + +model SchedulePresets { + id Int @id @default(autoincrement()) + employee Employees @relation("SchedulePreset", fields: [employee_id], references: [id]) + employee_id Int + + name String + is_default Boolean @default(false) + + shifts SchedulePresetShifts[] @relation("SchedulePresetShiftsSchedulePreset") + + @@unique([employee_id, name], name: "unique_preset_name_per_employee") + @@map("schedule_presets") +} + +model SchedulePresetShifts { + id Int @id @default(autoincrement()) + preset SchedulePresets @relation("SchedulePresetShiftsSchedulePreset",fields: [preset_id], references: [id]) + preset_id Int + bank_code BankCodes @relation("SchedulePresetShiftsBankCodes",fields: [bank_code_id], references: [id]) + bank_code_id Int + + sort_order Int + start_time DateTime @db.Time(0) + end_time DateTime @db.Time(0) + is_remote Boolean @default(false) + week_day Weekday + + @@unique([preset_id, week_day, sort_order], name: "unique_preset_shift_per_day_order") + @@index([preset_id, week_day]) + @@map("schedule_preset_shifts") +} + + + + + + + + model Shifts { id Int @id @default(autoincrement()) timesheet Timesheets @relation("ShiftTimesheet", fields: [timesheet_id], references: [id]) timesheet_id Int bank_code BankCodes @relation("ShiftBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int - comment String? - date DateTime @db.Date - start_time DateTime @db.Time(0) - end_time DateTime @db.Time(0) - is_approved Boolean @default(false) - is_remote Boolean @default(false) + + date DateTime @db.Date + start_time DateTime @db.Time(0) + end_time DateTime @db.Time(0) + is_approved Boolean @default(false) + is_remote Boolean @default(false) + comment String? archive ShiftsArchive[] @relation("ShiftsToArchive") @@ -196,16 +248,17 @@ model Shifts { } model ShiftsArchive { - id Int @id @default(autoincrement()) - shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id]) - shift_id Int - archive_at DateTime @default(now()) - timesheet_id Int - bank_code_id Int - comment String? + id Int @id @default(autoincrement()) + shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id]) + shift_id Int + date DateTime @db.Date start_time DateTime @db.Time(0) end_time DateTime @db.Time(0) + timesheet_id Int + bank_code_id Int + comment String? + archive_at DateTime @default(now()) @@map("shifts_archive") } @@ -217,27 +270,29 @@ model BankCodes { modifier Float bank_code String - shifts Shifts[] @relation("ShiftBankCodes") - expenses Expenses[] @relation("ExpenseBankCodes") - leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes") + shifts Shifts[] @relation("ShiftBankCodes") + expenses Expenses[] @relation("ExpenseBankCodes") + leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes") + SchedulePresetShifts SchedulePresetShifts[] @relation("SchedulePresetShiftsBankCodes") @@map("bank_codes") } model Expenses { - id Int @id @default(autoincrement()) - timesheet Timesheets @relation("ExpensesTimesheet", fields: [timesheet_id], references: [id]) + id Int @id @default(autoincrement()) + timesheet Timesheets @relation("ExpensesTimesheet", fields: [timesheet_id], references: [id]) timesheet_id Int - bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id]) + bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int - date DateTime @db.Date - amount Decimal @db.Money - mileage Decimal? - attachment Int? attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull) + attachment Int? + + date DateTime @db.Date + amount Decimal @db.Money + mileage Decimal? comment String - is_approved Boolean @default(false) supervisor_comment String? + is_approved Boolean @default(false) archive ExpensesArchive[] @relation("ExpensesToArchive") @@ -245,17 +300,18 @@ model Expenses { } model ExpensesArchive { - id Int @id @default(autoincrement()) - expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id]) + id Int @id @default(autoincrement()) + expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id]) expense_id Int + attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull) + attachment Int? + timesheet_id Int archived_at DateTime @default(now()) bank_code_id Int date DateTime @db.Date amount Decimal? @db.Money mileage Decimal? - attachment Int? - attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull) comment String? is_approved Boolean supervisor_comment String? @@ -282,7 +338,7 @@ model OAuthSessions { } model Blobs { - sha256 String @id @db.Char(64) + sha256 String @id @db.Char(64) size Int mime String storage_path String @@ -295,19 +351,20 @@ model Blobs { } model Attachments { - id Int @id @default(autoincrement()) - sha256 String @db.Char(64) - blob Blobs @relation(fields: [sha256], references: [sha256], onUpdate: Cascade) - owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc - owner_id String //expense_id, employee_id, etc + id Int @id @default(autoincrement()) + blob Blobs @relation(fields: [sha256], references: [sha256], onUpdate: Cascade) + sha256 String @db.Char(64) + + owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc + owner_id String //expense_id, employee_id, etc original_name String - status AttachmentStatus @default(ACTIVE) + status AttachmentStatus @default(ACTIVE) retention_policy RetentionPolicy created_by String - created_at DateTime @default(now()) + created_at DateTime @default(now()) - expenses Expenses[] @relation("ExpenseAttachment") - expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment") + expenses Expenses[] @relation("ExpenseAttachment") + expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment") @@index([owner_type, owner_id, created_at]) @@index([sha256]) @@ -315,9 +372,10 @@ model Attachments { } model Preferences { - id Int @id @default(autoincrement()) - user Users @relation("UserPreferences", fields: [user_id], references: [id]) - user_id String @unique @db.Uuid + id Int @id @default(autoincrement()) + user Users @relation("UserPreferences", fields: [user_id], references: [id]) + user_id String @unique @db.Uuid + notifications Boolean @default(false) dark_mode Boolean @default(false) lang_switch Boolean @default(false) @@ -326,8 +384,7 @@ model Preferences { @@map("preferences") } - -enum AttachmentStatus { +enum AttachmentStatus { ACTIVE DELETED } @@ -360,7 +417,7 @@ enum LeaveTypes { LEGAL // obligations legales comme devoir de juree WEDDING // mariage HOLIDAY // férier - + @@map("leave_types") } @@ -373,3 +430,13 @@ enum LeaveApprovalStatus { @@map("leave_approval_status") } + +enum Weekday { + SUN + MON + TUE + WED + THU + FRI + SAT +} diff --git a/src/app.module.ts b/src/app.module.ts index 407535a..74eb167 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -25,6 +25,7 @@ import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { ValidationError } from 'class-validator'; +import { SchedulePresetsModule } from './modules/schedule-presets/schedule-presets.module'; @Module({ imports: [ @@ -43,7 +44,8 @@ import { ValidationError } from 'class-validator'; OauthSessionsModule, PayperiodsModule, PrismaModule, - ScheduleModule.forRoot(), + ScheduleModule.forRoot(), //cronjobs + SchedulePresetsModule, ShiftsModule, TimesheetsModule, UsersModule, diff --git a/src/modules/schedule-presets/controller/schedule-presets.controller.ts b/src/modules/schedule-presets/controller/schedule-presets.controller.ts new file mode 100644 index 0000000..a71f757 --- /dev/null +++ b/src/modules/schedule-presets/controller/schedule-presets.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, NotFoundException, Param, Put, Query } from "@nestjs/common"; +import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto"; +import { SchedulePresetsCommandService } from "../services/schedule-presets-command.service"; +import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; +import { SchedulePresetsQueryService } from "../services/schedule-presets-query.service"; + +@Controller('schedule-presets') +export class SchedulePresetsController { + constructor( + private readonly commandService: SchedulePresetsCommandService, + private readonly queryService: SchedulePresetsQueryService, + ){} + + @Put(':email') + async upsert( + @Param('email') email: string, + @Query('action') action: UpsertAction, + @Body() dto: SchedulePresetsDto, + ) { + const actions: UpsertAction[] = ['create','update','delete']; + if(!actions) throw new NotFoundException(`No action found for ${actions}`) + return this.commandService.upsertSchedulePreset(email, action, dto); + } + + @Get(':email') + async findListByEmail( + @Param('email') email: string, + ) { + return this.queryService.findSchedulePresetsByEmail(email); + } +} \ No newline at end of file diff --git a/src/modules/schedule-presets/dtos/create-schedule-preset-shifts.dto.ts b/src/modules/schedule-presets/dtos/create-schedule-preset-shifts.dto.ts new file mode 100644 index 0000000..33c06cd --- /dev/null +++ b/src/modules/schedule-presets/dtos/create-schedule-preset-shifts.dto.ts @@ -0,0 +1,26 @@ +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, Matches, Min } from "class-validator"; +import { Weekday } from "@prisma/client"; + +export class SchedulePresetShiftsDto { + @IsEnum(Weekday) + week_day!: Weekday; + + @IsInt() + @Min(1) + sort_order!: number; + + @IsString() + type!: string; + + @IsString() + @Matches(HH_MM_REGEX) + start_time!: string; + + @IsString() + @Matches(HH_MM_REGEX) + end_time!: string; + + @IsOptional() + @IsBoolean() + is_remote?: boolean; +} \ No newline at end of file diff --git a/src/modules/schedule-presets/dtos/create-schedule-presets.dto.ts b/src/modules/schedule-presets/dtos/create-schedule-presets.dto.ts new file mode 100644 index 0000000..7bd822f --- /dev/null +++ b/src/modules/schedule-presets/dtos/create-schedule-presets.dto.ts @@ -0,0 +1,15 @@ +import { ArrayMinSize, IsArray, IsBoolean, IsEmail, IsOptional, IsString } from "class-validator"; +import { SchedulePresetShiftsDto } from "./create-schedule-preset-shifts.dto"; + +export class SchedulePresetsDto { + @IsString() + name!: string; + + @IsBoolean() + @IsOptional() + is_default: boolean; + + @IsArray() + @ArrayMinSize(1) + preset_shifts: SchedulePresetShiftsDto[]; +} \ No newline at end of file diff --git a/src/modules/schedule-presets/schedule-presets.module.ts b/src/modules/schedule-presets/schedule-presets.module.ts new file mode 100644 index 0000000..3bfb06d --- /dev/null +++ b/src/modules/schedule-presets/schedule-presets.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { SchedulePresetsCommandService } from "./services/schedule-presets-command.service"; +import { SchedulePresetsQueryService } from "./services/schedule-presets-query.service"; +import { SchedulePresetsController } from "./controller/schedule-presets.controller"; +import { EmployeeIdEmailResolver } from "../shared/utils/resolve-email-id.utils"; +import { BankCodesResolver } from "../shared/utils/resolve-bank-type-id.utils"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Module({ + imports: [], + controllers: [SchedulePresetsController], + providers: [ + PrismaService, + SchedulePresetsCommandService, + SchedulePresetsQueryService, + EmployeeIdEmailResolver, + BankCodesResolver, + ], + exports:[ + SchedulePresetsCommandService, + SchedulePresetsQueryService + ], +}) export class SchedulePresetsModule {} \ No newline at end of file diff --git a/src/modules/schedule-presets/services/schedule-presets-apply.service.ts b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts new file mode 100644 index 0000000..63aee30 --- /dev/null +++ b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts @@ -0,0 +1,45 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { PrismaService } from "src/prisma/prisma.service"; +import { ApplyResult } from "../types/schedule-presets.types"; + +// @Injectable() +// export class SchedulePresetsApplyService { +// constructor( +// private readonly prisma: PrismaService, +// private readonly emailResolver: EmployeeIdEmailResolver, +// ) {} + +// async applyToTimesheet( +// email: string, +// preset_name: string, +// start_date_iso: string, +// ): Promise { +// if(!preset_name?.trim()) throw new BadRequestException('A preset_name is required'); +// if(!DATE_ISO_FORMAT.test(start_date_iso)) throw new BadRequestException('start_date must be of format :YYYY-MM-DD'); + +// const employee_id = await this.emailResolver.findIdByEmail(email); +// if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); + +// const preset = await this.prisma.schedulePresets.findFirst({ +// where: { employee_id, name: preset_name }, +// include: { +// shifts: { +// orderBy: [{ week_day: 'asc'}, { sort_order: 'asc'}], +// select: { +// week_day: true, +// sort_order: true, +// start_time: true, +// end_time: true, +// is_remote: true, +// bank_code_id: true, +// }, +// }, +// }, +// }); +// if(!preset) throw new NotFoundException(`Preset ${preset} not found`); + +// const start_date = new Date(`${start_date_iso}T00:00:00.000Z`) + +// } +// } \ No newline at end of file diff --git a/src/modules/schedule-presets/services/schedule-presets-command.service.ts b/src/modules/schedule-presets/services/schedule-presets-command.service.ts new file mode 100644 index 0000000..b3c9e91 --- /dev/null +++ b/src/modules/schedule-presets/services/schedule-presets-command.service.ts @@ -0,0 +1,238 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; +import { PrismaService } from "src/prisma/prisma.service"; +import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto"; +import { Prisma, Weekday } from "@prisma/client"; + +@Injectable() +export class SchedulePresetsCommandService { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmployeeIdEmailResolver, + private readonly typeResolver : BankCodesResolver, + ){} + + //_________________________________________________________________ + // MASTER CRUD FUNCTION + //_________________________________________________________________ + async upsertSchedulePreset( + email: string, + action: UpsertAction, + dto: SchedulePresetsDto, + ): Promise<{ + action: UpsertAction; + preset_id?: number; + total_items?: number; + }>{ + if(!dto.name?.trim()) throw new BadRequestException(`A Name is required`); + + //resolve employee_id using email + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`employee with email: ${email} not found`); + + //DELETE + if(action === 'delete') { + return this.deletePreset(employee_id, dto.name); + } + + if(!Array.isArray(dto.preset_shifts) || dto.preset_shifts.length === 0) { + throw new BadRequestException(`Empty array, no detected shifts`); + } + const shifts_data = await this.resolveAndBuildPresetShifts(dto); + + //CREATE AND UPDATE + if(action === 'create') { + return this.createPreset(employee_id, dto, shifts_data); + } else if (action === 'update') { + return this.updatePreset(employee_id, dto, shifts_data); + } + throw new BadRequestException(`Unknown action: ${ action }`); + } + + //_________________________________________________________________ + // CREATE + //_________________________________________________________________ + private async createPreset( + employee_id: number, + dto: SchedulePresetsDto, + shifts_data: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[], + ): Promise<{ + action: UpsertAction; + preset_id: number; + total_items: number; + }> { + try { + const result = await this.prisma.$transaction(async (tx)=> { + if(dto.is_default) { + await tx.schedulePresets.updateMany({ + where: { employee_id, is_default: true }, + data: { is_default: false }, + }); + } + const created = await tx.schedulePresets.create({ + data: { + employee_id, + name: dto.name, + is_default: !!dto.is_default, + shifts: { create: shifts_data}, + }, + include: { shifts: true }, + }); + return created; + }); + return { action: 'create', preset_id: result.id, total_items: result.shifts.length }; + } catch (error: unknown) { + if(error instanceof Prisma.PrismaClientKnownRequestError){ + if(error?.code === 'P2002') { + throw new ConflictException(`The name ${dto.name} is already used for another schedule preset`); + } + if (error.code === 'P2003' || error.code === 'P2011') { + throw new ConflictException('Invalid constraint on preset shifts'); + } + } + throw error; + } + } + + //_________________________________________________________________ + // UPDATE + //_________________________________________________________________ + private async updatePreset( + employee_id: number, + dto: SchedulePresetsDto, + shifts_data: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[], + ): Promise<{ + action: UpsertAction; + preset_id?: number; + total_items?: number; + }> { + const existing = await this.prisma.schedulePresets.findFirst({ + where: { employee_id, name: dto.name }, + select: { id:true, is_default: true }, + }); + if(!existing) throw new NotFoundException(`Preset "${dto.name}" not found`); + + try { + const result = await this.prisma.$transaction(async (tx) => { + if(typeof dto.is_default === 'boolean'){ + if(dto.is_default) { + await tx.schedulePresets.updateMany({ + where: { employee_id, is_default: true, NOT: { id: existing.id } }, + data: { is_default: false }, + }); + } + await tx.schedulePresets.update({ + where: { id: existing.id }, + data: { is_default: dto.is_default }, + }); + } + + await tx.schedulePresetShifts.deleteMany({ where: { preset_id: existing.id } }); + + const create_many_data: Prisma.SchedulePresetShiftsCreateManyInput[] = + shifts_data.map((shift)=> { + if(!shift.bank_code || !('connect' in shift.bank_code) || typeof shift.bank_code.connect?.id !=='number'){ + throw new NotFoundException(`Bank code is required for updates( ${shift.week_day}, ${shift.sort_order})`); + } + const bank_code_id = shift.bank_code.connect.id; + return { + preset_id: existing.id, + week_day: shift.week_day, + sort_order: shift.sort_order, + start_time: shift.start_time, + end_time: shift.end_time, + is_remote: shift.is_remote ?? false, + bank_code_id: bank_code_id, + }; + }); + await tx.schedulePresetShifts.createMany({data: create_many_data}); + + const count = await tx.schedulePresetShifts.count({ where: { preset_id: existing.id } }); + return { id: existing.id, total: count }; + }); + return { action: 'update', preset_id: result.id, total_items: result.total }; + } catch (error: unknown){ + if(error instanceof Prisma.PrismaClientKnownRequestError){ + if(error?.code === 'P2003' || error?.code === 'P2011') { + throw new ConflictException(`Invalid constraint on preset shifts`); + } + } + throw error; + } + } + + //_________________________________________________________________ + // DELETE + //_________________________________________________________________ + private async deletePreset( + employee_id: number, + name: string, + ): Promise<{ + action: UpsertAction; + preset_id?: number; + total_items?: number; + }> { + const existing = await this.prisma.schedulePresets.findFirst({ + where: { employee_id, name }, + select: { id: true }, + }); + if(!existing) throw new NotFoundException(`Preset "${name}" not found`); + await this.prisma.$transaction(async (tx) => { + await tx.schedulePresetShifts.deleteMany({ where: { preset_id: existing.id } }); + await tx.schedulePresets.delete({where: { id: existing.id } }); + }); + return { action: 'delete', preset_id: existing.id, total_items: 0 }; + } + + //PRIVATE HELPER + //resolve bank_code_id using type and convert hours to TIME and valid shifts end/start + private async resolveAndBuildPresetShifts( + dto: SchedulePresetsDto + ): Promise{ + + if(!dto.preset_shifts?.length) throw new NotFoundException(`Empty or preset shifts not found`); + + const types = Array.from(new Set(dto.preset_shifts.map((shift)=> shift.type))); + const bank_code_set = new Map(); + + for (const type of types) { + const { id } = await this.typeResolver.findByType(type); + bank_code_set.set(type, id) + } + const toTime = (hhmm: string) => new Date(`1970-01-01T${hhmm}:00.000Z`); + + const pair_set = new Set(); + for (const shift of dto.preset_shifts) { + const key = `${shift.week_day}:${shift.sort_order}`; + if (pair_set.has(key)) { + throw new ConflictException(`Duplicate shift for day/order (${shift.week_day}, ${shift.sort_order})`); + } + pair_set.add(key); + } + + const items: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[] = dto.preset_shifts.map((shift)=> { + const bank_code_id = bank_code_set.get(shift.type); + if(!bank_code_id) throw new NotFoundException(`Bank code not found for type ${shift.type}`); + if (!shift.start_time || !shift.end_time) { + throw new BadRequestException(`start_time and end_time are required for (${shift.week_day}, ${shift.sort_order})`); + } + const start = toTime(shift.start_time); + const end = toTime(shift.end_time); + if(end.getTime() <= start.getTime()) { + throw new ConflictException(`end_time must be > start_time ( day: ${shift.week_day}, order: ${shift.sort_order})`); + } + + return { + week_day: shift.week_day as Weekday, + sort_order: shift.sort_order, + bank_code: { connect: { id: bank_code_id} }, + start_time: start, + end_time: end, + is_remote: !!shift.is_remote, + }; + }); + return items; + } +} diff --git a/src/modules/schedule-presets/services/schedule-presets-query.service.ts b/src/modules/schedule-presets/services/schedule-presets-query.service.ts new file mode 100644 index 0000000..665fe47 --- /dev/null +++ b/src/modules/schedule-presets/services/schedule-presets-query.service.ts @@ -0,0 +1,51 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { PrismaService } from "src/prisma/prisma.service"; +import { PresetResponse, ShiftResponse } from "../types/schedule-presets.types"; +import { Prisma } from "@prisma/client"; + +@Injectable() +export class SchedulePresetsQueryService { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmployeeIdEmailResolver, + ){} + + async findSchedulePresetsByEmail(email:string): Promise { + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); + + try { + const presets = await this.prisma.schedulePresets.findMany({ + where: { employee_id }, + orderBy: [{is_default: 'desc' }, { name: 'asc' }], + include: { + shifts: { + orderBy: [{week_day:'asc'}, { sort_order: 'asc'}], + include: { bank_code: { select: { type: true } } }, + }, + }, + }); + const hhmm = (date: Date) => date.toISOString().slice(11,16); + + const response: PresetResponse[] = presets.map((preset) => ({ + id: preset.id, + name: preset.name, + is_default: preset.is_default, + shifts: preset.shifts.map((shift)=> ({ + week_day: shift.week_day, + sort_order: shift.sort_order, + start_time: hhmm(shift.start_time), + end_time: hhmm(shift.end_time), + is_remote: shift.is_remote, + type: shift.bank_code?.type, + })), + })); + return response; + } catch ( error: unknown) { + if(error instanceof Prisma.PrismaClientKnownRequestError) {} + throw error; + } + } + +} \ No newline at end of file diff --git a/src/modules/schedule-presets/types/schedule-presets.types.ts b/src/modules/schedule-presets/types/schedule-presets.types.ts new file mode 100644 index 0000000..ea2a3cd --- /dev/null +++ b/src/modules/schedule-presets/types/schedule-presets.types.ts @@ -0,0 +1,21 @@ +export type ShiftResponse = { + week_day: string; + sort_order: number; + start_time: string; + end_time: string; + is_remote: boolean; + type: string; +}; + +export type PresetResponse = { + id: number; + name: string; + is_default: boolean; + shifts: ShiftResponse[]; +} + +export type ApplyResult = { + timesheet_id: number; + created: number; + skipped: number; +} \ No newline at end of file diff --git a/src/modules/shared/constants/regex.constant.ts b/src/modules/shared/constants/regex.constant.ts new file mode 100644 index 0000000..30f77c1 --- /dev/null +++ b/src/modules/shared/constants/regex.constant.ts @@ -0,0 +1,2 @@ +const HH_MM_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; +const DATE_ISO_FORMAT = /^\d{4}-\d{2}-\d{2}$/; \ No newline at end of file diff --git a/src/modules/shared/types/upsert-actions.types.ts b/src/modules/shared/types/upsert-actions.types.ts new file mode 100644 index 0000000..9342d75 --- /dev/null +++ b/src/modules/shared/types/upsert-actions.types.ts @@ -0,0 +1 @@ +export type UpsertAction = 'create' | 'update' | 'delete'; \ No newline at end of file From 7c7edea768cb5536b59591bfb6a123d8d0cebb36 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 9 Oct 2025 08:40:20 -0400 Subject: [PATCH 60/69] feat(schedule-presets): created an apply service to auto-create shifts using presets --- .../controller/schedule-presets.controller.ts | 15 +- .../mappers/schedule-presets.mappers.ts | 3 + .../schedule-presets.module.ts | 5 +- .../schedule-presets-apply.service.ts | 155 ++++++++++++++---- 4 files changed, 140 insertions(+), 38 deletions(-) create mode 100644 src/modules/schedule-presets/mappers/schedule-presets.mappers.ts diff --git a/src/modules/schedule-presets/controller/schedule-presets.controller.ts b/src/modules/schedule-presets/controller/schedule-presets.controller.ts index a71f757..b031c67 100644 --- a/src/modules/schedule-presets/controller/schedule-presets.controller.ts +++ b/src/modules/schedule-presets/controller/schedule-presets.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, NotFoundException, Param, Put, Query } from "@nestjs/common"; +import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Post, Put, Query } from "@nestjs/common"; import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto"; import { SchedulePresetsCommandService } from "../services/schedule-presets-command.service"; import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; @@ -11,6 +11,7 @@ export class SchedulePresetsController { private readonly queryService: SchedulePresetsQueryService, ){} + //used to create, update or delete a schedule preset @Put(':email') async upsert( @Param('email') email: string, @@ -22,10 +23,22 @@ export class SchedulePresetsController { return this.commandService.upsertSchedulePreset(email, action, dto); } + //used to show the list of available schedule presets @Get(':email') async findListByEmail( @Param('email') email: string, ) { return this.queryService.findSchedulePresetsByEmail(email); } + //used to apply a preset to a timesheet + @Post('/apply-presets/:email') + async applyPresets( + @Param('email') email: string, + @Query('preset') preset_name: string, + @Query('start') start_date: string, + ) { + if(!preset_name?.trim()) throw new BadRequestException('Query "preset" is required'); + if(!start_date?.trim()) throw new BadRequestException('Query "start" is required YYYY-MM-DD'); + return this.applyPresets(email, preset_name, start_date); + } } \ No newline at end of file diff --git a/src/modules/schedule-presets/mappers/schedule-presets.mappers.ts b/src/modules/schedule-presets/mappers/schedule-presets.mappers.ts new file mode 100644 index 0000000..10a9faf --- /dev/null +++ b/src/modules/schedule-presets/mappers/schedule-presets.mappers.ts @@ -0,0 +1,3 @@ +import { Weekday } from "@prisma/client"; + +export const WEEKDAY: Weekday[] = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; \ No newline at end of file diff --git a/src/modules/schedule-presets/schedule-presets.module.ts b/src/modules/schedule-presets/schedule-presets.module.ts index 3bfb06d..1973e1a 100644 --- a/src/modules/schedule-presets/schedule-presets.module.ts +++ b/src/modules/schedule-presets/schedule-presets.module.ts @@ -5,6 +5,7 @@ import { SchedulePresetsController } from "./controller/schedule-presets.control import { EmployeeIdEmailResolver } from "../shared/utils/resolve-email-id.utils"; import { BankCodesResolver } from "../shared/utils/resolve-bank-type-id.utils"; import { PrismaService } from "src/prisma/prisma.service"; +import { SchedulePresetsApplyService } from "./services/schedule-presets-apply.service"; @Module({ imports: [], @@ -13,11 +14,13 @@ import { PrismaService } from "src/prisma/prisma.service"; PrismaService, SchedulePresetsCommandService, SchedulePresetsQueryService, + SchedulePresetsApplyService, EmployeeIdEmailResolver, BankCodesResolver, ], exports:[ SchedulePresetsCommandService, - SchedulePresetsQueryService + SchedulePresetsQueryService, + SchedulePresetsApplyService, ], }) export class SchedulePresetsModule {} \ No newline at end of file diff --git a/src/modules/schedule-presets/services/schedule-presets-apply.service.ts b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts index 63aee30..99ef799 100644 --- a/src/modules/schedule-presets/services/schedule-presets-apply.service.ts +++ b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts @@ -1,45 +1,128 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { PrismaService } from "src/prisma/prisma.service"; import { ApplyResult } from "../types/schedule-presets.types"; +import { Prisma, Weekday } from "@prisma/client"; +import { WEEKDAY } from "../mappers/schedule-presets.mappers"; -// @Injectable() -// export class SchedulePresetsApplyService { -// constructor( -// private readonly prisma: PrismaService, -// private readonly emailResolver: EmployeeIdEmailResolver, -// ) {} +@Injectable() +export class SchedulePresetsApplyService { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmployeeIdEmailResolver, + ) {} -// async applyToTimesheet( -// email: string, -// preset_name: string, -// start_date_iso: string, -// ): Promise { -// if(!preset_name?.trim()) throw new BadRequestException('A preset_name is required'); -// if(!DATE_ISO_FORMAT.test(start_date_iso)) throw new BadRequestException('start_date must be of format :YYYY-MM-DD'); + async applyToTimesheet( + email: string, + preset_name: string, + start_date_iso: string, + ): Promise { + if(!preset_name?.trim()) throw new BadRequestException('A preset_name is required'); + if(!DATE_ISO_FORMAT.test(start_date_iso)) throw new BadRequestException('start_date must be of format :YYYY-MM-DD'); -// const employee_id = await this.emailResolver.findIdByEmail(email); -// if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); -// const preset = await this.prisma.schedulePresets.findFirst({ -// where: { employee_id, name: preset_name }, -// include: { -// shifts: { -// orderBy: [{ week_day: 'asc'}, { sort_order: 'asc'}], -// select: { -// week_day: true, -// sort_order: true, -// start_time: true, -// end_time: true, -// is_remote: true, -// bank_code_id: true, -// }, -// }, -// }, -// }); -// if(!preset) throw new NotFoundException(`Preset ${preset} not found`); + const preset = await this.prisma.schedulePresets.findFirst({ + where: { employee_id, name: preset_name }, + include: { + shifts: { + orderBy: [{ week_day: 'asc'}, { sort_order: 'asc'}], + select: { + week_day: true, + sort_order: true, + start_time: true, + end_time: true, + is_remote: true, + bank_code_id: true, + }, + }, + }, + }); + if(!preset) throw new NotFoundException(`Preset ${preset} not found`); -// const start_date = new Date(`${start_date_iso}T00:00:00.000Z`) + const start_date = new Date(`${start_date_iso}T00:00:00.000Z`); + const timesheet = await this.prisma.timesheets.upsert({ + where: { employee_id_start_date: { employee_id, start_date: start_date} }, + update: {}, + create: { employee_id, start_date: start_date }, + select: { id: true }, + }); -// } -// } \ No newline at end of file + //index shifts by weekday + const index_by_day = new Map(); + for (const shift of preset.shifts) { + const list = index_by_day.get(shift.week_day) ?? []; + list.push(shift); + index_by_day.set(shift.week_day, list); + } + + const addDays = (date: Date, days: number) => + new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days)); + + const overlaps = (aStart: Date, aEnd: Date, bStart: Date, bEnd: Date) => + aStart.getTime() < bEnd.getTime() && aEnd.getTime() > bStart.getTime(); + + let created = 0; + let skipped = 0; + + await this.prisma.$transaction(async (tx) => { + for(let i = 0; i < 7; i++) { + const date = addDays(start_date, i); + const week_day = WEEKDAY[date.getUTCDay()]; + const shifts = index_by_day.get(week_day) ?? []; + + if(shifts.length === 0) continue; + + const existing = await tx.shifts.findMany({ + where: { timesheet_id: timesheet.id, date: date }, + orderBy: { start_time: 'asc' }, + select: { + start_time: true, + end_time: true, + bank_code_id: true, + is_remote: true, + comment: true, + }, + }); + + const payload: Prisma.ShiftsCreateManyInput[] = []; + + for(const shift of shifts) { + if(shift.end_time.getTime() <= shift.start_time.getTime()) { + throw new ConflictException(`Invalid time range in preset day: ${week_day}, order: ${shift.sort_order}`); + } + const conflict = existing.find((existe)=> overlaps( + shift.start_time, shift.end_time , + existe.start_time, existe.end_time, + )); + if(conflict) { + throw new ConflictException({ + error_code: 'SHIFT_OVERLAP_WITH_EXISTING', + mesage: `Preset shift overlaps existing shift on ${start_date} + ${i}(week day ${week_day})`, + conflict: { + existing_start: conflict.start_time.toISOString().slice(11,16), + existing_end: conflict.end_time.toISOString().slice(11,16), + }, + }); + } + payload.push({ + timesheet_id: timesheet.id, + date: date, + start_time: shift.start_time, + end_time: shift.end_time, + is_remote: shift.is_remote, + comment: null, + bank_code_id: shift.bank_code_id, + }); + } + if(payload.length) { + const response = await tx.shifts.createMany({ data: payload, skipDuplicates: true }); + created += response.count; + skipped += payload.length - response.count; + } + } + }); + return { timesheet_id: timesheet.id, created, skipped }; + } +} \ No newline at end of file From fba3c02f48b2ec5864393d162ed6efae72386b17 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 9 Oct 2025 10:39:17 -0400 Subject: [PATCH 61/69] fix(seeders): minor data fix and added Decimal to mileage --- docs/swagger/swagger-spec.json | 39 +++++++++++++++++++ .../migration.sql | 12 ++++++ prisma/mock-seeds-scripts/01-bankCodes.ts | 26 ++++++------- prisma/mock-seeds-scripts/12-expenses.ts | 23 +++++++---- prisma/schema.prisma | 6 +-- 5 files changed, 82 insertions(+), 24 deletions(-) create mode 100644 prisma/migrations/20251009141338_change_type_mileage/migration.sql diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 01572f2..b77ed2a 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -1134,6 +1134,45 @@ "SchedulePresets" ] } + }, + "/schedule-presets/apply-presets/{email}": { + "post": { + "operationId": "SchedulePresetsController_applyPresets", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "preset", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "start", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "SchedulePresets" + ] + } } }, "info": { diff --git a/prisma/migrations/20251009141338_change_type_mileage/migration.sql b/prisma/migrations/20251009141338_change_type_mileage/migration.sql new file mode 100644 index 0000000..f43e8de --- /dev/null +++ b/prisma/migrations/20251009141338_change_type_mileage/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to alter the column `mileage` on the `expenses` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `Decimal(12,2)`. + - You are about to alter the column `mileage` on the `expenses_archive` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `Decimal(12,2)`. + +*/ +-- AlterTable +ALTER TABLE "expenses" ALTER COLUMN "mileage" SET DATA TYPE DECIMAL(12,2); + +-- AlterTable +ALTER TABLE "expenses_archive" ALTER COLUMN "mileage" SET DATA TYPE DECIMAL(12,2); diff --git a/prisma/mock-seeds-scripts/01-bankCodes.ts b/prisma/mock-seeds-scripts/01-bankCodes.ts index 320db5d..2b1ade9 100644 --- a/prisma/mock-seeds-scripts/01-bankCodes.ts +++ b/prisma/mock-seeds-scripts/01-bankCodes.ts @@ -4,19 +4,19 @@ const prisma = new PrismaClient(); async function main() { const presets = [ - // type, categorie, modifier, bank_code - ['REGULAR' ,'SHIFT' , 1.0 , 'G1' ], - ['OVERTIME' ,'SHIFT' , 2 , 'G43' ], - ['EMERGENCY' ,'SHIFT' , 2 , 'G48' ], - ['EVENING' ,'SHIFT' , 1.25, 'G56' ], - ['SICK' ,'SHIFT' , 1.0 , 'G105'], - ['PRIME_DISPO','EXPENSE', 1.0 , 'G202'], - ['COMMISSION' ,'EXPENSE', 1.0 , 'G234'], - ['VACATION' ,'SHIFT' , 1.0 , 'G305'], - ['PER_DIEM' ,'EXPENSE', 1.0 , 'G502'], - ['MILEAGE' ,'EXPENSE', 0.72, 'G503'], - ['EXPENSES' ,'EXPENSE', 1.0 , 'G517'], - ['HOLIDAY' ,'SHIFT' , 2.0 , 'G700'], + // type, categorie, modifier, bank_code + ['REGULAR' ,'SHIFT' , 1.0 , 'G1' ], + ['OVERTIME' ,'SHIFT' , 2 , 'G43' ], + ['EMERGENCY' ,'SHIFT' , 2 , 'G48' ], + ['EVENING' ,'SHIFT' , 1.25 , 'G56' ], + ['SICK' ,'SHIFT' , 1.0 , 'G105'], + ['HOLIDAY' ,'SHIFT' , 1.0 , 'G104'], + ['VACATION' ,'SHIFT' , 1.0 , 'G305'], + ['ON_CALL' ,'EXPENSE' , 1.0 , 'G202'], + ['COMMISSION' ,'EXPENSE' , 1.0 , 'G234'], + ['PER_DIEM' ,'EXPENSE' , 1.0 , 'G502'], + ['MILEAGE' ,'EXPENSE' , 0.72 , 'G503'], + ['EXPENSES' ,'EXPENSE' , 1.0 , 'G517'], ]; await prisma.bankCodes.createMany({ diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 622b30d..926a52f 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -43,6 +43,11 @@ function centsToAmountString(cents: number): string { return `${sign}${dollars}.${c.toString().padStart(2, '0')}`; } +function to2(value: string): string { + // normalise au cas où (sécurité) + return (Math.round(parseFloat(value) * 100) / 100).toFixed(2); +} + // Tire un multiple de STEP_CENTS entre minCents et maxCents (inclus) function rndQuantizedCents(minCents: number, maxCents: number, step = STEP_CENTS): number { const qmin = Math.ceil(minCents / step); @@ -118,20 +123,21 @@ async function main() { const bank_code_id = bcMap.get(code)!; // Montants (cents) quantisés à 25¢ => aucun flottant binaire plus tard - let amount: string; + let amount: string = '0.00'; + let mileage: string = '0.00'; switch (code) { case 'G503': // kilométrage - amount = rndAmount(1000, 7500); // 10.00 à 75.00 + mileage = to2(rndAmount(1000, 7500)); // 10.00 à 75.00 break; case 'G502': // per_diem - amount = rndAmount(1500, 3000); // 15.00 à 30.00 + amount = to2(rndAmount(1500, 3000)); // 15.00 à 30.00 break; - case 'G202': // allowance /prime de garde - amount = rndAmount(2000, 15000); // 20.00 à 150.00 + case 'G202': // on_call /prime de garde + amount = to2(rndAmount(2000, 15000)); // 20.00 à 150.00 break; case 'G517': // expenses default: - amount = rndAmount(500, 5000); // 5.00 à 50.00 + amount = to2(rndAmount(500, 5000)); // 5.00 à 50.00 break; } @@ -140,9 +146,10 @@ async function main() { timesheet_id: ts.id, bank_code_id, date, - amount, // string "xx.yy" (2 décimales exactes) + amount, + mileage, attachment: null, - comment: `Expense ${code} ${amount}$ (emp ${e.id})`, + comment: `Expense ${code} (emp ${e.id})`, is_approved: Math.random() < 0.65, supervisor_comment: Math.random() < 0.25 ? 'OK' : null, }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b223fa3..dd489c2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -289,7 +289,7 @@ model Expenses { date DateTime @db.Date amount Decimal @db.Money - mileage Decimal? + mileage Decimal? @db.Decimal(12,2) comment String supervisor_comment String? is_approved Boolean @default(false) @@ -311,7 +311,7 @@ model ExpensesArchive { bank_code_id Int date DateTime @db.Date amount Decimal? @db.Money - mileage Decimal? + mileage Decimal? @db.Decimal(12,2) comment String? is_approved Boolean supervisor_comment String? @@ -380,7 +380,7 @@ model Preferences { dark_mode Boolean @default(false) lang_switch Boolean @default(false) lefty_mode Boolean @default(false) - +// TODO: change BOOLEAN to use 0 or 1 in case there is more than 2 options for each preferences @@map("preferences") } From 5a1017f82b30529bf680e8707b6b7ec616368e7a Mon Sep 17 00:00:00 2001 From: Nicolas Drolet Date: Thu, 9 Oct 2025 15:17:02 -0400 Subject: [PATCH 62/69] fix(pay-period): change payload to send regular hours and other hours, rather than each individual shift type as a property --- docs/swagger/swagger-spec.json | 59 +- .../utils/leave-request.util.ts | 6 + .../dtos/overview-employee-period.dto.ts | 70 +- .../services/pay-periods-query.service.ts | 704 +++++++++--------- 4 files changed, 456 insertions(+), 383 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 01572f2..97745e0 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -1134,6 +1134,45 @@ "SchedulePresets" ] } + }, + "/schedule-presets/apply-presets/{email}": { + "post": { + "operationId": "SchedulePresetsController_applyPresets", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "preset", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "start", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "SchedulePresets" + ] + } } }, "info": { @@ -1551,20 +1590,10 @@ "example": 40, "description": "pay-period`s regular hours" }, - "evening_hours": { - "type": "number", + "other_hours": { + "type": "object", "example": 0, - "description": "pay-period`s evening hours" - }, - "emergency_hours": { - "type": "number", - "example": 0, - "description": "pay-period`s emergency hours" - }, - "overtime_hours": { - "type": "number", - "example": 2, - "description": "pay-period`s overtime hours" + "description": "pay-period`s other hours" }, "expenses": { "type": "number", @@ -1585,9 +1614,7 @@ "required": [ "employee_name", "regular_hours", - "evening_hours", - "emergency_hours", - "overtime_hours", + "other_hours", "expenses", "mileage", "is_approved" diff --git a/src/modules/leave-requests/utils/leave-request.util.ts b/src/modules/leave-requests/utils/leave-request.util.ts index 746a568..6688f1a 100644 --- a/src/modules/leave-requests/utils/leave-request.util.ts +++ b/src/modules/leave-requests/utils/leave-request.util.ts @@ -62,17 +62,21 @@ export class LeaveRequestsUtils { await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { old_shift: existing ? { + date: existing.date, start_time: existing.start_time.toISOString().slice(11, 16), end_time: existing.end_time.toISOString().slice(11, 16), type: existing.bank_code?.type ?? type, is_remote: existing.is_remote, + is_approved:existing.is_approved, comment: existing.comment ?? undefined, } : undefined, new_shift: { + date: existing?.date ?? '', start_time: toHHmm(start_minutes), end_time: toHHmm(end_minutes), is_remote: existing?.is_remote ?? false, + is_approved:existing?.is_approved ?? false, comment: comment ?? existing?.comment ?? "", type: type, }, @@ -97,10 +101,12 @@ export class LeaveRequestsUtils { await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { old_shift: { + date: existing.date, start_time: existing.start_time.toISOString().slice(11, 16), end_time: existing.end_time.toISOString().slice(11, 16), type: existing.bank_code?.type ?? type, is_remote: existing.is_remote, + is_approved:existing.is_approved, comment: existing.comment ?? undefined, }, }); diff --git a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts index 861c783..1ea6937 100644 --- a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts +++ b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts @@ -1,48 +1,54 @@ import { ApiProperty } from '@nestjs/swagger'; export class EmployeePeriodOverviewDto { - // @ApiProperty({ - // example: 42, - // description: "Employees.id (clé primaire num.)", - // }) - // @Allow() - // @IsOptional() - // employee_id: number; + // @ApiProperty({ + // example: 42, + // description: "Employees.id (clé primaire num.)", + // }) + // @Allow() + // @IsOptional() + // employee_id: number; - email:string; + email: string; - @ApiProperty({ - example: 'Alex Dupont', - description: 'Nom complet de lemployé', - }) - employee_name: string; + @ApiProperty({ + example: 'Alex Dupont', + description: 'Nom complet de lemployé', + }) + employee_name: string; - @ApiProperty({ example: 40, description: 'pay-period`s regular hours' }) - regular_hours: number; + @ApiProperty({ example: 40, description: 'pay-period`s regular hours' }) + regular_hours: number; - @ApiProperty({ example: 0, description: 'pay-period`s evening hours' }) - evening_hours: number; + @ApiProperty({ example: 0, description: 'pay-period`s other hours' }) + other_hours: { + evening_hours: number; - @ApiProperty({ example: 0, description: 'pay-period`s emergency hours' }) - emergency_hours: number; + emergency_hours: number; - @ApiProperty({ example: 2, description: 'pay-period`s overtime hours' }) - overtime_hours: number; + overtime_hours: number; - total_hours: number; + sick_hours: number; - @ApiProperty({ example: 420.69, description: 'pay-period`s total expenses ($)' }) - expenses: number; + holiday_hours: number; - @ApiProperty({ example: 40, description: 'pay-period total mileages (km)' }) - mileage: number; + vacation_hours: number; + }; - @ApiProperty({ - example: true, - description: 'Tous les timesheets de la période sont approuvés pour cet employé', - }) - is_approved: boolean; + total_hours: number; - is_remote: boolean; + @ApiProperty({ example: 420.69, description: 'pay-period`s total expenses ($)' }) + expenses: number; + + @ApiProperty({ example: 40, description: 'pay-period total mileages (km)' }) + mileage: number; + + @ApiProperty({ + example: true, + description: 'Tous les timesheets de la période sont approuvés pour cet employé', + }) + is_approved: boolean; + + is_remote: boolean; } diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index 8e0a952..0e6aac0 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -9,365 +9,399 @@ import { mapPayPeriodToDto } from "../mappers/pay-periods.mapper"; @Injectable() export class PayPeriodsQueryService { - constructor( private readonly prisma: PrismaService) {} + constructor(private readonly prisma: PrismaService) { } - async getOverview(pay_period_no: number): Promise { - const period = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no }, - orderBy: { pay_year: "desc" }, - }); - if (!period) throw new NotFoundException(`Period #${pay_period_no} not found`); + async getOverview(pay_period_no: number): Promise { + const period = await this.prisma.payPeriods.findFirst({ + where: { pay_period_no }, + orderBy: { pay_year: "desc" }, + }); + if (!period) throw new NotFoundException(`Period #${pay_period_no} not found`); - return this.buildOverview({ - period_start: period.period_start, - period_end : period.period_end, - payday : period.payday, - period_no : period.pay_period_no, - pay_year : period.pay_year, - label : period.label, - }); - } - - async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise { - const period = computePeriod(pay_year, period_no); - return this.buildOverview({ - period_start: period.period_start, - period_end : period.period_end, - period_no : period.period_no, - pay_year : period.pay_year, - payday : period.payday, - label :period.label, - } as any); - } - - //find crew member associated with supervisor - private async resolveCrew(supervisor_id: number, include_subtree: boolean): - Promise> { - const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = []; - - let frontier = await this.prisma.employees.findMany({ - where: { supervisor_id: supervisor_id }, - select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, - }); - result.push(...frontier.map(emp => ({ - id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email - }))); - - if (!include_subtree) return result; - - while (frontier.length) { - const parent_ids = frontier.map(emp => emp.id); - const next = await this.prisma.employees.findMany({ - where: { supervisor_id: { in: parent_ids } }, - select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, - }); - if (next.length === 0) break; - result.push(...next.map(emp => ({ - id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email - }))); - frontier = next; + return this.buildOverview({ + period_start: period.period_start, + period_end: period.period_end, + payday: period.payday, + period_no: period.pay_period_no, + pay_year: period.pay_year, + label: period.label, + }); } - return result; - } - //fetchs crew emails - async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise> { - const crew = await this.resolveCrew(supervisor_id, include_subtree); - return new Set(crew.map(crew_member => crew_member.email).filter(Boolean)); - } - - async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean): - Promise { - // 1) Search for the period - const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } }); - if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`); + async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise { + const period = computePeriod(pay_year, period_no); + return this.buildOverview({ + period_start: period.period_start, + period_end: period.period_end, + period_no: period.period_no, + pay_year: period.pay_year, + payday: period.payday, + label: period.label, + } as any); + } - // 2) fetch supervisor - const supervisor = await this.prisma.employees.findFirst({ - where: { user: { email: email }}, - select: { - id: true, - is_supervisor: true, - }, - }); + //find crew member associated with supervisor + private async resolveCrew(supervisor_id: number, include_subtree: boolean): + Promise> { + const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = []; - if (!supervisor) throw new NotFoundException('No employee record linked to current user'); - if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor'); + let frontier = await this.prisma.employees.findMany({ + where: { supervisor_id: supervisor_id }, + select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, + }); + result.push(...frontier.map(emp => ({ + id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email + }))); - // 3)fetchs crew members - const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }] - const crew_ids = crew.map(c => c.id); - // seed names map for employee without data - const seed_names = new Map( - crew.map(crew => [ - crew.id, - { name:`${crew.first_name} ${crew.last_name}`.trim(), - email: crew.email } - ] - ) - ); + if (!include_subtree) return result; - // 4) overview build - return this.buildOverview({ - period_no : period.pay_period_no, - period_start: period.period_start, - period_end : period.period_end, - payday : period.payday, - pay_year : period.pay_year, - label : period.label, - //add is_approved - }, { filtered_employee_ids: crew_ids, seed_names }); - } + while (frontier.length) { + const parent_ids = frontier.map(emp => emp.id); + const next = await this.prisma.employees.findMany({ + where: { supervisor_id: { in: parent_ids } }, + select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, + }); + if (next.length === 0) break; + result.push(...next.map(emp => ({ + id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email + }))); + frontier = next; + } + return result; + } - private async buildOverview( - period: { period_start: string | Date; period_end: string | Date; payday: string | Date; - period_no: number; pay_year: number; label: string; }, //add is_approved - options?: { filtered_employee_ids?: number[]; seed_names?: Map} - ): Promise { - const toDateString = (d: Date) => d.toISOString().slice(0, 10); - const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number)); + //fetchs crew emails + async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise> { + const crew = await this.resolveCrew(supervisor_id, include_subtree); + return new Set(crew.map(crew_member => crew_member.email).filter(Boolean)); + } - const start = period.period_start instanceof Date - ? period.period_start - : new Date(`${period.period_start}T00:00:00.000Z`); + async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean): + Promise { + // 1) Search for the period + const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } }); + if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`); - const end = period.period_end instanceof Date - ? period.period_end - : new Date(`${period.period_end}T00:00:00.000Z`); + // 2) fetch supervisor + const supervisor = await this.prisma.employees.findFirst({ + where: { user: { email: email } }, + select: { + id: true, + is_supervisor: true, + }, + }); - const payd = period.payday instanceof Date - ? period.payday - : new Date (`${period.payday}T00:00:00.000Z`); + if (!supervisor) throw new NotFoundException('No employee record linked to current user'); + if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor'); - //restrictEmployeeIds = filter for shifts and expenses by employees - const where_employee = options?.filtered_employee_ids?.length ? { employee_id: { in: options.filtered_employee_ids } }: {}; + // 3)fetchs crew members + const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }] + const crew_ids = crew.map(c => c.id); + // seed names map for employee without data + const seed_names = new Map( + crew.map(crew => [ + crew.id, + { + name: `${crew.first_name} ${crew.last_name}`.trim(), + email: crew.email + } + ] + ) + ); - // SHIFTS (filtered by crew) - const shifts = await this.prisma.shifts.findMany({ - where: { - date: { gte: start, lte: end }, - timesheet: where_employee, - }, - select: { - start_time: true, - end_time: true, - is_remote: true, - timesheet: { select: { - is_approved: true, - employee: { select: { - id: true, - user: { select: { - first_name: true, - last_name: true, - email: true, - } }, - } }, + // 4) overview build + return this.buildOverview({ + period_no: period.pay_period_no, + period_start: period.period_start, + period_end: period.period_end, + payday: period.payday, + pay_year: period.pay_year, + label: period.label, + //add is_approved + }, { filtered_employee_ids: crew_ids, seed_names }); + } + + private async buildOverview( + period: { + period_start: string | Date; period_end: string | Date; payday: string | Date; + period_no: number; pay_year: number; label: string; + }, //add is_approved + options?: { filtered_employee_ids?: number[]; seed_names?: Map } + ): Promise { + const toDateString = (d: Date) => d.toISOString().slice(0, 10); + const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number)); + + const start = period.period_start instanceof Date + ? period.period_start + : new Date(`${period.period_start}T00:00:00.000Z`); + + const end = period.period_end instanceof Date + ? period.period_end + : new Date(`${period.period_end}T00:00:00.000Z`); + + const payd = period.payday instanceof Date + ? period.payday + : new Date(`${period.payday}T00:00:00.000Z`); + + //restrictEmployeeIds = filter for shifts and expenses by employees + const where_employee = options?.filtered_employee_ids?.length ? { employee_id: { in: options.filtered_employee_ids } } : {}; + + // SHIFTS (filtered by crew) + const shifts = await this.prisma.shifts.findMany({ + where: { + date: { gte: start, lte: end }, + timesheet: where_employee, + }, + select: { + start_time: true, + end_time: true, + is_remote: true, + timesheet: { + select: { + is_approved: true, + employee: { + select: { + id: true, + user: { + select: { + first_name: true, + last_name: true, + email: true, + } + }, + } + }, }, - }, - bank_code: { select: { categorie: true, type: true } }, - }, - }); - - // EXPENSES (filtered by crew) - const expenses = await this.prisma.expenses.findMany({ - where: { - date: { gte: start, lte: end }, - timesheet: where_employee, - }, - select: { - amount: true, - timesheet: { select: { - is_approved: true, - employee: { select: { - id: true, - user: { select: { - first_name: true, - last_name: true, - email: true, - } }, - } }, - } }, - bank_code: { select: { categorie: true, modifier: true, type: true } }, - }, - }); - - const by_employee = new Map(); - - // seed for employee without data - if (options?.seed_names) { - for (const [id, {name, email}] of options.seed_names.entries()) { - by_employee.set(id, { - email, - employee_name: name, - regular_hours: 0, - evening_hours: 0, - emergency_hours: 0, - overtime_hours: 0, - total_hours: 0, - expenses: 0, - mileage: 0, - is_approved: true, - is_remote: true, + }, + bank_code: { select: { categorie: true, type: true } }, + }, }); - } - } - const ensure = (id: number, name: string, email: string) => { - if (!by_employee.has(id)) { - by_employee.set(id, { - email, - employee_name: name, - regular_hours: 0, - evening_hours: 0, - emergency_hours: 0, - overtime_hours: 0, - total_hours: 0, - expenses: 0, - mileage: 0, - is_approved: true, - is_remote: true, + // EXPENSES (filtered by crew) + const expenses = await this.prisma.expenses.findMany({ + where: { + date: { gte: start, lte: end }, + timesheet: where_employee, + }, + select: { + amount: true, + timesheet: { + select: { + is_approved: true, + employee: { + select: { + id: true, + user: { + select: { + first_name: true, + last_name: true, + email: true, + } + }, + } + }, + } + }, + bank_code: { select: { categorie: true, modifier: true, type: true } }, + }, }); - } - return by_employee.get(id)!; - }; - for (const shift of shifts) { - const employee = shift.timesheet.employee; - const name = `${employee.user.first_name} ${employee.user.last_name}`.trim(); - const record = ensure(employee.id, name, employee.user.email); + const by_employee = new Map(); - const hours = computeHours(shift.start_time, shift.end_time); - const type = (shift.bank_code?.type ?? '').toUpperCase(); - switch (type) { - case "EVENING": record.evening_hours += hours; - record.total_hours += hours; - break; - case "EMERGENCY": record.emergency_hours += hours; - record.total_hours += hours; - break; - case "OVERTIME": record.overtime_hours += hours; - record.total_hours += hours; - break; - case "REGULAR" : record.regular_hours += hours; - record.total_hours += hours; - break; - } - - record.is_approved = record.is_approved && shift.timesheet.is_approved; - record.is_remote = record.is_remote || !!shift.is_remote; + // seed for employee without data + if (options?.seed_names) { + for (const [id, { name, email }] of options.seed_names.entries()) { + by_employee.set(id, { + email, + employee_name: name, + regular_hours: 0, + other_hours: { + evening_hours: 0, + emergency_hours: 0, + overtime_hours: 0, + sick_hours: 0, + holiday_hours: 0, + vacation_hours: 0, + }, + total_hours: 0, + expenses: 0, + mileage: 0, + is_approved: true, + is_remote: true, + }); + } + } + + const ensure = (id: number, name: string, email: string) => { + if (!by_employee.has(id)) { + by_employee.set(id, { + email, + employee_name: name, + regular_hours: 0, + other_hours: { + evening_hours: 0, + emergency_hours: 0, + overtime_hours: 0, + sick_hours: 0, + holiday_hours: 0, + vacation_hours: 0, + }, + total_hours: 0, + expenses: 0, + mileage: 0, + is_approved: true, + is_remote: true, + }); + } + return by_employee.get(id)!; + }; + + for (const shift of shifts) { + const employee = shift.timesheet.employee; + const name = `${employee.user.first_name} ${employee.user.last_name}`.trim(); + const record = ensure(employee.id, name, employee.user.email); + + const hours = computeHours(shift.start_time, shift.end_time); + const type = (shift.bank_code?.type ?? '').toUpperCase(); + switch (type) { + case "EVENING": record.other_hours.evening_hours += hours; + record.total_hours += hours; + break; + case "EMERGENCY": record.other_hours.emergency_hours += hours; + record.total_hours += hours; + break; + case "OVERTIME": record.other_hours.overtime_hours += hours; + record.total_hours += hours; + break; + case "SICK": record.other_hours.sick_hours += hours; + record.total_hours += hours; + break; + case "HOLIDAY": record.other_hours.holiday_hours += hours; + record.total_hours += hours; + break; + case "VACATION": record.other_hours.vacation_hours += hours; + record.total_hours += hours; + break; + case "REGULAR": record.regular_hours += hours; + record.total_hours += hours; + break; + } + + record.is_approved = record.is_approved && shift.timesheet.is_approved; + record.is_remote = record.is_remote || !!shift.is_remote; + } + + for (const expense of expenses) { + const exp = expense.timesheet.employee; + const name = `${exp.user.first_name} ${exp.user.last_name}`.trim(); + const record = ensure(exp.id, name, exp.user.email); + + const amount = toMoney(expense.amount); + record.expenses += amount; + + const type = (expense.bank_code?.type || "").toUpperCase(); + const rate = expense.bank_code?.modifier ?? 0; + if (type === "MILEAGE" && rate > 0) { + record.mileage += Math.round((amount / rate) * 100) / 100; + } + record.is_approved = record.is_approved && expense.timesheet.is_approved; + } + + const employees_overview = Array.from(by_employee.values()).sort((a, b) => + a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), + ); + + return { + pay_period_no: period.period_no, + pay_year: period.pay_year, + payday: toDateString(payd), + period_start: toDateString(start), + period_end: toDateString(end), + label: period.label, + employees_overview, + }; } - for (const expense of expenses) { - const exp = expense.timesheet.employee; - const name = `${exp.user.first_name} ${exp.user.last_name}`.trim(); - const record = ensure(exp.id, name, exp.user.email); - - const amount = toMoney(expense.amount); - record.expenses += amount; - - const type = (expense.bank_code?.type || "").toUpperCase(); - const rate = expense.bank_code?.modifier ?? 0; - if (type === "MILEAGE" && rate > 0) { - record.mileage += Math.round((amount / rate) * 100) / 100; - } - record.is_approved = record.is_approved && expense.timesheet.is_approved; - } - - const employees_overview = Array.from(by_employee.values()).sort((a, b) => - a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), - ); - - return { - pay_period_no: period.period_no, - pay_year: period.pay_year, - payday: toDateString(payd), - period_start: toDateString(start), - period_end: toDateString(end), - label: period.label, - employees_overview, - }; - } - - async getSupervisor(email:string) { - return this.prisma.employees.findFirst({ - where: { user: { email } }, - select: { id: true, is_supervisor: true }, - }); - } - - async findAll(): Promise { - const currentPayYear = payYearOfDate(new Date()); - return listPayYear(currentPayYear).map(period =>({ - pay_period_no: period.period_no, - pay_year: period.pay_year, - payday: period.payday, - period_start: period.period_start, - period_end: period.period_end, - label: period.label, - //add is_approved - })); - } - - async findOne(period_no: number): Promise { - const row = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no: period_no }, - orderBy: { pay_year: "desc" }, - }); - if (!row) throw new NotFoundException(`Pay period #${period_no} not found`); - return mapPayPeriodToDto(row); - } - - async findCurrent(date?: string): Promise { - const iso_day = date ?? new Date().toISOString().slice(0,10); - return this.findByDate(iso_day); - } - - async findOneByYearPeriod(pay_year: number, period_no: number): Promise { - const row = await this.prisma.payPeriods.findFirst({ - where: { pay_year, pay_period_no: period_no }, - }); - if(row) return mapPayPeriodToDto(row); - - // fallback for outside of view periods - const period = computePeriod(pay_year, period_no); - return { - pay_period_no: period.period_no, - pay_year: period.pay_year, - period_start: period.period_start, - payday: period.payday, - period_end: period.period_end, - label: period.label + async getSupervisor(email: string) { + return this.prisma.employees.findFirst({ + where: { user: { email } }, + select: { id: true, is_supervisor: true }, + }); } - } - //function to cherry pick a Date to find a period - async findByDate(date: string): Promise { - const dt = new Date(date); - const row = await this.prisma.payPeriods.findFirst({ - where: { period_start: { lte: dt }, period_end: { gte: dt } }, - }); - if(row) return mapPayPeriodToDto(row); - - //fallback for outwside view periods - const pay_year = payYearOfDate(date); - const periods = listPayYear(pay_year); - const hit = periods.find(period => date >= period.period_start && date <= period.period_end); - if(!hit) throw new NotFoundException(`No period found for ${date}`); - - return { - pay_period_no: hit.period_no, - pay_year : hit.pay_year, - period_start : hit.period_start, - period_end : hit.period_end, - payday : hit.payday, - label : hit.label + async findAll(): Promise { + const currentPayYear = payYearOfDate(new Date()); + return listPayYear(currentPayYear).map(period => ({ + pay_period_no: period.period_no, + pay_year: period.pay_year, + payday: period.payday, + period_start: period.period_start, + period_end: period.period_end, + label: period.label, + //add is_approved + })); } - } - async getPeriodWindow(pay_year: number, period_no: number) { - return this.prisma.payPeriods.findFirst({ - where: {pay_year, pay_period_no: period_no }, - select: { period_start: true, period_end: true }, - }); - } + async findOne(period_no: number): Promise { + const row = await this.prisma.payPeriods.findFirst({ + where: { pay_period_no: period_no }, + orderBy: { pay_year: "desc" }, + }); + if (!row) throw new NotFoundException(`Pay period #${period_no} not found`); + return mapPayPeriodToDto(row); + } + + async findCurrent(date?: string): Promise { + const iso_day = date ?? new Date().toISOString().slice(0, 10); + return this.findByDate(iso_day); + } + + async findOneByYearPeriod(pay_year: number, period_no: number): Promise { + const row = await this.prisma.payPeriods.findFirst({ + where: { pay_year, pay_period_no: period_no }, + }); + if (row) return mapPayPeriodToDto(row); + + // fallback for outside of view periods + const period = computePeriod(pay_year, period_no); + return { + pay_period_no: period.period_no, + pay_year: period.pay_year, + period_start: period.period_start, + payday: period.payday, + period_end: period.period_end, + label: period.label + } + } + + //function to cherry pick a Date to find a period + async findByDate(date: string): Promise { + const dt = new Date(date); + const row = await this.prisma.payPeriods.findFirst({ + where: { period_start: { lte: dt }, period_end: { gte: dt } }, + }); + if (row) return mapPayPeriodToDto(row); + + //fallback for outwside view periods + const pay_year = payYearOfDate(date); + const periods = listPayYear(pay_year); + const hit = periods.find(period => date >= period.period_start && date <= period.period_end); + if (!hit) throw new NotFoundException(`No period found for ${date}`); + + return { + pay_period_no: hit.period_no, + pay_year: hit.pay_year, + period_start: hit.period_start, + period_end: hit.period_end, + payday: hit.payday, + label: hit.label + } + } + + async getPeriodWindow(pay_year: number, period_no: number) { + return this.prisma.payPeriods.findFirst({ + where: { pay_year, pay_period_no: period_no }, + select: { period_start: true, period_end: true }, + }); + } } From 4527b0e7f71bc3855d436c6d42cc83b4296aa858 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 9 Oct 2025 15:22:34 -0400 Subject: [PATCH 63/69] fix(shifts): clean --- prisma/mock-seeds-scripts/10-shifts.ts | 6 +- .../services/overtime.service.ts | 149 +++++++++++++++--- src/modules/shifts/dtos/upsert-shift.dto.ts | 3 + .../shifts/services/shifts-command.service.ts | 89 ++++++----- src/modules/shifts/utils/shifts.utils.ts | 7 +- 5 files changed, 185 insertions(+), 69 deletions(-) diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index ab10340..878bc43 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -71,10 +71,10 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) { async function main() { // --- Bank codes (pondérés: surtout G1 = régulier) --- - const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305', 'G43'] as const; + const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305'] as const; const WEIGHTED_CODES = [ - 'G1','G1','G1','G1','G1','G1','G1','G1', // 8x régulier - 'G56','G48','G700','G105','G305','G43' + 'G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1', + 'G56','G48','G104','G105','G305','G1','G1','G1','G1','G1','G1' ] as const; const bcRows = await prisma.bankCodes.findMany({ diff --git a/src/modules/business-logics/services/overtime.service.ts b/src/modules/business-logics/services/overtime.service.ts index 79619b5..6c6d1b0 100644 --- a/src/modules/business-logics/services/overtime.service.ts +++ b/src/modules/business-logics/services/overtime.service.ts @@ -1,55 +1,152 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils'; +import { Prisma } from '@prisma/client'; @Injectable() export class OvertimeService { private logger = new Logger(OvertimeService.name); - private daily_max = 12; // maximum for regular hours per day - private weekly_max = 80; //maximum for regular hours per week + private daily_max = 8; // maximum for regular hours per day + private weekly_max = 40; //maximum for regular hours per week + private INCLUDED_TYPES = ['EMERGENCY', 'EVENING','OVERTIME','REGULAR'] as const; // included types for weekly overtime calculation constructor(private prisma: PrismaService) {} - //calculate Daily overtime - getDailyOvertimeHours(start: Date, end: Date): number { - const hours = computeHours(start, end, 5); - const overtime = Math.max(0, hours - this.daily_max); - this.logger.debug(`getDailyOvertimeHours : ${overtime.toFixed(2)}h (threshold ${this.daily_max})`); - return overtime; + //calculate daily overtime + async getDailyOvertimeHoursForDay(employee_id: number, date: Date): Promise { + const shifts = await this.prisma.shifts.findMany({ + where: { date: date, timesheet: { employee_id: employee_id } }, + select: { start_time: true, end_time: true }, + }); + const total = shifts.map((shift)=> + computeHours(shift.start_time, shift.end_time, 5)).reduce((sum, hours)=> sum + hours, 0); + const overtime = Math.max(0, total - this.daily_max); + + this.logger.debug(`[OVERTIME]-[DAILY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`); + return overtime; } //calculate Weekly overtime - //switch employeeId for email - async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise { - const week_start = getWeekStart(refDate); - const week_end = getWeekEnd(week_start); + async getWeeklyOvertimeHours(employee_id: number, ref_date: Date): Promise { + const week_start = getWeekStart(ref_date); + const week_end = getWeekEnd(week_start); - //fetches all shifts containing hours - const shifts = await this.prisma.shifts.findMany({ - where: { timesheet: { employee_id: employeeId, shift: { - every: {date: { gte: week_start, lte: week_end } } + //fetches all shifts from INCLUDED_TYPES array + const included_shifts = await this.prisma.shifts.findMany({ + where: { + date: { gte:week_start, lte: week_end }, + timesheet: { employee_id }, + bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } }, }, - }, - }, - select: { start_time: true, end_time: true }, + select: { start_time: true, end_time: true }, + orderBy: [{date: 'asc'}, {start_time:'asc'}], }); //calculate total hours of those shifts minus weekly Max to find total overtime hours - const total = shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5)) + const total = included_shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5)) .reduce((sum, hours)=> sum+hours, 0); const overtime = Math.max(0, total - this.weekly_max); - this.logger.debug(`weekly total = ${total.toFixed(2)}h, weekly Overtime= ${overtime.toFixed(2)}h`); + this.logger.debug(`[OVERTIME]-[WEEKLY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`); return overtime; } - //apply modifier to overtime hours - calculateOvertimePay(overtime_hours: number, modifier: number): number { - const pay = overtime_hours * modifier; - this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`); + //transform REGULAR shifts to OVERTIME when exceed 40hrs of included_types of shift + async transformRegularHoursToWeeklyOvertime( + employee_id: number, + ref_date: Date, + tx?: Prisma.TransactionClient, + ): Promise { + //ensures the use of the transaction if needed. fallback to this.prisma if no transaction is detected. + const db = tx ?? this.prisma; - return pay; + //calculate weekly overtime + const overtime_hours = await this.getWeeklyOvertimeHours(employee_id, ref_date); + if(overtime_hours <= 0) return; + + const convert_to_minutes = Math.round(overtime_hours * 60); + + const [regular, overtime] = await Promise.all([ + db.bankCodes.findFirst({where: { type: 'REGULAR' }, select: { id: true } }), + db.bankCodes.findFirst({where: { type: 'OVERTIME'}, select: { id: true } }), + ]); + if(!regular || !overtime) return; + + const week_start = getWeekStart(ref_date); + const week_end = getWeekEnd(week_start); + + //gets all regular shifts and order them by desc + const regular_shifts_desc = await db.shifts.findMany({ + where: { + date: { gte:week_start, lte: week_end }, + timesheet: { employee_id }, + bank_code_id: regular.id, + }, + select: { + id: true, + timesheet_id: true, + date: true, + start_time: true, + end_time: true, + is_remote: true, + comment: true, + }, + orderBy: [{date: 'desc'}, {start_time:'desc'}], + }); + + let remaining_minutes = convert_to_minutes; + + for(const shift of regular_shifts_desc) { + if(remaining_minutes <= 0) break; + + const start = shift.start_time; + const end = shift.end_time; + const duration_in_minutes = Math.max(0, Math.round((end.getTime() - start.getTime())/60000)); + if(duration_in_minutes === 0) continue; + + if(duration_in_minutes <= remaining_minutes) { + await db.shifts.update({ + where: { id: shift.id }, + data: { bank_code_id: overtime.id }, + }); + remaining_minutes -= duration_in_minutes; + continue; + } + //sets the start_time of the new overtime shift + const new_overtime_start = new Date(end.getTime() - remaining_minutes * 60000); + + //shorten the regular shift + await db.shifts.update({ + where: { id: shift.id }, + data: { end_time: new_overtime_start }, + }); + + //creates the new overtime shift to replace the shorten regular shift + await db.shifts.create({ + data: { + timesheet_id: shift.timesheet_id, + date: shift.date, + start_time: new_overtime_start, + end_time: end, + is_remote: shift.is_remote, + comment: shift.comment, + bank_code_id: overtime.id, + }, + }); + remaining_minutes = 0; + } + this.logger.debug(`[OVERTIME]-[WEEKLY]-[TRANSFORM] emp=${employee_id} + week: ${week_start.toISOString().slice(0,10)}..${week_end.toISOString().slice(0,10)} + converted= ${(convert_to_minutes-remaining_minutes)/60}h`); } + //apply modifier to overtime hours + // calculateOvertimePay(overtime_hours: number, modifier: number): number { + // const pay = overtime_hours * modifier; + // this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`); + + // return pay; + // } + } diff --git a/src/modules/shifts/dtos/upsert-shift.dto.ts b/src/modules/shifts/dtos/upsert-shift.dto.ts index b82fbb5..fc2e130 100644 --- a/src/modules/shifts/dtos/upsert-shift.dto.ts +++ b/src/modules/shifts/dtos/upsert-shift.dto.ts @@ -17,6 +17,9 @@ export class ShiftPayloadDto { @IsBoolean() is_remote!: boolean; + @IsBoolean() + is_approved!: boolean; + @IsOptional() @IsString() @MaxLength(COMMENT_MAX_LENGTH) diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index f7b59f3..a47e628 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; import { formatHHmm, toDateOnlyUTC, weekStartSundayUTC } from "../helpers/shifts-date-time-helpers"; import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils"; import { DayShiftResponse, UpsertAction } from "../types-and-interfaces/shifts-upsert.types"; @@ -8,13 +8,17 @@ import { Prisma, Shifts } from "@prisma/client"; import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { PrismaService } from "src/prisma/prisma.service"; +import { OvertimeService } from "src/modules/business-logics/services/overtime.service"; @Injectable() export class ShiftsCommandService extends BaseApprovalService { + private readonly logger = new Logger(ShiftsCommandService.name); + constructor( prisma: PrismaService, private readonly emailResolver: EmployeeIdEmailResolver, private readonly bankTypeResolver: BankCodesResolver, + private readonly overtimeService: OvertimeService, ) { super(prisma); } //_____________________________________________________________________________________________ @@ -61,16 +65,16 @@ export class ShiftsCommandService extends BaseApprovalService { //validation/sanitation //resolve bank_code_id using type const old_norm_shift = old_shift ? await normalizeShiftPayload(old_shift) : undefined; - if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) { - throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); - } + // if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) { + // throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); + // } const old_bank_code_id: number | undefined = old_norm_shift ? (await this.bankTypeResolver.findByType(old_norm_shift.type, tx))?.id : undefined; const new_norm_shift = new_shift ? await normalizeShiftPayload(new_shift) : undefined; - if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) { - throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); - } + // if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) { + // throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); + // } const new_bank_code_id: number | undefined = new_norm_shift ? (await this.bankTypeResolver.findByType(new_norm_shift.type, tx))?.id : undefined; @@ -93,6 +97,7 @@ export class ShiftsCommandService extends BaseApprovalService { start_time: old_norm_shift.start_time, end_time: old_norm_shift.end_time, is_remote: old_norm_shift.is_remote, + is_approved: old_norm_shift.is_approved, comment: old_comment, bank_code_id: old_bank_code_id, }, @@ -100,28 +105,28 @@ export class ShiftsCommandService extends BaseApprovalService { }); }; - //checks for overlaping shifts - const assertNoOverlap = (exclude_shift_id?: number)=> { - if (!new_norm_shift) return; - const overlap_with = day_shifts.filter((shift)=> { - if(exclude_shift_id && shift.id === exclude_shift_id) return false; - return overlaps( - new_norm_shift.start_time.getTime(), - new_norm_shift.end_time.getTime(), - shift.start_time.getTime(), - shift.end_time.getTime(), - ); - }); + // //checks for overlaping shifts + // const assertNoOverlap = (exclude_shift_id?: number)=> { + // if (!new_norm_shift) return; + // const overlap_with = day_shifts.filter((shift)=> { + // if(exclude_shift_id && shift.id === exclude_shift_id) return false; + // return overlaps( + // new_norm_shift.start_time.getTime(), + // new_norm_shift.end_time.getTime(), + // shift.start_time.getTime(), + // shift.end_time.getTime(), + // ); + // }); - if(overlap_with.length > 0) { - const conflicts = overlap_with.map((shift)=> ({ - start_time: formatHHmm(shift.start_time), - end_time: formatHHmm(shift.end_time), - type: shift.bank_code?.type ?? 'UNKNOWN', - })); - throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts}); - } - }; + // if(overlap_with.length > 0) { + // const conflicts = overlap_with.map((shift)=> ({ + // start_time: formatHHmm(shift.start_time), + // end_time: formatHHmm(shift.end_time), + // type: shift.bank_code?.type ?? 'UNKNOWN', + // })); + // throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts}); + // } + // }; let action: UpsertAction; //_____________________________________________________________________________________________ // DELETE @@ -143,7 +148,7 @@ export class ShiftsCommandService extends BaseApprovalService { //_____________________________________________________________________________________________ else if (!old_shift && new_shift) { if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); - assertNoOverlap(); + // assertNoOverlap(); await tx.shifts.create({ data: { timesheet_id: timesheet.id, @@ -165,7 +170,7 @@ export class ShiftsCommandService extends BaseApprovalService { if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); const existing = await findExactOldShift(); if(!existing) throw new NotFoundException({ error_code: 'SHIFT_STALE', message: 'The shift was modified or deleted by someone else'}); - assertNoOverlap(existing.id); + // assertNoOverlap(existing.id); await tx.shifts.update({ where: { @@ -182,23 +187,33 @@ export class ShiftsCommandService extends BaseApprovalService { action = 'updated'; } else throw new BadRequestException('At least one of old_shift or new_shift must be provided'); + //switches regular hours to overtime hours when exceeds 40hrs per week. + await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx); + //Reload the day (truth source) const fresh_day = await tx.shifts.findMany({ where: { date: date_only, timesheet_id: timesheet.id, }, - include: { - bank_code: true - }, - orderBy: { - start_time: 'asc' - }, + include: { bank_code: true }, + orderBy: { start_time: 'asc' }, }); + try { + const [ daily_overtime, weekly_overtime ] = await Promise.all([ + this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only), + this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only), + ]); + this.logger.debug(`[OVERTIME] employee_id= ${employee_id}, date=${date_only.toISOString().slice(0,10)} + | daily= ${daily_overtime.toFixed(2)}h, weekly: ${weekly_overtime.toFixed(2)}h, (action:${action})`); + } catch (error) { + this.logger.warn(`Failed to compute overtime after ${action} : ${(error as Error).message}`); + } + return { action, - day: fresh_day.map((shift)=> ({ + day: fresh_day.map((shift) => ({ start_time: formatHHmm(shift.start_time), end_time: formatHHmm(shift.end_time), type: shift.bank_code?.type ?? 'UNKNOWN', diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts index cec997f..7988388 100644 --- a/src/modules/shifts/utils/shifts.utils.ts +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -24,14 +24,15 @@ export function resolveBankCodeByType(type: string): Promise { export function normalizeShiftPayload(payload: ShiftPayloadDto) { //normalize shift's infos - const start_time = timeFromHHMMUTC(payload.start_time); - const end_time = timeFromHHMMUTC(payload.end_time ); + const start_time = payload.start_time; + const end_time = payload.end_time; const type = (payload.type || '').trim().toUpperCase(); const is_remote = payload.is_remote === true; + const is_approved = payload.is_approved === false; //normalize comment const raw_comment = payload.comment ?? null; const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null; const comment = trimmed && trimmed.length > 0 ? trimmed: null; - return { start_time, end_time, type, is_remote, comment }; + return { start_time, end_time, type, is_remote, comment, is_approved }; } \ No newline at end of file From 1954d206a88a50b206da46d055674f3cc0baed95 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 9 Oct 2025 15:23:03 -0400 Subject: [PATCH 64/69] fix(dates): removed dates from L-R and P-P --- src/modules/leave-requests/utils/leave-request.util.ts | 3 --- src/modules/pay-periods/pay-periods.module.ts | 8 ++++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/modules/leave-requests/utils/leave-request.util.ts b/src/modules/leave-requests/utils/leave-request.util.ts index 6688f1a..f5493db 100644 --- a/src/modules/leave-requests/utils/leave-request.util.ts +++ b/src/modules/leave-requests/utils/leave-request.util.ts @@ -62,7 +62,6 @@ export class LeaveRequestsUtils { await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { old_shift: existing ? { - date: existing.date, start_time: existing.start_time.toISOString().slice(11, 16), end_time: existing.end_time.toISOString().slice(11, 16), type: existing.bank_code?.type ?? type, @@ -72,7 +71,6 @@ export class LeaveRequestsUtils { } : undefined, new_shift: { - date: existing?.date ?? '', start_time: toHHmm(start_minutes), end_time: toHHmm(end_minutes), is_remote: existing?.is_remote ?? false, @@ -101,7 +99,6 @@ export class LeaveRequestsUtils { await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { old_shift: { - date: existing.date, start_time: existing.start_time.toISOString().slice(11, 16), end_time: existing.end_time.toISOString().slice(11, 16), type: existing.bank_code?.type ?? type, diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index 80dd614..fc5ce6d 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -8,6 +8,10 @@ import { TimesheetsCommandService } from "../timesheets/services/timesheets-comm import { ExpensesCommandService } from "../expenses/services/expenses-command.service"; import { ShiftsCommandService } from "../shifts/services/shifts-command.service"; import { SharedModule } from "../shared/shared.module"; +import { EmployeeIdEmailResolver } from "../shared/utils/resolve-email-id.utils"; +import { BankCodesResolver } from "../shared/utils/resolve-bank-type-id.utils"; +import { PrismaService } from "src/prisma/prisma.service"; +import { OvertimeService } from "../business-logics/services/overtime.service"; @Module({ imports: [PrismaModule, TimesheetsModule, SharedModule], @@ -17,6 +21,10 @@ import { SharedModule } from "../shared/shared.module"; TimesheetsCommandService, ExpensesCommandService, ShiftsCommandService, + EmployeeIdEmailResolver, + BankCodesResolver, + PrismaService, + OvertimeService, ], controllers: [PayPeriodsController], exports: [ From af8ea95cc4a57bf38ad569f8a064bf67bb89a4e3 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 9 Oct 2025 15:45:57 -0400 Subject: [PATCH 65/69] fix(shifts): changed UTC comparison for ISOString --- .../helpers/shifts-date-time-helpers.ts | 32 ++++++---- .../shifts/services/shifts-command.service.ts | 64 +++++++++---------- src/modules/shifts/utils/shifts.utils.ts | 8 +-- 3 files changed, 54 insertions(+), 50 deletions(-) diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts index b00cf7b..c899dc8 100644 --- a/src/modules/shifts/helpers/shifts-date-time-helpers.ts +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -1,23 +1,27 @@ -export function timeFromHHMMUTC(hhmm: string): Date { - const [hour, min] = hhmm.split(':').map(Number); - return new Date(Date.UTC(1970,0,1,hour, min,0)); +export function timeFromHHMM(hhmm: string): Date { + const [hour, min] = hhmm.split(':').map(Number); + return new Date(1970, 0, 1, hour, min, 0, 0); } -export function weekStartSundayUTC(d: Date): Date { - const day = d.getUTCDay(); - const start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); - start.setUTCDate(start.getUTCDate()- day); - return start; +export function weekStartSunday(d: Date): Date { + const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const day = start.getDay(); // 0 = dimanche + start.setDate(start.getDate() - day); + start.setHours(0, 0, 0, 0); + return start; } -export function toDateOnlyUTC(input: string | Date): Date { - const date = new Date(input); - return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +export function toDateOnly(input: string | Date): Date { + const base = (typeof input === 'string') ? new Date(input) : new Date(input); + const y = (typeof input === 'string') ? Number(input.slice(0,4)) : base.getFullYear(); + const m = (typeof input === 'string') ? Number(input.slice(5,7)) - 1 : base.getMonth(); + const d = (typeof input === 'string') ? Number(input.slice(8,10)) : base.getDate(); + return new Date(y, m, d, 0, 0, 0, 0); } export function formatHHmm(time: Date): string { - const hh = String(time.getUTCHours()).padStart(2,'0'); - const mm = String(time.getUTCMinutes()).padStart(2,'0'); - return `${hh}:${mm}`; + const hh = String(time.getHours()).padStart(2,'0'); + const mm = String(time.getMinutes()).padStart(2,'0'); + return `${hh}:${mm}`; } diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index a47e628..fc65119 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; -import { formatHHmm, toDateOnlyUTC, weekStartSundayUTC } from "../helpers/shifts-date-time-helpers"; import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils"; import { DayShiftResponse, UpsertAction } from "../types-and-interfaces/shifts-upsert.types"; import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; @@ -9,6 +8,7 @@ import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { PrismaService } from "src/prisma/prisma.service"; import { OvertimeService } from "src/modules/business-logics/services/overtime.service"; +import { formatHHmm, toDateOnly, weekStartSunday } from "../helpers/shifts-date-time-helpers"; @Injectable() export class ShiftsCommandService extends BaseApprovalService { @@ -49,11 +49,11 @@ export class ShiftsCommandService extends BaseApprovalService { throw new BadRequestException('At least one of old or new shift must be provided'); } - const date_only = toDateOnlyUTC(date_string); + const date_only = toDateOnly(date_string); const employee_id = await this.emailResolver.findIdByEmail(email); return this.prisma.$transaction(async (tx) => { - const start_of_week = weekStartSundayUTC(date_only); + const start_of_week = weekStartSunday(date_only); const timesheet = await tx.timesheets.upsert({ where: { employee_id_start_date: { employee_id, start_date: start_of_week } }, @@ -65,16 +65,16 @@ export class ShiftsCommandService extends BaseApprovalService { //validation/sanitation //resolve bank_code_id using type const old_norm_shift = old_shift ? await normalizeShiftPayload(old_shift) : undefined; - // if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) { - // throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); - // } + if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) { + throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); + } const old_bank_code_id: number | undefined = old_norm_shift ? (await this.bankTypeResolver.findByType(old_norm_shift.type, tx))?.id : undefined; const new_norm_shift = new_shift ? await normalizeShiftPayload(new_shift) : undefined; - // if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) { - // throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); - // } + if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) { + throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); + } const new_bank_code_id: number | undefined = new_norm_shift ? (await this.bankTypeResolver.findByType(new_norm_shift.type, tx))?.id : undefined; @@ -105,28 +105,28 @@ export class ShiftsCommandService extends BaseApprovalService { }); }; - // //checks for overlaping shifts - // const assertNoOverlap = (exclude_shift_id?: number)=> { - // if (!new_norm_shift) return; - // const overlap_with = day_shifts.filter((shift)=> { - // if(exclude_shift_id && shift.id === exclude_shift_id) return false; - // return overlaps( - // new_norm_shift.start_time.getTime(), - // new_norm_shift.end_time.getTime(), - // shift.start_time.getTime(), - // shift.end_time.getTime(), - // ); - // }); + //checks for overlaping shifts + const assertNoOverlap = (exclude_shift_id?: number)=> { + if (!new_norm_shift) return; + const overlap_with = day_shifts.filter((shift)=> { + if(exclude_shift_id && shift.id === exclude_shift_id) return false; + return overlaps( + new_norm_shift.start_time.getTime(), + new_norm_shift.end_time.getTime(), + shift.start_time.getTime(), + shift.end_time.getTime(), + ); + }); - // if(overlap_with.length > 0) { - // const conflicts = overlap_with.map((shift)=> ({ - // start_time: formatHHmm(shift.start_time), - // end_time: formatHHmm(shift.end_time), - // type: shift.bank_code?.type ?? 'UNKNOWN', - // })); - // throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts}); - // } - // }; + if(overlap_with.length > 0) { + const conflicts = overlap_with.map((shift)=> ({ + start_time: formatHHmm(shift.start_time), + end_time: formatHHmm(shift.end_time), + type: shift.bank_code?.type ?? 'UNKNOWN', + })); + throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts}); + } + }; let action: UpsertAction; //_____________________________________________________________________________________________ // DELETE @@ -148,7 +148,7 @@ export class ShiftsCommandService extends BaseApprovalService { //_____________________________________________________________________________________________ else if (!old_shift && new_shift) { if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); - // assertNoOverlap(); + assertNoOverlap(); await tx.shifts.create({ data: { timesheet_id: timesheet.id, @@ -170,7 +170,7 @@ export class ShiftsCommandService extends BaseApprovalService { if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); const existing = await findExactOldShift(); if(!existing) throw new NotFoundException({ error_code: 'SHIFT_STALE', message: 'The shift was modified or deleted by someone else'}); - // assertNoOverlap(existing.id); + assertNoOverlap(existing.id); await tx.shifts.update({ where: { diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts index 7988388..2ec24c2 100644 --- a/src/modules/shifts/utils/shifts.utils.ts +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -1,6 +1,6 @@ import { NotFoundException } from "@nestjs/common"; import { ShiftPayloadDto } from "../dtos/upsert-shift.dto"; -import { timeFromHHMMUTC } from "../helpers/shifts-date-time-helpers"; +import { timeFromHHMM } from "../helpers/shifts-date-time-helpers"; export function overlaps( a_start_ms: number, @@ -24,8 +24,8 @@ export function resolveBankCodeByType(type: string): Promise { export function normalizeShiftPayload(payload: ShiftPayloadDto) { //normalize shift's infos - const start_time = payload.start_time; - const end_time = payload.end_time; + const start_time = timeFromHHMM(payload.start_time); + const end_time = timeFromHHMM(payload.end_time ); const type = (payload.type || '').trim().toUpperCase(); const is_remote = payload.is_remote === true; const is_approved = payload.is_approved === false; @@ -34,5 +34,5 @@ export function resolveBankCodeByType(type: string): Promise { const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null; const comment = trimmed && trimmed.length > 0 ? trimmed: null; - return { start_time, end_time, type, is_remote, comment, is_approved }; + return { start_time, end_time, type, is_remote, is_approved, comment }; } \ No newline at end of file From 78a335a47c1177ecb9428e84ffdd0258e80a250d Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 9 Oct 2025 15:49:00 -0400 Subject: [PATCH 66/69] fix(imports): ajusted imports for new ISOstring dates methods --- src/modules/expenses/services/expenses-command.service.ts | 4 ++-- src/modules/shared/utils/resolve-employee-timesheet.utils.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 7c80eca..8cc64f3 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -2,7 +2,6 @@ import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { Expenses, Prisma } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; -import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; @@ -20,6 +19,7 @@ import { normalizeType, parseAttachmentId } from "../utils/expenses.utils"; +import { toDateOnly } from "src/modules/shifts/helpers/shifts-date-time-helpers"; @Injectable() export class ExpensesCommandService extends BaseApprovalService { @@ -59,7 +59,7 @@ export class ExpensesCommandService extends BaseApprovalService { if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided'); //validate date format - const date_only = toDateOnlyUTC(date); + const date_only = toDateOnly(date); if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)'); //resolve employee_id by email diff --git a/src/modules/shared/utils/resolve-employee-timesheet.utils.ts b/src/modules/shared/utils/resolve-employee-timesheet.utils.ts index 1117dca..eb3b305 100644 --- a/src/modules/shared/utils/resolve-employee-timesheet.utils.ts +++ b/src/modules/shared/utils/resolve-employee-timesheet.utils.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { Prisma, PrismaClient } from "@prisma/client"; -import { weekStartSundayUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; +import { weekStartSunday } from "src/modules/shifts/helpers/shifts-date-time-helpers"; import { PrismaService } from "src/prisma/prisma.service"; @@ -14,7 +14,7 @@ export class EmployeeTimesheetResolver { readonly ensureForDate = async (employee_id: number, date: Date, client?: Tx, ): Promise<{id: number; start_date: Date }> => { const db = client ?? this.prisma; - const startOfWeek = weekStartSundayUTC(date); + const startOfWeek = weekStartSunday(date); const existing = await db.timesheets.findFirst({ where: { employee_id: employee_id, From 71d86f7fed5a4391f19f68c698c4f226de2bbb94 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 9 Oct 2025 16:41:34 -0400 Subject: [PATCH 67/69] refactor(helpers): moved helpers to a shared file --- docs/swagger/swagger-spec.json | 10 +--- .../holiday-leave-requests.service.ts | 3 +- .../services/leave-request.service.ts | 3 +- .../services/sick-leave-requests.service.ts | 3 +- .../vacation-leave-requests.service.ts | 3 +- .../utils/leave-request.util.ts | 53 +++++++++---------- .../shared/helpers/date-time.helpers.ts | 34 ++++++++++++ .../shifts/controllers/shifts.controller.ts | 5 +- src/modules/shifts/dtos/upsert-shift.dto.ts | 3 ++ .../helpers/shifts-date-time-helpers.ts | 34 ++++++------ .../shifts/services/shifts-command.service.ts | 11 +++- src/modules/shifts/utils/shifts.utils.ts | 3 +- 12 files changed, 99 insertions(+), 66 deletions(-) create mode 100644 src/modules/shared/helpers/date-time.helpers.ts diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 97745e0..01dbbcf 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -437,7 +437,7 @@ ] } }, - "/shifts/upsert/{email}/{date}": { + "/shifts/upsert/{email}": { "put": { "operationId": "ShiftsController_upsert_by_date", "parameters": [ @@ -448,14 +448,6 @@ "schema": { "type": "string" } - }, - { - "name": "date", - "required": true, - "in": "path", - "schema": { - "type": "string" - } } ], "requestBody": { diff --git a/src/modules/leave-requests/services/holiday-leave-requests.service.ts b/src/modules/leave-requests/services/holiday-leave-requests.service.ts index 025833c..a3c72d1 100644 --- a/src/modules/leave-requests/services/holiday-leave-requests.service.ts +++ b/src/modules/leave-requests/services/holiday-leave-requests.service.ts @@ -6,7 +6,8 @@ import { HolidayService } from 'src/modules/business-logics/services/holid import { PrismaService } from 'src/prisma/prisma.service'; import { mapRowToView } from '../mappers/leave-requests.mapper'; import { leaveRequestsSelect } from '../utils/leave-requests.select'; -import { LeaveRequestsUtils, normalizeDates, toDateOnly } from '../utils/leave-request.util'; +import { LeaveRequestsUtils} from '../utils/leave-request.util'; +import { normalizeDates, toDateOnly } from 'src/modules/shared/helpers/date-time.helpers'; @Injectable() diff --git a/src/modules/leave-requests/services/leave-request.service.ts b/src/modules/leave-requests/services/leave-request.service.ts index d7e3239..4b42f55 100644 --- a/src/modules/leave-requests/services/leave-request.service.ts +++ b/src/modules/leave-requests/services/leave-request.service.ts @@ -12,7 +12,8 @@ import { HolidayService } from "src/modules/business-logics/services/holiday.ser import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; import { VacationService } from "src/modules/business-logics/services/vacation.service"; import { PrismaService } from "src/prisma/prisma.service"; -import { LeaveRequestsUtils, normalizeDates, toDateOnly, toISODateKey } from "../utils/leave-request.util"; +import { LeaveRequestsUtils } from "../utils/leave-request.util"; +import { normalizeDates, toDateOnly, toISODateKey } from "src/modules/shared/helpers/date-time.helpers"; @Injectable() export class LeaveRequestsService { diff --git a/src/modules/leave-requests/services/sick-leave-requests.service.ts b/src/modules/leave-requests/services/sick-leave-requests.service.ts index cde2013..ba3d77f 100644 --- a/src/modules/leave-requests/services/sick-leave-requests.service.ts +++ b/src/modules/leave-requests/services/sick-leave-requests.service.ts @@ -7,7 +7,8 @@ import { mapRowToView } from "../mappers/leave-requests.mapper"; import { PrismaService } from "src/prisma/prisma.service"; import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; import { roundToQuarterHour } from "src/common/utils/date-utils"; -import { LeaveRequestsUtils, normalizeDates, toDateOnly } from "../utils/leave-request.util"; +import { LeaveRequestsUtils } from "../utils/leave-request.util"; +import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers"; @Injectable() export class SickLeaveRequestsService { diff --git a/src/modules/leave-requests/services/vacation-leave-requests.service.ts b/src/modules/leave-requests/services/vacation-leave-requests.service.ts index 31d1081..90126a8 100644 --- a/src/modules/leave-requests/services/vacation-leave-requests.service.ts +++ b/src/modules/leave-requests/services/vacation-leave-requests.service.ts @@ -8,7 +8,8 @@ import { PrismaService } from "src/prisma/prisma.service"; import { mapRowToView } from "../mappers/leave-requests.mapper"; import { leaveRequestsSelect } from "../utils/leave-requests.select"; import { roundToQuarterHour } from "src/common/utils/date-utils"; -import { LeaveRequestsUtils, normalizeDates, toDateOnly } from "../utils/leave-request.util"; +import { LeaveRequestsUtils } from "../utils/leave-request.util"; +import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers"; @Injectable() export class VacationLeaveRequestsService { diff --git a/src/modules/leave-requests/utils/leave-request.util.ts b/src/modules/leave-requests/utils/leave-request.util.ts index f5493db..a826728 100644 --- a/src/modules/leave-requests/utils/leave-request.util.ts +++ b/src/modules/leave-requests/utils/leave-request.util.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { LeaveTypes } from "@prisma/client"; +import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers"; import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service"; import { PrismaService } from "src/prisma/prisma.service"; @@ -33,7 +34,7 @@ export class LeaveRequestsUtils { async syncShift( email: string, employee_id: number, - iso_date: string, + date: string, hours: number, type: LeaveTypes, comment?: string, @@ -44,6 +45,10 @@ export class LeaveRequestsUtils { if (duration_minutes > 8 * 60) { throw new BadRequestException("Amount of hours cannot exceed 8 hours per day."); } + const date_only = toDateOnly(date); + const yyyy_mm_dd = toStringFromDate(date_only); + + const start_minutes = 8 * 60; const end_minutes = start_minutes + duration_minutes; @@ -52,25 +57,27 @@ export class LeaveRequestsUtils { const existing = await this.prisma.shifts.findFirst({ where: { - date: new Date(iso_date), + date: date_only, bank_code: { type }, timesheet: { employee_id: employee_id }, }, include: { bank_code: true }, }); - await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { + await this.shiftsCommand.upsertShiftsByDate(email, { old_shift: existing ? { - start_time: existing.start_time.toISOString().slice(11, 16), - end_time: existing.end_time.toISOString().slice(11, 16), - type: existing.bank_code?.type ?? type, - is_remote: existing.is_remote, - is_approved:existing.is_approved, - comment: existing.comment ?? undefined, - } + date: yyyy_mm_dd, + start_time: existing.start_time.toISOString().slice(11, 16), + end_time: existing.end_time.toISOString().slice(11, 16), + type: existing.bank_code?.type ?? type, + is_remote: existing.is_remote, + is_approved:existing.is_approved, + comment: existing.comment ?? undefined, + } : undefined, new_shift: { + date: yyyy_mm_dd, start_time: toHHmm(start_minutes), end_time: toHHmm(end_minutes), is_remote: existing?.is_remote ?? false, @@ -87,9 +94,11 @@ export class LeaveRequestsUtils { iso_date: string, type: LeaveTypes, ) { + const date_only = toDateOnly(iso_date); + const yyyy_mm_dd = toStringFromDate(date_only); const existing = await this.prisma.shifts.findFirst({ where: { - date: new Date(iso_date), + date: date_only, bank_code: { type }, timesheet: { employee_id: employee_id }, }, @@ -97,10 +106,11 @@ export class LeaveRequestsUtils { }); if (!existing) return; - await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { + await this.shiftsCommand.upsertShiftsByDate(email, { old_shift: { - start_time: existing.start_time.toISOString().slice(11, 16), - end_time: existing.end_time.toISOString().slice(11, 16), + date: yyyy_mm_dd, + start_time: hhmmFromLocal(existing.start_time), + end_time: hhmmFromLocal(existing.end_time), type: existing.bank_code?.type ?? type, is_remote: existing.is_remote, is_approved:existing.is_approved, @@ -110,18 +120,3 @@ export class LeaveRequestsUtils { } } - - -export const toDateOnly = (iso: string): Date => { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) { - throw new BadRequestException(`Invalid date: ${iso}`); - } - date.setHours(0, 0, 0, 0); - return date; -}; - -export const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10); - -export const normalizeDates = (dates: string[]): string[] => - Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso))))); \ No newline at end of file diff --git a/src/modules/shared/helpers/date-time.helpers.ts b/src/modules/shared/helpers/date-time.helpers.ts new file mode 100644 index 0000000..6716321 --- /dev/null +++ b/src/modules/shared/helpers/date-time.helpers.ts @@ -0,0 +1,34 @@ +import { BadRequestException } from "@nestjs/common"; + +export const hhmmFromLocal = (d: Date) => + `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; + +export const toDateOnly = (s: string): Date => { + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { + const y = Number(s.slice(0,4)); + const m = Number(s.slice(5,7)) - 1; + const d = Number(s.slice(8,10)); + return new Date(y, m, d, 0, 0, 0, 0); + } + const dt = new Date(s); + if (Number.isNaN(dt.getTime())) throw new BadRequestException(`Invalid date: ${s}`); + return new Date(dt.getFullYear(), dt.getMonth(), dt.getDate(), 0,0,0,0); +}; + +export const toStringFromDate = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + + +export const toISOtoDateOnly = (iso: string): Date => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException(`Invalid date: ${iso}`); + } + date.setHours(0, 0, 0, 0); + return date; +}; + +export const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10); + +export const normalizeDates = (dates: string[]): string[] => + Array.from(new Set(dates.map((iso) => toISODateKey(toISOtoDateOnly(iso))))); \ No newline at end of file diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index f12d9ad..45545dd 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -18,13 +18,12 @@ export class ShiftsController { private readonly shiftsCommandService: ShiftsCommandService, ){} - @Put('upsert/:email/:date') + @Put('upsert/:email') async upsert_by_date( @Param('email') email_param: string, - @Param('date') date_param: string, @Body() payload: UpsertShiftDto, ) { - return this.shiftsCommandService.upsertShiftsByDate(email_param, date_param, payload); + return this.shiftsCommandService.upsertShiftsByDate(email_param, payload); } @Patch('approval/:id') diff --git a/src/modules/shifts/dtos/upsert-shift.dto.ts b/src/modules/shifts/dtos/upsert-shift.dto.ts index fc2e130..7809571 100644 --- a/src/modules/shifts/dtos/upsert-shift.dto.ts +++ b/src/modules/shifts/dtos/upsert-shift.dto.ts @@ -5,6 +5,9 @@ export const COMMENT_MAX_LENGTH = 280; export class ShiftPayloadDto { + @Matches(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/) + date!: string; + @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/) start_time!: string; diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts index c899dc8..d5ba369 100644 --- a/src/modules/shifts/helpers/shifts-date-time-helpers.ts +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -1,27 +1,25 @@ export function timeFromHHMM(hhmm: string): Date { - const [hour, min] = hhmm.split(':').map(Number); - return new Date(1970, 0, 1, hour, min, 0, 0); + const [h, m] = hhmm.split(':').map(Number); + return new Date(1970, 0, 1, h, m, 0, 0); } -export function weekStartSunday(d: Date): Date { - const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()); - const day = start.getDay(); // 0 = dimanche - start.setDate(start.getDate() - day); +export function toDateOnly(ymd: string): Date { + const y = Number(ymd.slice(0, 4)); + const m = Number(ymd.slice(5, 7)) - 1; + const d = Number(ymd.slice(8, 10)); + return new Date(y, m, d, 0, 0, 0, 0); +} + +export function weekStartSunday(dateLocal: Date): Date { + const start = new Date(dateLocal.getFullYear(), dateLocal.getMonth(), dateLocal.getDate()); + const dow = start.getDay(); // 0 = dimanche + start.setDate(start.getDate() - dow); start.setHours(0, 0, 0, 0); return start; } -export function toDateOnly(input: string | Date): Date { - const base = (typeof input === 'string') ? new Date(input) : new Date(input); - const y = (typeof input === 'string') ? Number(input.slice(0,4)) : base.getFullYear(); - const m = (typeof input === 'string') ? Number(input.slice(5,7)) - 1 : base.getMonth(); - const d = (typeof input === 'string') ? Number(input.slice(8,10)) : base.getDate(); - return new Date(y, m, d, 0, 0, 0, 0); -} - -export function formatHHmm(time: Date): string { - const hh = String(time.getHours()).padStart(2,'0'); - const mm = String(time.getMinutes()).padStart(2,'0'); +export function formatHHmm(t: Date): string { + const hh = String(t.getHours()).padStart(2, '0'); + const mm = String(t.getMinutes()).padStart(2, '0'); return `${hh}:${mm}`; } - diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index fc65119..ddbff29 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -41,7 +41,7 @@ export class ShiftsCommandService extends BaseApprovalService { //_____________________________________________________________________________________________ // MASTER CRUD METHOD //_____________________________________________________________________________________________ - async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto): + async upsertShiftsByDate(email:string, dto: UpsertShiftDto): Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { const { old_shift, new_shift } = dto; @@ -49,7 +49,14 @@ export class ShiftsCommandService extends BaseApprovalService { throw new BadRequestException('At least one of old or new shift must be provided'); } - const date_only = toDateOnly(date_string); + const date = new_shift?.date ?? old_shift?.date; + if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift"); + if (old_shift?.date + && new_shift?.date + && old_shift.date + !== new_shift.date) throw new BadRequestException("old_shift.date and new_shift.date must be identical"); + + const date_only = toDateOnly(date); const employee_id = await this.emailResolver.findIdByEmail(email); return this.prisma.$transaction(async (tx) => { diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts index 2ec24c2..43c569f 100644 --- a/src/modules/shifts/utils/shifts.utils.ts +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -24,6 +24,7 @@ export function resolveBankCodeByType(type: string): Promise { export function normalizeShiftPayload(payload: ShiftPayloadDto) { //normalize shift's infos + const date = payload.date; const start_time = timeFromHHMM(payload.start_time); const end_time = timeFromHHMM(payload.end_time ); const type = (payload.type || '').trim().toUpperCase(); @@ -34,5 +35,5 @@ export function resolveBankCodeByType(type: string): Promise { const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null; const comment = trimmed && trimmed.length > 0 ? trimmed: null; - return { start_time, end_time, type, is_remote, is_approved, comment }; + return { date, start_time, end_time, type, is_remote, is_approved, comment }; } \ No newline at end of file From 6a4fbeb2c4087144ad9e5f68ede6b36333350bd1 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 9 Oct 2025 16:48:45 -0400 Subject: [PATCH 68/69] refactor(leave-requests): moved utils to shared file --- .../holiday-leave-requests.service.ts | 8 ++++++-- .../services/leave-request.service.ts | 10 +++++++--- .../services/sick-leave-requests.service.ts | 8 ++++++-- .../vacation-leave-requests.service.ts | 8 ++++++-- .../utils/leave-request.util.ts | 20 ------------------- 5 files changed, 25 insertions(+), 29 deletions(-) diff --git a/src/modules/leave-requests/services/holiday-leave-requests.service.ts b/src/modules/leave-requests/services/holiday-leave-requests.service.ts index a3c72d1..a6d17c2 100644 --- a/src/modules/leave-requests/services/holiday-leave-requests.service.ts +++ b/src/modules/leave-requests/services/holiday-leave-requests.service.ts @@ -8,6 +8,8 @@ import { mapRowToView } from '../mappers/leave-requests.mapper'; import { leaveRequestsSelect } from '../utils/leave-requests.select'; import { LeaveRequestsUtils} from '../utils/leave-request.util'; import { normalizeDates, toDateOnly } from 'src/modules/shared/helpers/date-time.helpers'; +import { BankCodesResolver } from 'src/modules/shared/utils/resolve-bank-type-id.utils'; +import { EmployeeIdEmailResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; @Injectable() @@ -16,12 +18,14 @@ export class HolidayLeaveRequestsService { private readonly prisma: PrismaService, private readonly holidayService: HolidayService, private readonly leaveUtils: LeaveRequestsUtils, + private readonly emailResolver: EmployeeIdEmailResolver, + private readonly typeResolver: BankCodesResolver, ) {} async create(dto: UpsertLeaveRequestDto): Promise { const email = dto.email.trim(); - const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); - const bank_code = await this.leaveUtils.resolveBankCodeByType(LeaveTypes.HOLIDAY); + const employee_id = await this.emailResolver.findIdByEmail(email); + const bank_code = await this.typeResolver.findByType(LeaveTypes.HOLIDAY); if(!bank_code) throw new NotFoundException(`bank_code not found`); const dates = normalizeDates(dto.dates); if (!dates.length) throw new BadRequestException('Dates array must not be empty'); diff --git a/src/modules/leave-requests/services/leave-request.service.ts b/src/modules/leave-requests/services/leave-request.service.ts index 4b42f55..f46ab5c 100644 --- a/src/modules/leave-requests/services/leave-request.service.ts +++ b/src/modules/leave-requests/services/leave-request.service.ts @@ -14,6 +14,8 @@ import { VacationService } from "src/modules/business-logics/services/vacation.s import { PrismaService } from "src/prisma/prisma.service"; import { LeaveRequestsUtils } from "../utils/leave-request.util"; import { normalizeDates, toDateOnly, toISODateKey } from "src/modules/shared/helpers/date-time.helpers"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; @Injectable() export class LeaveRequestsService { @@ -26,6 +28,8 @@ export class LeaveRequestsService { private readonly vacationLeaveService: VacationLeaveRequestsService, private readonly vacationLogic: VacationService, private readonly leaveUtils: LeaveRequestsUtils, + private readonly emailResolver: EmployeeIdEmailResolver, + private readonly typeResolver: BankCodesResolver, ) {} //handle distribution to the right service according to the selected type and action @@ -63,7 +67,7 @@ export class LeaveRequestsService { async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise { const email = dto.email.trim(); const dates = normalizeDates(dto.dates); - const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); + const employee_id = await this.emailResolver.findIdByEmail(email); if (!dates.length) throw new BadRequestException("Dates array must not be empty"); const rows = await this.prisma.leaveRequests.findMany({ @@ -97,8 +101,8 @@ export class LeaveRequestsService { async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise { const email = dto.email.trim(); - const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); - const bank_code = await this.leaveUtils.resolveBankCodeByType(type); + const employee_id = await this.emailResolver.findIdByEmail(email); + const bank_code = await this.typeResolver.findByType(type); if(!bank_code) throw new NotFoundException(`bank_code not found`); const modifier = Number(bank_code.modifier ?? 1); const dates = normalizeDates(dto.dates); diff --git a/src/modules/leave-requests/services/sick-leave-requests.service.ts b/src/modules/leave-requests/services/sick-leave-requests.service.ts index ba3d77f..a99488c 100644 --- a/src/modules/leave-requests/services/sick-leave-requests.service.ts +++ b/src/modules/leave-requests/services/sick-leave-requests.service.ts @@ -9,6 +9,8 @@ import { SickLeaveService } from "src/modules/business-logics/services/sick-le import { roundToQuarterHour } from "src/common/utils/date-utils"; import { LeaveRequestsUtils } from "../utils/leave-request.util"; import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; @Injectable() export class SickLeaveRequestsService { @@ -16,12 +18,14 @@ export class SickLeaveRequestsService { private readonly prisma: PrismaService, private readonly sickService: SickLeaveService, private readonly leaveUtils: LeaveRequestsUtils, + private readonly emailResolver: EmployeeIdEmailResolver, + private readonly typeResolver: BankCodesResolver, ) {} async create(dto: UpsertLeaveRequestDto): Promise { const email = dto.email.trim(); - const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); - const bank_code = await this.leaveUtils.resolveBankCodeByType(LeaveTypes.SICK); + const employee_id = await this.emailResolver.findIdByEmail(email); + const bank_code = await this.typeResolver.findByType(LeaveTypes.SICK); if(!bank_code) throw new NotFoundException(`bank_code not found`); const modifier = bank_code.modifier ?? 1; diff --git a/src/modules/leave-requests/services/vacation-leave-requests.service.ts b/src/modules/leave-requests/services/vacation-leave-requests.service.ts index 90126a8..d1bce32 100644 --- a/src/modules/leave-requests/services/vacation-leave-requests.service.ts +++ b/src/modules/leave-requests/services/vacation-leave-requests.service.ts @@ -10,6 +10,8 @@ import { leaveRequestsSelect } from "../utils/leave-requests.select"; import { roundToQuarterHour } from "src/common/utils/date-utils"; import { LeaveRequestsUtils } from "../utils/leave-request.util"; import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers"; +import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; @Injectable() export class VacationLeaveRequestsService { @@ -17,12 +19,14 @@ export class VacationLeaveRequestsService { private readonly prisma: PrismaService, private readonly vacationService: VacationService, private readonly leaveUtils: LeaveRequestsUtils, + private readonly emailResolver: EmployeeIdEmailResolver, + private readonly typeResolver: BankCodesResolver, ) {} async create(dto: UpsertLeaveRequestDto): Promise { const email = dto.email.trim(); - const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); - const bank_code = await this.leaveUtils.resolveBankCodeByType(LeaveTypes.VACATION); + const employee_id = await this.emailResolver.findIdByEmail(email); + const bank_code = await this.typeResolver.findByType(LeaveTypes.VACATION); if(!bank_code) throw new NotFoundException(`bank_code not found`); const modifier = bank_code.modifier ?? 1; diff --git a/src/modules/leave-requests/utils/leave-request.util.ts b/src/modules/leave-requests/utils/leave-request.util.ts index a826728..7cd41de 100644 --- a/src/modules/leave-requests/utils/leave-request.util.ts +++ b/src/modules/leave-requests/utils/leave-request.util.ts @@ -11,26 +11,6 @@ export class LeaveRequestsUtils { private readonly shiftsCommand: ShiftsCommandService, ){} - async resolveEmployeeIdByEmail(email: string): Promise { - const employee = await this.prisma.employees.findFirst({ - where: { user: { email } }, - select: { id: true }, - }); - if (!employee) { - throw new NotFoundException(`Employee with email ${email} not found`); - } - return employee.id; - } - - async resolveBankCodeByType(type: LeaveTypes) { - const bankCode = await this.prisma.bankCodes.findFirst({ - where: { type }, - select: { id: true, bank_code: true, modifier: true }, - }); - if (!bankCode) throw new BadRequestException(`Bank code type "${type}" not found`); - return bankCode; - } - async syncShift( email: string, employee_id: number, From 9efdafb20fd89f2796395b3adb9c534b8f732ba6 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 10 Oct 2025 09:27:57 -0400 Subject: [PATCH 69/69] refactor(preferences & modules): changed type from boolean to Int so each preferences can have more than 2 options. Also cleaned-up module imports/providers --- docs/swagger/swagger-spec.json | 37 +++++++++++ package-lock.json | 64 +++++++++---------- package.json | 2 +- .../migration.sql | 18 ++++++ prisma/schema.prisma | 10 +-- src/app.module.ts | 2 + src/modules/employees/employees.module.ts | 3 +- .../services/expenses-command.service.ts | 4 +- .../services/expenses-query.service.ts | 4 +- src/modules/exports/csv-exports.module.ts | 3 +- .../leave-requests/leave-requests.module.ts | 3 +- .../holiday-leave-requests.service.ts | 4 +- .../services/leave-request.service.ts | 4 +- .../services/sick-leave-requests.service.ts | 4 +- .../vacation-leave-requests.service.ts | 4 +- .../utils/leave-request.util.ts | 4 +- src/modules/pay-periods/pay-periods.module.ts | 9 +-- .../preferences/dtos/preferences.dto.ts | 18 +++--- src/modules/preferences/preferences.module.ts | 8 ++- .../services/preferences.service.ts | 17 ++--- .../schedule-presets.module.ts | 7 +- .../schedule-presets-apply.service.ts | 4 +- .../schedule-presets-command.service.ts | 4 +- .../schedule-presets-query.service.ts | 4 +- src/modules/shared/shared.module.ts | 6 +- .../shared/utils/resolve-email-id.utils.ts | 14 +++- .../shifts/services/shifts-command.service.ts | 4 +- src/modules/shifts/shifts.module.ts | 34 +++++----- .../services/timesheets-command.service.ts | 4 +- .../services/timesheets-query.service.ts | 4 +- src/modules/timesheets/timesheets.module.ts | 32 +++++----- 31 files changed, 201 insertions(+), 138 deletions(-) create mode 100644 prisma/migrations/20251010124650_changed_boolean_to_int_for_table_preferences/migration.sql diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 01dbbcf..0cf1209 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -1065,6 +1065,39 @@ ] } }, + "/preferences/{email}": { + "patch": { + "operationId": "PreferencesController_updatePreferences", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Preferences" + ] + } + }, "/schedule-presets/{email}": { "put": { "operationId": "SchedulePresetsController_upsert", @@ -1666,6 +1699,10 @@ "employees_overview" ] }, + "PreferencesDto": { + "type": "object", + "properties": {} + }, "SchedulePresetsDto": { "type": "object", "properties": {} diff --git a/package-lock.json b/package-lock.json index 0622bea..1166ca0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^6.16.3", + "prisma": "^6.17.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", @@ -3148,9 +3148,9 @@ } }, "node_modules/@prisma/config": { - "version": "6.16.3", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz", - "integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.0.tgz", + "integrity": "sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ==", "devOptional": true, "dependencies": { "c12": "3.1.0", @@ -3160,48 +3160,48 @@ } }, "node_modules/@prisma/debug": { - "version": "6.16.3", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz", - "integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.0.tgz", + "integrity": "sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g==", "devOptional": true }, "node_modules/@prisma/engines": { - "version": "6.16.3", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz", - "integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.0.tgz", + "integrity": "sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/debug": "6.16.3", - "@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", - "@prisma/fetch-engine": "6.16.3", - "@prisma/get-platform": "6.16.3" + "@prisma/debug": "6.17.0", + "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", + "@prisma/fetch-engine": "6.17.0", + "@prisma/get-platform": "6.17.0" } }, "node_modules/@prisma/engines-version": { - "version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz", - "integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==", + "version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a.tgz", + "integrity": "sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw==", "devOptional": true }, "node_modules/@prisma/fetch-engine": { - "version": "6.16.3", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz", - "integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.0.tgz", + "integrity": "sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg==", "devOptional": true, "dependencies": { - "@prisma/debug": "6.16.3", - "@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", - "@prisma/get-platform": "6.16.3" + "@prisma/debug": "6.17.0", + "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", + "@prisma/get-platform": "6.17.0" } }, "node_modules/@prisma/get-platform": { - "version": "6.16.3", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz", - "integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.0.tgz", + "integrity": "sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ==", "devOptional": true, "dependencies": { - "@prisma/debug": "6.16.3" + "@prisma/debug": "6.17.0" } }, "node_modules/@scarf/scarf": { @@ -10049,14 +10049,14 @@ } }, "node_modules/prisma": { - "version": "6.16.3", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz", - "integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.0.tgz", + "integrity": "sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/config": "6.16.3", - "@prisma/engines": "6.16.3" + "@prisma/config": "6.17.0", + "@prisma/engines": "6.17.0" }, "bin": { "prisma": "build/index.js" diff --git a/package.json b/package.json index 68eb03f..76aac8f 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^6.16.3", + "prisma": "^6.17.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", diff --git a/prisma/migrations/20251010124650_changed_boolean_to_int_for_table_preferences/migration.sql b/prisma/migrations/20251010124650_changed_boolean_to_int_for_table_preferences/migration.sql new file mode 100644 index 0000000..bf8f7c6 --- /dev/null +++ b/prisma/migrations/20251010124650_changed_boolean_to_int_for_table_preferences/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - The `notifications` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - The `dark_mode` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - The `lang_switch` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - The `lefty_mode` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column. + +*/ +-- AlterTable +ALTER TABLE "preferences" DROP COLUMN "notifications", +ADD COLUMN "notifications" INTEGER NOT NULL DEFAULT 0, +DROP COLUMN "dark_mode", +ADD COLUMN "dark_mode" INTEGER NOT NULL DEFAULT 0, +DROP COLUMN "lang_switch", +ADD COLUMN "lang_switch" INTEGER NOT NULL DEFAULT 0, +DROP COLUMN "lefty_mode", +ADD COLUMN "lefty_mode" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dd489c2..abb8fae 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -376,11 +376,11 @@ model Preferences { user Users @relation("UserPreferences", fields: [user_id], references: [id]) user_id String @unique @db.Uuid - notifications Boolean @default(false) - dark_mode Boolean @default(false) - lang_switch Boolean @default(false) - lefty_mode Boolean @default(false) -// TODO: change BOOLEAN to use 0 or 1 in case there is more than 2 options for each preferences + notifications Int @default(0) + dark_mode Int @default(0) + lang_switch Int @default(0) + lefty_mode Int @default(0) + @@map("preferences") } diff --git a/src/app.module.ts b/src/app.module.ts index 74eb167..daf8229 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module'; import { OvertimeService } from './modules/business-logics/services/overtime.service'; import { PayperiodsModule } from './modules/pay-periods/pay-periods.module'; +import { PreferencesModule } from './modules/preferences/preferences.module'; import { PrismaModule } from './prisma/prisma.module'; import { ScheduleModule } from '@nestjs/schedule'; import { ShiftsModule } from './modules/shifts/shifts.module'; @@ -43,6 +44,7 @@ import { SchedulePresetsModule } from './modules/schedule-presets/schedule-prese NotificationsModule, OauthSessionsModule, PayperiodsModule, + PreferencesModule, PrismaModule, ScheduleModule.forRoot(), //cronjobs SchedulePresetsModule, diff --git a/src/modules/employees/employees.module.ts b/src/modules/employees/employees.module.ts index b362663..66a14a7 100644 --- a/src/modules/employees/employees.module.ts +++ b/src/modules/employees/employees.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { EmployeesController } from './controllers/employees.controller'; import { EmployeesService } from './services/employees.service'; import { EmployeesArchivalService } from './services/employees-archival.service'; +import { SharedModule } from '../shared/shared.module'; @Module({ - controllers: [EmployeesController], + controllers: [EmployeesController, SharedModule], providers: [EmployeesService, EmployeesArchivalService], exports: [EmployeesService, EmployeesArchivalService], }) diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 8cc64f3..bda3c90 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -4,7 +4,7 @@ import { PrismaService } from "src/prisma/prisma.service"; import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; -import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils"; import { BadRequestException, @@ -27,7 +27,7 @@ export class ExpensesCommandService extends BaseApprovalService { prisma: PrismaService, private readonly bankCodesResolver: BankCodesResolver, private readonly timesheetsResolver: EmployeeTimesheetResolver, - private readonly emailResolver: EmployeeIdEmailResolver, + private readonly emailResolver: EmailToIdResolver, ) { super(prisma); } //_____________________________________________________________________________________________ diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index 46aba80..35cdde5 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -3,13 +3,13 @@ import { PrismaService } from "src/prisma/prisma.service"; import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers"; import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types"; -import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; @Injectable() export class ExpensesQueryService { constructor( private readonly prisma: PrismaService, - private readonly employeeRepo: EmployeeIdEmailResolver, + private readonly employeeRepo: EmailToIdResolver, ) {} diff --git a/src/modules/exports/csv-exports.module.ts b/src/modules/exports/csv-exports.module.ts index 92a5a96..e034c9e 100644 --- a/src/modules/exports/csv-exports.module.ts +++ b/src/modules/exports/csv-exports.module.ts @@ -1,9 +1,10 @@ import { Module } from "@nestjs/common"; import { CsvExportController } from "./controllers/csv-exports.controller"; import { CsvExportService } from "./services/csv-exports.service"; +import { SharedModule } from "../shared/shared.module"; @Module({ - providers:[CsvExportService], + providers:[CsvExportService, SharedModule], controllers: [CsvExportController], }) export class CsvExportModule {} diff --git a/src/modules/leave-requests/leave-requests.module.ts b/src/modules/leave-requests/leave-requests.module.ts index 8959e30..714dad8 100644 --- a/src/modules/leave-requests/leave-requests.module.ts +++ b/src/modules/leave-requests/leave-requests.module.ts @@ -8,9 +8,10 @@ import { SickLeaveRequestsService } from "./services/sick-leave-requests.service import { LeaveRequestsService } from "./services/leave-request.service"; import { ShiftsModule } from "../shifts/shifts.module"; import { LeaveRequestsUtils } from "./utils/leave-request.util"; +import { SharedModule } from "../shared/shared.module"; @Module({ - imports: [BusinessLogicsModule, ShiftsModule], + imports: [BusinessLogicsModule, ShiftsModule, SharedModule], controllers: [LeaveRequestController], providers: [ VacationLeaveRequestsService, diff --git a/src/modules/leave-requests/services/holiday-leave-requests.service.ts b/src/modules/leave-requests/services/holiday-leave-requests.service.ts index a6d17c2..309bfbb 100644 --- a/src/modules/leave-requests/services/holiday-leave-requests.service.ts +++ b/src/modules/leave-requests/services/holiday-leave-requests.service.ts @@ -9,7 +9,7 @@ import { leaveRequestsSelect } from '../utils/leave-requests.select'; import { LeaveRequestsUtils} from '../utils/leave-request.util'; import { normalizeDates, toDateOnly } from 'src/modules/shared/helpers/date-time.helpers'; import { BankCodesResolver } from 'src/modules/shared/utils/resolve-bank-type-id.utils'; -import { EmployeeIdEmailResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; +import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; @Injectable() @@ -18,7 +18,7 @@ export class HolidayLeaveRequestsService { private readonly prisma: PrismaService, private readonly holidayService: HolidayService, private readonly leaveUtils: LeaveRequestsUtils, - private readonly emailResolver: EmployeeIdEmailResolver, + private readonly emailResolver: EmailToIdResolver, private readonly typeResolver: BankCodesResolver, ) {} diff --git a/src/modules/leave-requests/services/leave-request.service.ts b/src/modules/leave-requests/services/leave-request.service.ts index f46ab5c..d5e3eb7 100644 --- a/src/modules/leave-requests/services/leave-request.service.ts +++ b/src/modules/leave-requests/services/leave-request.service.ts @@ -14,7 +14,7 @@ import { VacationService } from "src/modules/business-logics/services/vacation.s import { PrismaService } from "src/prisma/prisma.service"; import { LeaveRequestsUtils } from "../utils/leave-request.util"; import { normalizeDates, toDateOnly, toISODateKey } from "src/modules/shared/helpers/date-time.helpers"; -import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; @Injectable() @@ -28,7 +28,7 @@ export class LeaveRequestsService { private readonly vacationLeaveService: VacationLeaveRequestsService, private readonly vacationLogic: VacationService, private readonly leaveUtils: LeaveRequestsUtils, - private readonly emailResolver: EmployeeIdEmailResolver, + private readonly emailResolver: EmailToIdResolver, private readonly typeResolver: BankCodesResolver, ) {} diff --git a/src/modules/leave-requests/services/sick-leave-requests.service.ts b/src/modules/leave-requests/services/sick-leave-requests.service.ts index a99488c..dc513fa 100644 --- a/src/modules/leave-requests/services/sick-leave-requests.service.ts +++ b/src/modules/leave-requests/services/sick-leave-requests.service.ts @@ -9,7 +9,7 @@ import { SickLeaveService } from "src/modules/business-logics/services/sick-le import { roundToQuarterHour } from "src/common/utils/date-utils"; import { LeaveRequestsUtils } from "../utils/leave-request.util"; import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers"; -import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; @Injectable() @@ -18,7 +18,7 @@ export class SickLeaveRequestsService { private readonly prisma: PrismaService, private readonly sickService: SickLeaveService, private readonly leaveUtils: LeaveRequestsUtils, - private readonly emailResolver: EmployeeIdEmailResolver, + private readonly emailResolver: EmailToIdResolver, private readonly typeResolver: BankCodesResolver, ) {} diff --git a/src/modules/leave-requests/services/vacation-leave-requests.service.ts b/src/modules/leave-requests/services/vacation-leave-requests.service.ts index d1bce32..8d90b6f 100644 --- a/src/modules/leave-requests/services/vacation-leave-requests.service.ts +++ b/src/modules/leave-requests/services/vacation-leave-requests.service.ts @@ -10,7 +10,7 @@ import { leaveRequestsSelect } from "../utils/leave-requests.select"; import { roundToQuarterHour } from "src/common/utils/date-utils"; import { LeaveRequestsUtils } from "../utils/leave-request.util"; import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers"; -import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; @Injectable() @@ -19,7 +19,7 @@ export class VacationLeaveRequestsService { private readonly prisma: PrismaService, private readonly vacationService: VacationService, private readonly leaveUtils: LeaveRequestsUtils, - private readonly emailResolver: EmployeeIdEmailResolver, + private readonly emailResolver: EmailToIdResolver, private readonly typeResolver: BankCodesResolver, ) {} diff --git a/src/modules/leave-requests/utils/leave-request.util.ts b/src/modules/leave-requests/utils/leave-request.util.ts index 7cd41de..11e0c9b 100644 --- a/src/modules/leave-requests/utils/leave-request.util.ts +++ b/src/modules/leave-requests/utils/leave-request.util.ts @@ -1,8 +1,8 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { LeaveTypes } from "@prisma/client"; import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers"; +import { BadRequestException, Injectable } from "@nestjs/common"; import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service"; import { PrismaService } from "src/prisma/prisma.service"; +import { LeaveTypes } from "@prisma/client"; @Injectable() export class LeaveRequestsUtils { diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index fc5ce6d..c5606db 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -8,23 +8,18 @@ import { TimesheetsCommandService } from "../timesheets/services/timesheets-comm import { ExpensesCommandService } from "../expenses/services/expenses-command.service"; import { ShiftsCommandService } from "../shifts/services/shifts-command.service"; import { SharedModule } from "../shared/shared.module"; -import { EmployeeIdEmailResolver } from "../shared/utils/resolve-email-id.utils"; -import { BankCodesResolver } from "../shared/utils/resolve-bank-type-id.utils"; import { PrismaService } from "src/prisma/prisma.service"; -import { OvertimeService } from "../business-logics/services/overtime.service"; +import { BusinessLogicsModule } from "../business-logics/business-logics.module"; @Module({ - imports: [PrismaModule, TimesheetsModule, SharedModule], + imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule], providers: [ PayPeriodsQueryService, PayPeriodsCommandService, TimesheetsCommandService, ExpensesCommandService, ShiftsCommandService, - EmployeeIdEmailResolver, - BankCodesResolver, PrismaService, - OvertimeService, ], controllers: [PayPeriodsController], exports: [ diff --git a/src/modules/preferences/dtos/preferences.dto.ts b/src/modules/preferences/dtos/preferences.dto.ts index 2bfa3e3..5b1377e 100644 --- a/src/modules/preferences/dtos/preferences.dto.ts +++ b/src/modules/preferences/dtos/preferences.dto.ts @@ -1,16 +1,16 @@ -import { IsBoolean, IsEmail, IsString } from "class-validator"; +import { IsInt } from "class-validator"; export class PreferencesDto { - @IsBoolean() - notifications: boolean; + @IsInt() + notifications: number; - @IsBoolean() - dark_mode: boolean; + @IsInt() + dark_mode: number; - @IsBoolean() - lang_switch: boolean; + @IsInt() + lang_switch: number; - @IsBoolean() - lefty_mode: boolean; + @IsInt() + lefty_mode: number; } \ No newline at end of file diff --git a/src/modules/preferences/preferences.module.ts b/src/modules/preferences/preferences.module.ts index 94161cb..4fe0227 100644 --- a/src/modules/preferences/preferences.module.ts +++ b/src/modules/preferences/preferences.module.ts @@ -1,11 +1,13 @@ import { Module } from "@nestjs/common"; import { PreferencesController } from "./controllers/preferences.controller"; import { PreferencesService } from "./services/preferences.service"; +import { SharedModule } from "../shared/shared.module"; @Module({ -controllers: [ PreferencesController ], -providers: [ PreferencesService ], -exports: [ PreferencesService ], + imports: [SharedModule], + controllers: [ PreferencesController ], + providers: [ PreferencesService ], + exports: [ PreferencesService ], }) export class PreferencesModule {} \ No newline at end of file diff --git a/src/modules/preferences/services/preferences.service.ts b/src/modules/preferences/services/preferences.service.ts index cafac84..89d6484 100644 --- a/src/modules/preferences/services/preferences.service.ts +++ b/src/modules/preferences/services/preferences.service.ts @@ -2,22 +2,17 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { Preferences } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; import { PreferencesDto } from "../dtos/preferences.dto"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; @Injectable() export class PreferencesService { - constructor(private readonly prisma: PrismaService){} - - async resolveUserIdWithEmail(email: string): Promise { - const user = await this.prisma.users.findFirst({ - where: { email }, - select: { id: true }, - }); - if(!user) throw new NotFoundException(`User with email ${ email } not found`); - return user.id; - } + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmailToIdResolver , + ){} async updatePreferences(email: string, dto: PreferencesDto ): Promise { - const user_id = await this.resolveUserIdWithEmail(email); + const user_id = await this.emailResolver.resolveUserIdWithEmail(email); return this.prisma.preferences.update({ where: { user_id }, data: { diff --git a/src/modules/schedule-presets/schedule-presets.module.ts b/src/modules/schedule-presets/schedule-presets.module.ts index 1973e1a..2e25a6d 100644 --- a/src/modules/schedule-presets/schedule-presets.module.ts +++ b/src/modules/schedule-presets/schedule-presets.module.ts @@ -2,21 +2,18 @@ import { Module } from "@nestjs/common"; import { SchedulePresetsCommandService } from "./services/schedule-presets-command.service"; import { SchedulePresetsQueryService } from "./services/schedule-presets-query.service"; import { SchedulePresetsController } from "./controller/schedule-presets.controller"; -import { EmployeeIdEmailResolver } from "../shared/utils/resolve-email-id.utils"; -import { BankCodesResolver } from "../shared/utils/resolve-bank-type-id.utils"; import { PrismaService } from "src/prisma/prisma.service"; import { SchedulePresetsApplyService } from "./services/schedule-presets-apply.service"; +import { SharedModule } from "../shared/shared.module"; @Module({ - imports: [], + imports: [SharedModule], controllers: [SchedulePresetsController], providers: [ PrismaService, SchedulePresetsCommandService, SchedulePresetsQueryService, SchedulePresetsApplyService, - EmployeeIdEmailResolver, - BankCodesResolver, ], exports:[ SchedulePresetsCommandService, diff --git a/src/modules/schedule-presets/services/schedule-presets-apply.service.ts b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts index 99ef799..fa2bce1 100644 --- a/src/modules/schedule-presets/services/schedule-presets-apply.service.ts +++ b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; -import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { PrismaService } from "src/prisma/prisma.service"; import { ApplyResult } from "../types/schedule-presets.types"; import { Prisma, Weekday } from "@prisma/client"; @@ -9,7 +9,7 @@ import { WEEKDAY } from "../mappers/schedule-presets.mappers"; export class SchedulePresetsApplyService { constructor( private readonly prisma: PrismaService, - private readonly emailResolver: EmployeeIdEmailResolver, + private readonly emailResolver: EmailToIdResolver, ) {} async applyToTimesheet( diff --git a/src/modules/schedule-presets/services/schedule-presets-command.service.ts b/src/modules/schedule-presets/services/schedule-presets-command.service.ts index b3c9e91..0c2a8bb 100644 --- a/src/modules/schedule-presets/services/schedule-presets-command.service.ts +++ b/src/modules/schedule-presets/services/schedule-presets-command.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; -import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; import { PrismaService } from "src/prisma/prisma.service"; import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto"; @@ -10,7 +10,7 @@ import { Prisma, Weekday } from "@prisma/client"; export class SchedulePresetsCommandService { constructor( private readonly prisma: PrismaService, - private readonly emailResolver: EmployeeIdEmailResolver, + private readonly emailResolver: EmailToIdResolver, private readonly typeResolver : BankCodesResolver, ){} diff --git a/src/modules/schedule-presets/services/schedule-presets-query.service.ts b/src/modules/schedule-presets/services/schedule-presets-query.service.ts index 665fe47..7ccb0f0 100644 --- a/src/modules/schedule-presets/services/schedule-presets-query.service.ts +++ b/src/modules/schedule-presets/services/schedule-presets-query.service.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException } from "@nestjs/common"; -import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { PrismaService } from "src/prisma/prisma.service"; import { PresetResponse, ShiftResponse } from "../types/schedule-presets.types"; import { Prisma } from "@prisma/client"; @@ -8,7 +8,7 @@ import { Prisma } from "@prisma/client"; export class SchedulePresetsQueryService { constructor( private readonly prisma: PrismaService, - private readonly emailResolver: EmployeeIdEmailResolver, + private readonly emailResolver: EmailToIdResolver, ){} async findSchedulePresetsByEmail(email:string): Promise { diff --git a/src/modules/shared/shared.module.ts b/src/modules/shared/shared.module.ts index 4e71c92..adf0b68 100644 --- a/src/modules/shared/shared.module.ts +++ b/src/modules/shared/shared.module.ts @@ -1,5 +1,5 @@ import { Module } from "@nestjs/common"; -import { EmployeeIdEmailResolver } from "./utils/resolve-email-id.utils"; +import { EmailToIdResolver } from "./utils/resolve-email-id.utils"; import { EmployeeTimesheetResolver } from "./utils/resolve-employee-timesheet.utils"; import { FullNameResolver } from "./utils/resolve-full-name.utils"; import { BankCodesResolver } from "./utils/resolve-bank-type-id.utils"; @@ -9,13 +9,13 @@ import { PrismaModule } from "src/prisma/prisma.module"; imports: [PrismaModule], providers: [ FullNameResolver, - EmployeeIdEmailResolver, + EmailToIdResolver, BankCodesResolver, EmployeeTimesheetResolver, ], exports: [ FullNameResolver, - EmployeeIdEmailResolver, + EmailToIdResolver, BankCodesResolver, EmployeeTimesheetResolver, ], diff --git a/src/modules/shared/utils/resolve-email-id.utils.ts b/src/modules/shared/utils/resolve-email-id.utils.ts index c232fbe..543f377 100644 --- a/src/modules/shared/utils/resolve-email-id.utils.ts +++ b/src/modules/shared/utils/resolve-email-id.utils.ts @@ -5,7 +5,7 @@ import { PrismaService } from "src/prisma/prisma.service"; type Tx = Prisma.TransactionClient | PrismaClient; @Injectable() -export class EmployeeIdEmailResolver { +export class EmailToIdResolver { constructor(private readonly prisma: PrismaService) {} @@ -20,4 +20,16 @@ export class EmployeeIdEmailResolver { if(!employee)throw new NotFoundException(`Employee with email: ${email} not found`); return employee.id; } + + // find user_id using email + readonly resolveUserIdWithEmail = async (email: string, client?: Tx + ): Promise => { + const db = client ?? this.prisma; + const user = await db.users.findFirst({ + where: { email }, + select: { id: true }, + }); + if(!user) throw new NotFoundException(`User with email ${ email } not found`); + return user.id; + } } \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index ddbff29..85e79a1 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils"; import { DayShiftResponse, UpsertAction } from "../types-and-interfaces/shifts-upsert.types"; -import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; import { Prisma, Shifts } from "@prisma/client"; import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; @@ -16,7 +16,7 @@ export class ShiftsCommandService extends BaseApprovalService { constructor( prisma: PrismaService, - private readonly emailResolver: EmployeeIdEmailResolver, + private readonly emailResolver: EmailToIdResolver, private readonly bankTypeResolver: BankCodesResolver, private readonly overtimeService: OvertimeService, ) { super(prisma); } diff --git a/src/modules/shifts/shifts.module.ts b/src/modules/shifts/shifts.module.ts index 2e507e2..8d1346c 100644 --- a/src/modules/shifts/shifts.module.ts +++ b/src/modules/shifts/shifts.module.ts @@ -5,24 +5,24 @@ import { ShiftsCommandService } from './services/shifts-command.service'; import { NotificationsModule } from '../notifications/notifications.module'; import { ShiftsQueryService } from './services/shifts-query.service'; import { ShiftsArchivalService } from './services/shifts-archival.service'; -import { BankCodesResolver } from '../shared/utils/resolve-bank-type-id.utils'; -import { EmployeeIdEmailResolver } from '../shared/utils/resolve-email-id.utils'; +import { SharedModule } from '../shared/shared.module'; @Module({ - imports: [BusinessLogicsModule, NotificationsModule], - controllers: [ShiftsController], - providers: [ - ShiftsQueryService, - ShiftsCommandService, - ShiftsArchivalService, - BankCodesResolver, - EmployeeIdEmailResolver], - exports: [ - ShiftsQueryService, - ShiftsCommandService, - ShiftsArchivalService, - BankCodesResolver, - EmployeeIdEmailResolver - ], + imports: [ + BusinessLogicsModule, + NotificationsModule, + SharedModule + ], + controllers: [ShiftsController], + providers: [ + ShiftsQueryService, + ShiftsCommandService, + ShiftsArchivalService, + ], + exports: [ + ShiftsQueryService, + ShiftsCommandService, + ShiftsArchivalService, + ], }) export class ShiftsModule {} diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 59652e5..b6de4cb 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -7,7 +7,7 @@ import { CreateTimesheetDto } from "../dtos/create-timesheet.dto"; import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils"; import { parseISODate, parseHHmm } from "../utils/timesheet.helpers"; import { TimesheetDto } from "../dtos/timesheet-period.dto"; -import { EmployeeIdEmailResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; @@ -16,7 +16,7 @@ export class TimesheetsCommandService extends BaseApprovalService{ constructor( prisma: PrismaService, private readonly query: TimesheetsQueryService, - private readonly emailResolver: EmployeeIdEmailResolver, + private readonly emailResolver: EmailToIdResolver, private readonly timesheetResolver: EmployeeTimesheetResolver, private readonly bankTypeResolver: BankCodesResolver, ) {super(prisma);} diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index c14387f..1092c3b 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -5,7 +5,7 @@ import { PrismaService } from 'src/prisma/prisma.service'; import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; import { ShiftRow, ExpenseRow } from '../types/timesheet.types'; import { buildPeriod } from '../utils/timesheet.utils'; -import { EmployeeIdEmailResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; +import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils'; @@ -13,7 +13,7 @@ import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.uti export class TimesheetsQueryService { constructor( private readonly prisma: PrismaService, - private readonly emailResolver: EmployeeIdEmailResolver, + private readonly emailResolver: EmailToIdResolver, private readonly fullNameResolver: FullNameResolver ) {} diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index c7636ba..7824aa4 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -9,20 +9,22 @@ import { SharedModule } from '../shared/shared.module'; import { Module } from '@nestjs/common'; @Module({ - imports: [BusinessLogicsModule, SharedModule], - controllers: [TimesheetsController], - providers: [ - TimesheetsQueryService, - TimesheetsCommandService, - ShiftsCommandService, - ExpensesCommandService, - TimesheetArchiveService, - - ], - exports: [ - TimesheetsQueryService, - TimesheetArchiveService, - TimesheetsCommandService - ], + imports: [ + BusinessLogicsModule, + SharedModule + ], + controllers: [TimesheetsController], + providers: [ + TimesheetsQueryService, + TimesheetsCommandService, + ShiftsCommandService, + ExpensesCommandService, + TimesheetArchiveService, + ], + exports: [ + TimesheetsQueryService, + TimesheetArchiveService, + TimesheetsCommandService + ], }) export class TimesheetsModule {}