From c170481f3b8e4551cacf9132bc009bcf7e104c3b Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 29 Aug 2025 13:16:58 -0400 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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});