From 9a150715b0824c4875d8c0c7e30d566cc7307329 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 21 Aug 2025 15:00:40 -0400 Subject: [PATCH 1/4] refactor(leave-request): refactor to use email instead of employee ids --- .../dtos/create-leave-request.dto.ts | 30 +-- .../dtos/leave-request.view.dto.ts | 13 + .../dtos/search-leave-request.dto.ts | 20 +- .../mappers/leave-requests-archive.mapper.ts | 17 ++ .../mappers/leave-requests.mapper.ts | 19 ++ .../services/leave-requests.service.ts | 230 ++++++++---------- .../utils/leave-request.transform.ts | 32 +++ .../utils/leave-requests-archive.select.ts | 16 ++ .../utils/leave-requests.select.ts | 22 ++ 9 files changed, 243 insertions(+), 156 deletions(-) create mode 100644 src/modules/leave-requests/dtos/leave-request.view.dto.ts create mode 100644 src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts create mode 100644 src/modules/leave-requests/mappers/leave-requests.mapper.ts create mode 100644 src/modules/leave-requests/utils/leave-request.transform.ts create mode 100644 src/modules/leave-requests/utils/leave-requests-archive.select.ts create mode 100644 src/modules/leave-requests/utils/leave-requests.select.ts diff --git a/src/modules/leave-requests/dtos/create-leave-request.dto.ts b/src/modules/leave-requests/dtos/create-leave-request.dto.ts index 676f432..136c858 100644 --- a/src/modules/leave-requests/dtos/create-leave-request.dto.ts +++ b/src/modules/leave-requests/dtos/create-leave-request.dto.ts @@ -1,23 +1,12 @@ import { ApiProperty } from "@nestjs/swagger"; import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; import { Type } from "class-transformer"; -import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator"; +import { IsDateString, IsEmail, IsEnum, IsInt, IsISO8601, IsNotEmpty, IsOptional, IsString } from "class-validator"; export class CreateLeaveRequestsDto { - @ApiProperty({ - example: 1, - description: 'Leave request`s unique id(auto-incremented)', - }) - id: number; - - //remove emp_id and use email - @ApiProperty({ - example: '4655867', - description: 'Employee`s id', - }) - @Type(()=> Number) - @IsInt() - employee_id: number; + + @IsEmail() + email: string; @ApiProperty({ example: 7, @@ -37,18 +26,17 @@ export class CreateLeaveRequestsDto { @ApiProperty({ example: '22/06/2463', description: 'Leave request`s start date', - }) - @Type(()=>Date) - @IsNotEmpty() - start_date_time:Date; + }) + @IsISO8601() + start_date_time:string; @ApiProperty({ example: '25/03/3019', description: 'Leave request`s end date', }) - @Type(()=>Date) @IsOptional() - end_date_time?: Date; + @IsISO8601() + end_date_time?: string; @ApiProperty({ example: 'My precious', 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..693368d --- /dev/null +++ b/src/modules/leave-requests/dtos/leave-request.view.dto.ts @@ -0,0 +1,13 @@ +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 index 85bd95b..15ce8e4 100644 --- a/src/modules/leave-requests/dtos/search-leave-request.dto.ts +++ b/src/modules/leave-requests/dtos/search-leave-request.dto.ts @@ -1,13 +1,11 @@ -import { LeaveApprovalStatus } from "@prisma/client"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; import { Type } from "class-transformer"; -import { IsOptional, IsInt, IsEnum, IsDateString } from "class-validator"; +import { IsOptional, IsInt, IsEnum, IsDateString, IsEmail } from "class-validator"; export class SearchLeaveRequestsDto { - //remove emp_id and use email - @IsOptional() - @Type(()=> Number) - @IsInt() - employee_id?: number; + + @IsEmail() + email: string; @IsOptional() @Type(()=> Number) @@ -20,9 +18,13 @@ export class SearchLeaveRequestsDto { @IsOptional() @IsDateString() - start_date?: Date; + start_date?: string; @IsOptional() @IsDateString() - end_date?: Date; + end_date?: string; + + @IsOptional() + @IsEnum(LeaveTypes) + leave_type?: LeaveTypes; } \ No newline at end of file diff --git a/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts b/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts new file mode 100644 index 0000000..48d91e0 --- /dev/null +++ b/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts @@ -0,0 +1,17 @@ +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); + +export function mapArchiveRowToView(row: LeaveRequestArchiveRow, email: string, employee_full_name:string): LeaveRequestViewDto { + 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), + comment: row.comment, + approval_status: row.approval_status, + email, + employee_full_name, + } +} \ No newline at end of file diff --git a/src/modules/leave-requests/mappers/leave-requests.mapper.ts b/src/modules/leave-requests/mappers/leave-requests.mapper.ts new file mode 100644 index 0000000..4fe2133 --- /dev/null +++ b/src/modules/leave-requests/mappers/leave-requests.mapper.ts @@ -0,0 +1,19 @@ +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; +} + +export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto { + 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), + 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}` + } +} \ 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 ed5e8bd..1231ed6 100644 --- a/src/modules/leave-requests/services/leave-requests.service.ts +++ b/src/modules/leave-requests/services/leave-requests.service.ts @@ -8,6 +8,13 @@ import { SickLeaveService } from "src/modules/business-logics/services/sick-leav 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 { mapRowToView } from "../mappers/leave-requests.mapper"; +import { LeaveRequestsArchiveController } from "src/modules/archival/controllers/leave-requests-archive.controller"; +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"; @Injectable() export class LeaveRequestsService { @@ -18,138 +25,93 @@ export class LeaveRequestsService { private readonly sickLeaveService: SickLeaveService ) {} - //remove emp_id and use email - async create(dto: CreateLeaveRequestsDto): Promise { - const { employee_id, bank_code_id, leave_type, start_date_time, - end_date_time, comment, approval_status } = dto; - - return this.prisma.leaveRequests.create({ - data: { employee_id, bank_code_id, leave_type, start_date_time, - end_date_time, comment, approval_status: approval_status ?? undefined - }, - include: { employee: { include: { user: true } }, - bank_code: true - }, + //function to avoid using employee_id as identifier in the frontend. + 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; } - async findAll(filters: SearchLeaveRequestsDto): Promise { - const {start_date, end_date, ...other_filters } = filters; - const where: Record = buildPrismaWhere(other_filters); - - 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) }; - } - - const list = await this.prisma.leaveRequests.findMany({ - where, - include: { employee: { include: { user: true } }, - bank_code: true, - }, - }); - - const ms_per_day = 1000 * 60 * 60 * 24; - - return Promise.all( - list.map(async request => { - // end_date fallback - const end_date = request.end_date_time ?? request.start_date_time; - - //Requested days - const diff_days = Math.round((end_date.getTime() - request.start_date_time.getTime()) / ms_per_day) +1; - - // modifier fallback/validation - if (!request.bank_code || request.bank_code.modifier == null) { - throw new BadRequestException(`Modifier manquant pour bank_code_id=${request.bank_code_id}`); - } - const modifier = request.bank_code.modifier; - - let cost: number; - switch (request.bank_code.type) { - case 'holiday' : - cost = await this.holidayService.calculateHolidayPay( request.employee_id, request.start_date_time, modifier ); - break; - case 'vacation' : - cost = await this.vacationService.calculateVacationPay( request.employee_id, request.start_date_time,diff_days, modifier ); - break; - case 'sick' : - cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diff_days, modifier ); - break; - default: - cost = diff_days * modifier; - } - return {...request, days_requested: diff_days, cost }; - }) - ); - } - - //remove emp_id and use email - async findOne(id:number): Promise { - const request = await this.prisma.leaveRequests.findUnique({ - where: { id }, - include: { employee: { include: { user: true } }, - bank_code: true, - }, - }); - if(!request) { - throw new NotFoundException(`LeaveRequest #${id} not found`); - } - //validation and fallback for end_date_time - const end_Date = request.end_date_time ?? request.start_date_time; - - //calculate included days - const msPerDay = 1000 * 60 * 60 * 24; - const diff_days = Math.floor((end_Date.getTime() - request.start_date_time.getTime())/ msPerDay) + 1; - - if (!request.bank_code || request.bank_code.modifier == null) { - throw new BadRequestException(`Modifier missing for code ${request.bank_code_id}`); - } - const modifier = request.bank_code.modifier; - - //calculate cost based on bank_code types - let cost = diff_days * modifier; - switch(request.bank_code.type) { - case 'holiday': - cost = await this.holidayService.calculateHolidayPay( request.employee_id, request.start_date_time, modifier ); - break; - case 'vacation': - cost = await this.vacationService.calculateVacationPay( request.employee_id, request.start_date_time, diff_days, modifier ); - break; - case 'sick': - cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diff_days, modifier ); - break; - default: - cost = diff_days * modifier; - } - return {...request, days_requested: diff_days, cost }; - } - - //remove emp_id and use email - async update(id: number, dto: UpdateLeaveRequestsDto): Promise { - await this.findOne(id); - const { employee_id, leave_type, start_date_time, end_date_time, comment, approval_status } = dto; - return this.prisma.leaveRequests.update({ - where: { 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 !== undefined && { employee_id }), - ...(leave_type !== undefined && { leave_type } ), - ...(start_date_time !== undefined && { start_date_time }), - ...(end_date_time !== undefined && { end_date_time }), - ...(comment !== undefined && { comment }), - ...(approval_status !== undefined && { approval_status }), + 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, }, - include: { employee: { include: { user:true } } }, + select: leaveRequestsSelect, }); + return mapRowToViewWithDays(row); } - async remove(id:number): Promise { - await this.findOne(id); - return this.prisma.leaveRequests.delete({ - where: { id }, + //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 (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' }, }); + + return rows.map(mapRowToViewWithDays); + } + + //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 ****************************************************** @@ -184,14 +146,30 @@ export class LeaveRequestsService { }); } - //fetches all archived employees + //fetches all archived leave-requests async findAllArchived(): Promise { return this.prisma.leaveRequestsArchive.findMany(); } //remove emp_id and use email //fetches an archived employee - async findOneArchived(id: number): Promise { - return this.prisma.leaveRequestsArchive.findUniqueOrThrow({ where: { id } }); + 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 emp = await this.prisma.employees.findUnique({ + where: { id: row.employee_id }, + 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}` : ""; + + return mapArchiveRowToViewWithDays(row, email, full_name); } } \ No newline at end of file diff --git a/src/modules/leave-requests/utils/leave-request.transform.ts b/src/modules/leave-requests/utils/leave-request.transform.ts new file mode 100644 index 0000000..b70c66d --- /dev/null +++ b/src/modules/leave-requests/utils/leave-request.transform.ts @@ -0,0 +1,32 @@ +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 */ +export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto { + const view = mapRowToView(row); + view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time); + return view; +} + +/** 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; +} \ 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 new file mode 100644 index 0000000..5dbbd36 --- /dev/null +++ b/src/modules/leave-requests/utils/leave-requests-archive.select.ts @@ -0,0 +1,16 @@ +import { Prisma } from "@prisma/client"; + +export const leaveRequestsArchiveSelect = { + id: true, + leave_request_id: true, + archived_at: true, + employee_id: true, + leave_type: true, + start_date_time: true, + end_date_time: 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 new file mode 100644 index 0000000..9636334 --- /dev/null +++ b/src/modules/leave-requests/utils/leave-requests.select.ts @@ -0,0 +1,22 @@ +import { Prisma } from "@prisma/client"; + +//custom prisma select to avoid employee_id exposure +export const leaveRequestsSelect = { + id: true, + bank_code_id: true, + leave_type: true, + start_date_time: true, + end_date_time: true, + comment: true, + approval_status: true, + employee: { select: { + id: true, + user: { select: { + email: true, + first_name: true, + last_name: true, + }}, + }}, +} satisfies Prisma.LeaveRequestsSelect; + +export type LeaveRequestRow = Prisma.LeaveRequestsGetPayload<{ select: typeof leaveRequestsSelect}>; From bfe145854f929048747cc50c24704905b14895c0 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 21 Aug 2025 16:27:17 -0400 Subject: [PATCH 2/4] fix(export csv): added a filter to check if item is approved --- .../exports/services/csv-exports.service.ts | 83 +++++++++++-------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/src/modules/exports/services/csv-exports.service.ts b/src/modules/exports/services/csv-exports.service.ts index 5399d85..ed9fab2 100644 --- a/src/modules/exports/services/csv-exports.service.ts +++ b/src/modules/exports/services/csv-exports.service.ts @@ -18,54 +18,67 @@ export interface CsvRow { export class CsvExportService { constructor(private readonly prisma: PrismaService) {} - async collectTransaction(period_id: number, companies: ExportCompany[]): Promise { - const companyCodes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); + async collectTransaction( period_id: number, companies: ExportCompany[], approved: boolean = true): + Promise { + + 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`); - } + if(!period) throw new NotFoundException(`Pay period ${period_id} not found`); - const startDate = period.period_start; - const endDate = period.period_end; + const start_date = period.period_start; + const end_date = period.period_end; + + const approved_filter = approved ? { is_approved: true } : {}; //fetching shifts const shifts = await this.prisma.shifts.findMany({ - where: { date: { gte: startDate, lte: endDate }, - timesheet: { employee: { company_code: { in: companyCodes} } }, + 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 } }, - }}, - }}, + 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: startDate, lte: endDate }, - timesheet: { employee: { company_code: { in: companyCodes} } }, + 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 } }, - } }, - } }, - }, + 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: startDate, lte: endDate }, - employee: { company_code: { in: companyCodes } }, + 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 } }, - }}, + include: { + bank_code: true, + employee: { include: { + user: true, + supervisor: { include: { + user: true } } } }, }, }); @@ -74,7 +87,7 @@ export class CsvExportService { //Shifts Mapping for (const shift of shifts) { const emp = shift.timesheet.employee; - const week_number = this.computeWeekNumber(startDate, shift.date); + const week_number = this.computeWeekNumber(start_date, shift.date); const hours = this.computeHours(shift.start_time, shift.end_time); rows.push({ @@ -85,7 +98,7 @@ export class CsvExportService { quantity_hours: hours, amount: undefined, week_number, - pay_date: this.formatDate(endDate), + pay_date: this.formatDate(end_date), holiday_date: undefined, }); } @@ -93,7 +106,7 @@ export class CsvExportService { //Expenses Mapping for (const e of expenses) { const emp = e.timesheet.employee; - const week_number = this.computeWeekNumber(startDate, e.date); + const week_number = this.computeWeekNumber(start_date, e.date); rows.push({ company_code: emp.company_code, @@ -103,7 +116,7 @@ export class CsvExportService { quantity_hours: undefined, amount: Number(e.amount), week_number, - pay_date: this.formatDate(endDate), + pay_date: this.formatDate(end_date), holiday_date: undefined, }); } @@ -115,7 +128,7 @@ export class CsvExportService { const start = l.start_date_time; const end = l.end_date_time ?? start; - const week_number = this.computeWeekNumber(startDate, start); + const week_number = this.computeWeekNumber(start_date, start); const hours = this.computeHours(start, end); rows.push({ @@ -126,7 +139,7 @@ export class CsvExportService { quantity_hours: hours, amount: undefined, week_number, - pay_date: this.formatDate(endDate), + pay_date: this.formatDate(end_date), holiday_date: undefined, }); } @@ -146,7 +159,7 @@ export class CsvExportService { generateCsv(rows: CsvRow[]): Buffer { const header = [ 'company_code', - 'external_payrol_id', + 'external_payroll_id', 'full_name', 'bank_code', 'quantity_hours', From 2eadabbdb45414904177f9c158eee83ebe53bf7c Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 22 Aug 2025 06:59:45 -0400 Subject: [PATCH 3/4] fix(Leave-Requests): small fix in Promises --- .../leave-requests-archive.controller.ts | 3 ++- .../controllers/leave-requests.controller.ts | 21 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/modules/archival/controllers/leave-requests-archive.controller.ts b/src/modules/archival/controllers/leave-requests-archive.controller.ts index 62e6925..51ad8e6 100644 --- a/src/modules/archival/controllers/leave-requests-archive.controller.ts +++ b/src/modules/archival/controllers/leave-requests-archive.controller.ts @@ -2,6 +2,7 @@ import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } fr 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"; @ApiTags('LeaveRequests Archives') @@ -22,7 +23,7 @@ export class LeaveRequestsArchiveController { //@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 { + async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise { try{ return await this.leaveRequestsService.findOneArchived(id); }catch { diff --git a/src/modules/leave-requests/controllers/leave-requests.controller.ts b/src/modules/leave-requests/controllers/leave-requests.controller.ts index 1878b64..8948df4 100644 --- a/src/modules/leave-requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave-requests/controllers/leave-requests.controller.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseEnumPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; import { LeaveRequestsService } from "../services/leave-requests.service"; import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto"; import { LeaveRequests } from "@prisma/client"; @@ -7,6 +7,7 @@ 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"; @ApiTags('Leave Requests') @ApiBearerAuth('access-token') @@ -20,35 +21,35 @@ export class LeaveRequestController { @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 { + 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: CreateLeaveRequestsDto, isArray: true }) + @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<(LeaveRequests & {daysRequested:number; cost: number})[]> { + 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: CreateLeaveRequestsDto }) + @ApiResponse({ status: 201, description: 'Leave request found',type: LeaveRequestViewDto }) @ApiResponse({ status: 400, description: 'Leave request not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { + 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: CreateLeaveRequestsDto }) + @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 { + update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateLeaveRequestsDto): Promise { return this.leaveRequetsService.update(id, dto); } @@ -58,14 +59,14 @@ export class LeaveRequestController { @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 { + remove(@Param('id', ParseIntPipe) id: number): Promise { return this.leaveRequetsService.remove(id); } //remove emp_id and use email @Patch(':id/approval') updateApproval( @Param('id', ParseIntPipe) id: number, - @Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise { + @Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise { const approvalStatus = is_approved ? LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED; return this.leaveRequetsService.update(id, { approval_status: approvalStatus }); From 301d5f2c9d2be5d73515a3f5dcb3b1d86ca6c36d Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 25 Aug 2025 08:07:48 -0400 Subject: [PATCH 4/4] feat(timesheet): replaced findAll by getPeriodByQuery with year, period_no and email query params. --- .../controllers/timesheets.controller.ts | 30 ++-- .../timesheets/dtos/timesheet-period.dto.ts | 45 ++++++ .../services/timesheets-query.service.ts | 89 ++++++++---- .../timesheets/utils/timesheet.helpers.ts | 134 ++++++++++++++++++ 4 files changed, 254 insertions(+), 44 deletions(-) create mode 100644 src/modules/timesheets/dtos/timesheet-period.dto.ts create mode 100644 src/modules/timesheets/utils/timesheet.helpers.ts diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index 8ee56ed..a5519ef 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; import { TimesheetsQueryService } from '../services/timesheets-query.service'; import { CreateTimesheetDto } from '../dtos/create-timesheet.dto'; import { Timesheets } from '@prisma/client'; @@ -8,6 +8,7 @@ import { Roles as RoleEnum } from '.prisma/client'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { TimesheetsCommandService } from '../services/timesheets-command.service'; import { SearchTimesheetDto } from '../dtos/search-timesheet.dto'; +import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; @ApiTags('Timesheets') @ApiBearerAuth('access-token') @@ -15,8 +16,8 @@ import { SearchTimesheetDto } from '../dtos/search-timesheet.dto'; @Controller('timesheets') export class TimesheetsController { constructor( - private readonly timesheetsService: TimesheetsQueryService, - private readonly timesheetsCommandService: TimesheetsCommandService, + private readonly timesheetsQuery: TimesheetsQueryService, + private readonly timesheetsCommand: TimesheetsCommandService, ) {} @Post() @@ -25,17 +26,18 @@ export class TimesheetsController { @ApiResponse({ status: 201, description: 'Timesheet created', type: CreateTimesheetDto }) @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) create(@Body() dto: CreateTimesheetDto): Promise { - return this.timesheetsService.create(dto); + return this.timesheetsQuery.create(dto); } @Get() //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find all timesheets' }) - @ApiResponse({ status: 201, description: 'List of timesheet found', type: CreateTimesheetDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of timesheets not found' }) - @UsePipes(new ValidationPipe({transform: true, whitelist: true })) - findAll(@Query() filters: SearchTimesheetDto): Promise { - return this.timesheetsService.findAll(filters); + async getPeriodByQuery( + @Query('year', ParseIntPipe ) year: number, + @Query('period_no', ParseIntPipe ) period_no: number, + @Query('email') email?: string + ): Promise { + if(!email || !email.trim()) throw new BadRequestException('Query param "email" is mandatory for this route.'); + return this.timesheetsQuery.findAll(year, period_no, email.trim()); } @Get(':id') @@ -44,7 +46,7 @@ export class TimesheetsController { @ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto }) @ApiResponse({ status: 400, description: 'Timesheet not found' }) findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.timesheetsService.findOne(id); + return this.timesheetsQuery.findOne(id); } @Patch(':id') @@ -56,7 +58,7 @@ export class TimesheetsController { @Param('id', ParseIntPipe) id:number, @Body() dto: UpdateTimesheetDto, ): Promise { - return this.timesheetsService.update(id, dto); + return this.timesheetsQuery.update(id, dto); } @Delete(':id') @@ -65,12 +67,12 @@ export class TimesheetsController { @ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto }) @ApiResponse({ status: 400, description: 'Timesheet not found' }) remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.timesheetsService.remove(id); + return this.timesheetsQuery.remove(id); } @Patch(':id/approval') //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { - return this.timesheetsCommandService.updateApproval(id, isApproved); + return this.timesheetsCommand.updateApproval(id, isApproved); } } diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts new file mode 100644 index 0000000..71a8e2f --- /dev/null +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -0,0 +1,45 @@ +export class ShiftDto { + start: string; + end : string; + is_approved: boolean; +} + +export class ExpenseDto { + amount: number; + is_approved: boolean; +} + +export type DayShiftsDto = ShiftDto[]; + +export class DayExpensesDto { + cash: ExpenseDto[] = []; + km : ExpenseDto[] = []; + [otherType:string]: ExpenseDto[] | any; //pour si on ajoute d'autre type de dépenses +} + +export class WeekDto { + is_approved: boolean; + shifts: { + sun: DayShiftsDto; + mon: DayShiftsDto; + tue: DayShiftsDto; + wed: DayShiftsDto; + thu: DayShiftsDto; + fri: DayShiftsDto; + sat: DayShiftsDto; + } + expenses: { + sun: DayExpensesDto; + mon: DayExpensesDto; + tue: DayExpensesDto; + wed: DayExpensesDto; + thu: DayExpensesDto; + fri: DayExpensesDto; + sat: DayExpensesDto; + } +} + +export class TimesheetPeriodDto { + week1: WeekDto; + week2: WeekDto; +} diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 39e71c8..0fe08ee 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -7,8 +7,12 @@ import { OvertimeService } from 'src/modules/business-logics/services/overtime.s import { computeHours } from 'src/common/utils/date-utils'; import { buildPrismaWhere } from 'src/common/shared/build-prisma-where'; import { SearchTimesheetDto } from '../dtos/search-timesheet.dto'; +import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; +import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; -const ROUND_TO = 5; +// deprecated (used with old findAll) const ROUND_TO = 5; +type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean }; +type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean }; @Injectable() export class TimesheetsQueryService { @@ -30,38 +34,63 @@ export class TimesheetsQueryService { }); } - async findAll(filters: SearchTimesheetDto): Promise { - const where = buildPrismaWhere(filters); - - //fetchs lists of shifts and expenses for a selected timesheet - const rawlist = await this.prisma.timesheets.findMany({ - where, include: { - shift: { include: {bank_code: true } }, - expense: { include: { bank_code: true } }, - employee: { include: { user : true } }, - }, + async findAll(year: number, period_no: number, email: string): Promise { + //finds the employee + const employee = await this.prisma.employees.findFirst({ + where: { user: { is: { email } } }, + select: { id: true }, }); + if(!employee) throw new NotFoundException(`no employee with email ${email} found`); - const detailedlist = await Promise.all( - rawlist.map(async timesheet => { - //detailed shifts - const detailedShifts = timesheet.shift.map(shift => { - const totalhours = computeHours(shift.start_time, shift.end_time, ROUND_TO); - const regularHours = Math.min(8, totalhours); - const dailyOvertime = this.overtime.getDailyOvertimeHours(shift.start_time, shift.end_time); - const payRegular = regularHours * shift.bank_code.modifier; - const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, shift.bank_code.modifier); - return { ...shift, totalhours, payRegular, payOvertime }; - }); - //calculate overtime weekly - const weeklyOvertimeHours = detailedShifts.length - ? await this.overtime.getWeeklyOvertimeHours( - timesheet.employee_id, - timesheet.shift[0].date): 0; - return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours }; + //finds the period + const period = await this.prisma.payPeriods.findFirst({ + where: { pay_year: year, pay_period_no: period_no }, + select: { period_start: true, period_end: true }, + }); + if(!period) throw new NotFoundException(`Period ${year}-${period_no} not found`); + + const from = toUTCDateOnly(period.period_start); + const to = endOfDayUTC(period.period_end); + + //collects data from shifts and expenses + const [ raw_shifts, raw_expenses] = await Promise.all([ + this.prisma.shifts.findMany({ + where: { + timesheet: { is: { employee_id: employee.id } }, + date: { gte: from, lte: to }, + }, + select: { date: true,start_time: true, end_time: true, is_approved: true }, + orderBy: [{date: 'asc'}, { start_time: 'asc' }], }), - ); - return detailedlist; + this.prisma.expenses.findMany({ + where: { + timesheet: { is: { employee_id: employee.id } }, + date: { gte: from, lte: to }, + }, + select: { date: true, amount: true, is_approved: true, bank_code: { + select: { type: true } }, + }, + orderBy: { date: 'asc' }, + }), + ]); + + //Shift data mapping + const shifts: ShiftRow[] = raw_shifts.map(shift => ({ + date: shift.date, + start_time: shift.start_time, + end_time: shift.end_time, + is_approved: shift.is_approved ?? true, + })); + + const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ + date: expense.date, + amount: typeof (expense.amount as any)?.toNumber() === 'function' ? + (expense.amount as any).toNumber() : Number(expense.amount), + type: expense.bank_code?.type ?? 'CASH', + is_approved: expense.is_approved ?? true, + })); + + return buildPeriod(period.period_start, period.period_end, shifts , expenses); } async findOne(id: number): Promise { diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts new file mode 100644 index 0000000..6dfef18 --- /dev/null +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -0,0 +1,134 @@ +import { DayExpensesDto, DayShiftsDto, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto"; + +//makes the strings indexes for arrays +export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; +export type DayKey = 'sun'|'mon'|'tue'|'wed'|'thu'|'fri'|'sat'; + +//DB line types +type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean }; +type ExpenseRow = {date: Date; amount: number; type: string; is_approved?: boolean }; + +export function dayKeyFromDate(date: Date, useUTC = true): DayKey { + const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday + return DAY_KEYS[index]; +} + +const MS_PER_DAY = 86_400_000; + +export function toUTCDateOnly(date: Date | string): Date { + const d = new Date(date); + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); +} + +export function addDays(date:Date, days: number): Date { + return new Date(date.getTime() + days * MS_PER_DAY); +} + +export function endOfDayUTC(date: Date | string): Date { + const d = toUTCDateOnly(date); + return new Date(d.getTime() + MS_PER_DAY - 1); +} + +export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): boolean { + const time = date.getTime(); + return time >= start.getTime() && time <= end_inclusive.getTime(); +} + +export function dayIndex(week_start: Date, date: Date): 1|2|3|4|5|6|7 { + const diff = Math.floor((toUTCDateOnly(date).getTime() - toUTCDateOnly(week_start).getTime())/ MS_PER_DAY); + const index = Math.min(6, Math.max(0, diff)) + 1; + return index as 1|2|3|4|5|6|7; +} + +export function toTimeString(date: Date): string { + const hours = String(date.getUTCHours()).padStart(2,'0'); + const minutes = String(date.getUTCMinutes()).padStart(2,'0'); + return `${hours}:${minutes}`; +} + +export function round2(num: number) { + return Math.round(num * 100) / 100; +} + +export function makeEmptyDayShifts(): DayShiftsDto { return []; } + +export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; } + +export function makeEmptyWeek(): WeekDto { + return { + is_approved: true, + shifts: { + sun: makeEmptyDayShifts(), + mon: makeEmptyDayShifts(), + tue: makeEmptyDayShifts(), + wed: makeEmptyDayShifts(), + thu: makeEmptyDayShifts(), + fri: makeEmptyDayShifts(), + sat: makeEmptyDayShifts(), + }, + expenses: { + sun: makeEmptyDayExpenses(), + mon: makeEmptyDayExpenses(), + tue: makeEmptyDayExpenses(), + wed: makeEmptyDayExpenses(), + thu: makeEmptyDayExpenses(), + fri: makeEmptyDayExpenses(), + sat: makeEmptyDayExpenses(), + }, + }; +} + +export function makeEmptyPeriod(): TimesheetPeriodDto { + return { week1: makeEmptyWeek(), week2: makeEmptyWeek() }; +} + +//needs ajusting according to DB's data for expenses types +export function normalizeExpenseBucket(db_type: string): 'km' | 'cash' { + const type = db_type.trim().toUpperCase(); + if(type.includes('KM') || type.includes('MILEAGE')) return 'km'; + return 'cash'; +} + +export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): WeekDto { + const week = makeEmptyWeek(); + let all_approved = true; + + //Shifts mapped and filtered by dates + const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end)); + for (const shift of week_shifts) { + const key = dayKeyFromDate(shift.date, true); + week.shifts[key].push({ + start: toTimeString(shift.start_time), + end : toTimeString(shift.end_time), + is_approved: shift.is_approved ?? true, + } as ShiftDto); + all_approved = all_approved && (shift.is_approved ?? true); + } + + //Expenses mapped and filtered by dates + const week_expenses = expenses.filter(expense => isBetweenUTC(expense.date, week_start, week_end)); + for (const expense of week_expenses) { + const key = dayKeyFromDate(expense.date, true); + const bucket = normalizeExpenseBucket(expense.type); + if (!Array.isArray(week.expenses[key][bucket])) week.expenses[key][bucket] = []; + week.expenses[key][bucket].push({ + amount: round2(expense.amount), + is_approved: expense.is_approved ?? true, + }); + all_approved = all_approved && (expense.is_approved ?? true); + } + week.is_approved = all_approved; + return week; +} + +export function buildPeriod( period_start: Date, period_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): TimesheetPeriodDto { + const week1_start = toUTCDateOnly(period_start); + const week1_end = endOfDayUTC(addDays(week1_start, 6)); + const week2_start = toUTCDateOnly(addDays(week1_start, 7)); + const week2_end = endOfDayUTC(period_end); + + return { + week1: buildWeek(week1_start, week1_end, shifts, expenses), + week2: buildWeek(week2_start, week2_end, shifts, expenses), + }; +} \ No newline at end of file