From 15d782131497765dff36d70efb32491443c74837 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 23 Dec 2025 07:18:01 -0500 Subject: [PATCH 1/5] fix(pay-period): removed is_remote from payload and added dynamic returns of timesheet.is_approved --- .../dtos/overview-pay-period.dto.ts | 1 - .../services/pay-periods-query.service.ts | 31 +++++++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts b/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts index c955663..19f7896 100644 --- a/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts +++ b/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts @@ -25,7 +25,6 @@ export class EmployeePeriodOverviewDto { expenses: number; mileage: number; is_approved: boolean; - is_remote: boolean; } export class PayPeriodBundleDto { diff --git a/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts b/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts index 59b399c..cb9bf1a 100644 --- a/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts +++ b/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts @@ -127,11 +127,13 @@ export class PayPeriodsQueryService { return { success: true, data: overview.data } } + + private async buildOverview( period: { period_start: string | Date; period_end: string | Date; payday: string | Date; period_no: number; pay_year: number; label: string; - }, //add is_approved + }, options?: { filtered_employee_ids?: number[]; seed_names?: Map } ): Promise> { const toDateString = (d: Date) => d.toISOString().slice(0, 10); @@ -167,6 +169,7 @@ export class PayPeriodsQueryService { is_remote: true, timesheet: { select: { + id: true, is_approved: true, employee: { select: { @@ -193,6 +196,7 @@ export class PayPeriodsQueryService { amount: true, timesheet: { select: { + id: true, is_approved: true, employee: { select: { @@ -211,9 +215,20 @@ export class PayPeriodsQueryService { bank_code: { select: { categorie: true, modifier: true, type: true } }, }, }); + const timesheet_id = shifts[0].timesheet.id ? expenses[0].timesheet.id : null; + if (timesheet_id === null) return { success: false, error: 'INVALID_TIMESHEET' }; const by_employee = new Map(); + const timesheet = await this.prisma.timesheets.findFirst({ + where: { id: timesheet_id }, + select: { + is_approved: true, + id: true, + }, + }); + if (!timesheet) return { success: false, error: 'INVALID_TIMESHEET' }; + // seed for employee without data if (options?.seed_names) { for (const [id, { name, email }] of options.seed_names.entries()) { @@ -234,7 +249,6 @@ export class PayPeriodsQueryService { expenses: 0, mileage: 0, is_approved: false, - is_remote: false, }); } } else { @@ -245,8 +259,8 @@ export class PayPeriodsQueryService { first_name: true, last_name: true, email: true - } - } + }, + }, }, }); @@ -256,7 +270,7 @@ export class PayPeriodsQueryService { if (employee.last_work_day !== null) { is_active = this.checkForInactiveDate(employee.last_work_day) } - + by_employee.set(employee.id, { email: employee.user.email, employee_name: employee.user.first_name + ' ' + employee.user.last_name, @@ -273,8 +287,7 @@ export class PayPeriodsQueryService { total_hours: 0, expenses: 0, mileage: 0, - is_approved: false, - is_remote: false, + is_approved: timesheet.is_approved, }); } } @@ -297,8 +310,7 @@ export class PayPeriodsQueryService { total_hours: 0, expenses: 0, mileage: 0, - is_approved: false, - is_remote: false, + is_approved: timesheet.is_approved, }); } return by_employee.get(id)!; @@ -336,7 +348,6 @@ export class PayPeriodsQueryService { } record.is_approved = record.is_approved && shift.timesheet.is_approved; - record.is_remote = record.is_remote || !!shift.is_remote; } for (const expense of expenses) { From 264ee101bff4dceea0a909e875b6ef68f59b640c Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 23 Dec 2025 14:17:37 -0500 Subject: [PATCH 2/5] refactor(pay-period): refactor buildOverview and cleaned service/controller --- .../dtos/overview-pay-period.dto.ts | 15 + .../pay-period/pay-periods.controller.ts | 38 +- .../pay-period/pay-periods.module.ts | 4 +- .../pay-periods-build-overview.service.ts | 195 ++++++++ .../services/pay-periods-command.service.ts | 6 +- .../services/pay-periods-query.service.ts | 436 +----------------- .../time-and-attendance.module.ts | 2 + .../utils/selects.utils.ts | 25 +- 8 files changed, 251 insertions(+), 470 deletions(-) create mode 100644 src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts diff --git a/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts b/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts index 19f7896..6dd4ebf 100644 --- a/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts +++ b/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts @@ -39,4 +39,19 @@ export class PayPeriodDto { payday: string; pay_year: number; label: string; +} + +export type Overview = { + period_start: string; + period_end: string; + payday: string; + period_no: number; + pay_year: number; + label: string; + options?: options; +} + +export type options = { + filtered_employee_ids?: number[]; + seed_names?: Map } \ No newline at end of file diff --git a/src/time-and-attendance/pay-period/pay-periods.controller.ts b/src/time-and-attendance/pay-period/pay-periods.controller.ts index 8104c69..19a884f 100644 --- a/src/time-and-attendance/pay-period/pay-periods.controller.ts +++ b/src/time-and-attendance/pay-period/pay-periods.controller.ts @@ -1,33 +1,21 @@ -import { Body, Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query, UnauthorizedException } from "@nestjs/common"; -import { PayPeriodBundleDto, PayPeriodOverviewDto } from "./dtos/overview-pay-period.dto"; +import { Body, Controller, Get, Param, ParseIntPipe, Patch } from "@nestjs/common"; +import { PayPeriodOverviewDto } from "./dtos/overview-pay-period.dto"; import { PayPeriodsQueryService } from "./services/pay-periods-query.service"; import { PayPeriodsCommandService } from "./services/pay-periods-command.service"; import { Result } from "src/common/errors/result-error.factory"; import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators"; import { Modules as ModulesEnum } from ".prisma/client"; -import { Access } from "src/common/decorators/module-access.decorators"; -import { BulkCrewApprovalDto } from "src/time-and-attendance/pay-period/dtos/bulk-crew-approval.dto"; +import { GetOverviewService } from "src/time-and-attendance/pay-period/services/pay-periods-build-overview.service"; @Controller('pay-periods') export class PayPeriodsController { constructor( private readonly queryService: PayPeriodsQueryService, + private readonly getOverviewService: GetOverviewService, private readonly commandService: PayPeriodsCommandService, ) { } - @Get('current-and-all') - @ModuleAccessAllowed(ModulesEnum.timesheets) - async getCurrentAndAll(@Query('date') date?: string): Promise> { - const current = await this.queryService.findCurrent(date); - if (!current.success) return { success: false, error: 'INVALID_PAY_PERIOD' }; - - const periods = await this.queryService.findAll(); - if (!periods.success) return { success: false, error: 'INVALID_PAY_PERIOD' }; - - return { success: true, data: { current: current.data, periods: periods.data } }; - } - @Get("date/:date") @ModuleAccessAllowed(ModulesEnum.timesheets) async findByDate(@Param("date") date: string) { @@ -50,28 +38,18 @@ export class PayPeriodsController { @Body('timesheet_ids') timesheet_ids: number[], @Body('is_approved') is_approved: boolean, ): Promise> { - if (!email) return {success: false, error: 'EMAIL_REQUIRED'}; - if (!timesheet_ids || timesheet_ids.length < 1) return {success: false, error: 'TIMESHEET_ID_REQUIRED'}; - if (!is_approved) return {success: false, error: 'APPROVAL_STATUS_REQUIRED'} + if (!email) return { success: false, error: 'EMAIL_REQUIRED' }; + if (!timesheet_ids || timesheet_ids.length < 1) return { success: false, error: 'TIMESHEET_ID_REQUIRED' }; + if (!is_approved) return { success: false, error: 'APPROVAL_STATUS_REQUIRED' } return this.commandService.bulkApproveEmployee(email, timesheet_ids, is_approved); } - @Get('crew/:year/:periodNumber') - @ModuleAccessAllowed(ModulesEnum.timesheets_approval) - async getCrewOverview(@Access('email') email: string, - @Param('year', ParseIntPipe) year: number, - @Param('periodNumber', ParseIntPipe) period_no: number, - @Query('includeSubtree', new ParseBoolPipe({ optional: true })) include_subtree = false, - ): Promise> { - return this.queryService.getCrewOverview(year, period_no, email, include_subtree); - } - @Get('overview/:year/:periodNumber') @ModuleAccessAllowed(ModulesEnum.timesheets_approval) async getOverviewByYear( @Param('year', ParseIntPipe) year: number, @Param('periodNumber', ParseIntPipe) period_no: number, ): Promise> { - return this.queryService.getOverviewByYearPeriod(year, period_no); + return this.getOverviewService.getOverviewByYearPeriod(year, period_no); } } diff --git a/src/time-and-attendance/pay-period/pay-periods.module.ts b/src/time-and-attendance/pay-period/pay-periods.module.ts index 8781b70..3e375db 100644 --- a/src/time-and-attendance/pay-period/pay-periods.module.ts +++ b/src/time-and-attendance/pay-period/pay-periods.module.ts @@ -4,13 +4,15 @@ import { Module } from "@nestjs/common"; import { PayPeriodsCommandService } from "src/time-and-attendance/pay-period/services/pay-periods-command.service"; import { TimesheetsModule } from "src/time-and-attendance/timesheets/timesheets.module"; import { EmailToIdResolver } from "src/common/mappers/email-id.mapper"; +import { GetOverviewService } from "src/time-and-attendance/pay-period/services/pay-periods-build-overview.service"; @Module({ imports:[TimesheetsModule], controllers: [PayPeriodsController], providers: [ - PayPeriodsQueryService, + PayPeriodsQueryService, PayPeriodsCommandService, + GetOverviewService, EmailToIdResolver, ], }) diff --git a/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts b/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts new file mode 100644 index 0000000..c83447f --- /dev/null +++ b/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts @@ -0,0 +1,195 @@ +import { Injectable } from "@nestjs/common"; +import { Result } from "src/common/errors/result-error.factory"; +import { computeHours, computePeriod, toDateFromString } from "src/common/utils/date-utils"; +import { PrismaService } from "src/prisma/prisma.service"; +import { EmployeePeriodOverviewDto, Overview, PayPeriodOverviewDto } from "src/time-and-attendance/pay-period/dtos/overview-pay-period.dto"; + + +@Injectable() +export class GetOverviewService { + constructor( + private readonly prisma: PrismaService, + ) { } + + async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise> { + const period = computePeriod(pay_year, period_no); + const overview = await this.buildOverview({ + period_start: period.period_start, + period_end: period.period_end, + period_no: period.period_no, + pay_year: period.pay_year, + payday: period.payday, + label: period.label, + + }); + if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' } + return { success: true, data: overview.data } + } + + async buildOverview(overview: Overview): Promise> { + const employee_overviews = await this.prisma.employees.findMany({ + where: { + OR: [ + { last_work_day: { gte: overview.period_start } }, + { last_work_day: null }, + ] + }, + select: { + id: true, + last_work_day: true, + user: { + select: { + first_name: true, + last_name: true, + email: true, + }, + }, + supervisor: { + select: { + user: { + select: { + first_name: true, + last_name: true, + }, + }, + }, + }, + timesheet: { + where: { + start_date: { gte: overview.period_start, lte: overview.period_end }, + }, + select: { + id: true, + is_approved: true, + shift: { + select: { + start_time: true, + end_time: true, + bank_code: { select: { type: true } }, + }, + }, + expense: { + select: { + amount: true, + bank_code: { select: { type: true, modifier: true } }, + }, + }, + }, + }, + }, + orderBy: { user: { first_name: 'asc' } }, + }); + + const by_employee = new Map(); + + // seed for employee without data + if (overview.options?.seed_names) { + for (const [id, { name, email }] of overview.options.seed_names.entries()) { + by_employee.set(id, this.createEmployeeSeeds(email, name)); + } + } else { + for (const employee of employee_overviews) { + const name = `${employee.user.first_name} ${employee.user.last_name}`; + const record = this.createEmployeeSeeds(employee.user.email, name); + by_employee.set(employee.id, record); + } + } + + const ensure = (id: number, name: string, email: string) => { + if (!by_employee.has(id)) { + by_employee.set(id, this.createEmployeeSeeds(email, name)); + } + return by_employee.get(id)!; + }; + + for (const employee of employee_overviews) { + const name = `${employee.user.first_name} ${employee.user.last_name}`.trim(); + const record = ensure(employee.id, name, employee.user.email); + for (const timesheet of employee.timesheet) { + //totals by types for shifts + for (const shift of timesheet.shift) { + const hours = computeHours(shift.start_time, shift.end_time); + const type = (shift.bank_code?.type ?? '').toUpperCase(); + switch (type) { + case "EVENING": record.other_hours.evening_hours += hours; + record.total_hours += hours; + break; + case "EMERGENCY": record.other_hours.emergency_hours += hours; + record.total_hours += hours; + break; + case "OVERTIME": record.other_hours.overtime_hours += hours; + record.total_hours += hours; + break; + case "SICK": record.other_hours.sick_hours += hours; + record.total_hours += hours; + break; + case "HOLIDAY": record.other_hours.holiday_hours += hours; + record.total_hours += hours; + break; + case "VACATION": record.other_hours.vacation_hours += hours; + record.total_hours += hours; + break; + case "REGULAR": record.regular_hours += hours; + record.total_hours += hours; + break; + } + } + //totals by type for expenses + for (const expense of timesheet.expense) { + const amount = Number(expense.amount) + record.expenses = Number((record.expenses + amount).toFixed(2)); + const type = (expense.bank_code?.type || "").toUpperCase(); + const rate = expense.bank_code?.modifier ?? 1; + const mileage = amount / rate; + if (type === "MILEAGE" && rate > 0) { + record.mileage = Number((record.mileage += Math.round(mileage)).toFixed(2)); + } + } + } + } + + for (const employee of employee_overviews) { + const record = by_employee.get(employee.id); + if (!record) continue; + const timesheets = employee.timesheet; + const has_data = timesheets.some(timesheet => timesheet.shift.length > 0 || timesheet.expense.length > 0); + record.is_approved = has_data && timesheets.every(timesheet => timesheet.is_approved === true); + } + + const employees_overview = Array.from(by_employee.values()).sort((a, b) => + a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), + ); + + return { + success: true, + data: { + pay_period_no: overview.period_no, + pay_year: overview.pay_year, + payday: overview.payday, + period_start: (overview.period_start), + period_end: overview.period_end, + label: overview.label, + employees_overview, + } + }; + } + + createEmployeeSeeds = (email: string, employee_name: string, is_active = true): EmployeePeriodOverviewDto => ({ + email, + employee_name, + is_active, + regular_hours: 0, + other_hours: { + evening_hours: 0, + emergency_hours: 0, + overtime_hours: 0, + sick_hours: 0, + holiday_hours: 0, + vacation_hours: 0, + }, + total_hours: 0, + expenses: 0, + mileage: 0, + is_approved: false, + }); +} \ No newline at end of file diff --git a/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts b/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts index 5799f3c..b15d89e 100644 --- a/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts +++ b/src/time-and-attendance/pay-period/services/pay-periods-command.service.ts @@ -1,7 +1,5 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; -import { PayPeriodsQueryService } from "./pay-periods-query.service"; -import { TimesheetApprovalService } from "src/time-and-attendance/timesheets/services/timesheet-approval.service"; import { Result } from "src/common/errors/result-error.factory"; import { EmailToIdResolver } from "src/common/mappers/email-id.mapper"; import { Prisma } from "@prisma/client"; @@ -12,8 +10,6 @@ import { Prisma } from "@prisma/client"; export class PayPeriodsCommandService { constructor( private readonly prisma: PrismaService, - private readonly timesheetsApproval: TimesheetApprovalService, - private readonly query: PayPeriodsQueryService, private readonly emailResolver: EmailToIdResolver, ) { } diff --git a/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts b/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts index cb9bf1a..90bd0a9 100644 --- a/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts +++ b/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts @@ -1,426 +1,14 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; -import { computeHours, computePeriod, listPayYear, payYearOfDate, toStringFromDate } from "src/common/utils/date-utils"; -import { EmployeePeriodOverviewDto, PayPeriodDto, PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto"; +import { computePeriod, listPayYear, payYearOfDate } from "src/common/utils/date-utils"; +import { PayPeriodDto } from "../dtos/overview-pay-period.dto"; import { Result } from "src/common/errors/result-error.factory"; import { mapPayPeriodToDto } from "src/time-and-attendance/pay-period/pay-periods.mapper"; @Injectable() export class PayPeriodsQueryService { - constructor(private readonly prisma: PrismaService) { } - - async getOverview(pay_period_no: number): Promise> { - const period = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no }, - orderBy: { pay_year: "desc" }, - }); - if (!period) return { success: false, error: `PAY_PERIOD_NOT_FOUND` }; - - const overview = await this.buildOverview({ - period_start: period.period_start, - period_end: period.period_end, - payday: period.payday, - period_no: period.pay_period_no, - pay_year: period.pay_year, - label: period.label, - }); - if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' } - - return { success: true, data: overview.data } - } - - async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise> { - const period = computePeriod(pay_year, period_no); - const overview = await this.buildOverview({ - period_start: period.period_start, - period_end: period.period_end, - period_no: period.period_no, - pay_year: period.pay_year, - payday: period.payday, - label: period.label, - }); - if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' } - return { success: true, data: overview.data } - } - - //find crew member associated with supervisor - private async resolveCrew(supervisor_id: number, include_subtree: boolean): - Promise, string>> { - const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = []; - - let frontier = await this.prisma.employees.findMany({ - where: { supervisor_id: supervisor_id }, - select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, - }); - result.push(...frontier.map(emp => ({ - id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email - }))); - - if (!include_subtree) return { success: true, data: result }; - - while (frontier.length) { - const parent_ids = frontier.map(emp => emp.id); - const next = await this.prisma.employees.findMany({ - where: { supervisor_id: { in: parent_ids } }, - select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, - }); - if (next.length === 0) break; - result.push(...next.map(emp => ({ - id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email - }))); - frontier = next; - } - return { success: true, data: result }; - } - - //fetchs crew emails - async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise, string>> { - const crew = await this.resolveCrew(supervisor_id, include_subtree); - if (!crew.success) return { success: false, error: crew.error } - return { success: true, data: new Set(crew.data.map(crew_member => crew_member.email).filter(Boolean)) } - } - - async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean): - Promise> { - // 1) Search for the period - const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } }); - if (!period) return { success: false, error: 'PAY_PERIOD_NOT_FOUND' } - - // 2) fetch supervisor - const supervisor = await this.prisma.employees.findFirst({ - where: { user: { email: email } }, - select: { - id: true, - is_supervisor: true, - }, - }); - - if (!supervisor) return { success: false, error: 'EMPLOYEE_NOT_FOUND' } - if (!supervisor.is_supervisor) return { success: false, error: 'INVALID_EMPLOYEE' } - - // 3)fetchs crew members - const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }] - if (!crew.success) return { success: false, error: crew.error } - const crew_ids = crew.data.map(c => c.id); - // seed names map for employee without data - const seed_names = new Map( - crew.data.map(crew => [ - crew.id, - { - name: `${crew.first_name} ${crew.last_name}`.trim(), - email: crew.email - } - ] - ) - ); - const overview = await this.buildOverview({ - period_no: period.pay_period_no, - period_start: period.period_start, - period_end: period.period_end, - payday: period.payday, - pay_year: period.pay_year, - label: period.label, - }, { filtered_employee_ids: crew_ids, seed_names }) - if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' } - - // 4) overview build - return { success: true, data: overview.data } - } - - - - private async buildOverview( - period: { - period_start: string | Date; period_end: string | Date; payday: string | Date; - period_no: number; pay_year: number; label: string; - }, - options?: { filtered_employee_ids?: number[]; seed_names?: Map } - ): Promise> { - const toDateString = (d: Date) => d.toISOString().slice(0, 10); - const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number)); - - const start = period.period_start instanceof Date - ? period.period_start - : new Date(`${period.period_start}T00:00:00.000Z`); - - const end = period.period_end instanceof Date - ? period.period_end - : new Date(`${period.period_end}T00:00:00.000Z`); - - const payd = period.payday instanceof Date - ? period.payday - : new Date(`${period.payday}T00:00:00.000Z`); - - //restrictEmployeeIds = filter for shifts and expenses by employees - const where_employee = options?.filtered_employee_ids?.length ? - { - date: { gte: start, lte: end }, - timesheet: { employee_id: { in: options.filtered_employee_ids } }, - } - : - { date: { gte: start, lte: end } }; - - // SHIFTS (filtered by crew) - const shifts = await this.prisma.shifts.findMany({ - where: where_employee, - select: { - start_time: true, - end_time: true, - is_remote: true, - timesheet: { - select: { - id: true, - is_approved: true, - employee: { - select: { - id: true, - user: { - select: { - first_name: true, - last_name: true, - email: true, - } - }, - } - }, - }, - }, - bank_code: { select: { categorie: true, type: true } }, - }, - }); - - // EXPENSES (filtered by crew) - const expenses = await this.prisma.expenses.findMany({ - where: where_employee, - select: { - amount: true, - timesheet: { - select: { - id: true, - is_approved: true, - employee: { - select: { - id: true, - user: { - select: { - first_name: true, - last_name: true, - email: true, - } - }, - } - }, - } - }, - bank_code: { select: { categorie: true, modifier: true, type: true } }, - }, - }); - const timesheet_id = shifts[0].timesheet.id ? expenses[0].timesheet.id : null; - if (timesheet_id === null) return { success: false, error: 'INVALID_TIMESHEET' }; - - const by_employee = new Map(); - - const timesheet = await this.prisma.timesheets.findFirst({ - where: { id: timesheet_id }, - select: { - is_approved: true, - id: true, - }, - }); - if (!timesheet) return { success: false, error: 'INVALID_TIMESHEET' }; - - // seed for employee without data - if (options?.seed_names) { - for (const [id, { name, email }] of options.seed_names.entries()) { - by_employee.set(id, { - email, - employee_name: name, - is_active: true, - regular_hours: 0, - other_hours: { - evening_hours: 0, - emergency_hours: 0, - overtime_hours: 0, - sick_hours: 0, - holiday_hours: 0, - vacation_hours: 0, - }, - total_hours: 0, - expenses: 0, - mileage: 0, - is_approved: false, - }); - } - } else { - const all_employees = await this.prisma.employees.findMany({ - include: { - user: { - select: { - first_name: true, - last_name: true, - email: true - }, - }, - }, - }); - - for (const employee of all_employees) { - let is_active = true; - - if (employee.last_work_day !== null) { - is_active = this.checkForInactiveDate(employee.last_work_day) - } - - by_employee.set(employee.id, { - email: employee.user.email, - employee_name: employee.user.first_name + ' ' + employee.user.last_name, - is_active: is_active, - regular_hours: 0, - other_hours: { - evening_hours: 0, - emergency_hours: 0, - overtime_hours: 0, - sick_hours: 0, - holiday_hours: 0, - vacation_hours: 0, - }, - total_hours: 0, - expenses: 0, - mileage: 0, - is_approved: timesheet.is_approved, - }); - } - } - - const ensure = (id: number, name: string, email: string) => { - if (!by_employee.has(id)) { - by_employee.set(id, { - email, - employee_name: name, - is_active: true, - regular_hours: 0, - other_hours: { - evening_hours: 0, - emergency_hours: 0, - overtime_hours: 0, - sick_hours: 0, - holiday_hours: 0, - vacation_hours: 0, - }, - total_hours: 0, - expenses: 0, - mileage: 0, - is_approved: timesheet.is_approved, - }); - } - return by_employee.get(id)!; - }; - - for (const shift of shifts) { - const employee = shift.timesheet.employee; - const name = `${employee.user.first_name} ${employee.user.last_name}`.trim(); - const record = ensure(employee.id, name, employee.user.email); - - const hours = computeHours(shift.start_time, shift.end_time); - const type = (shift.bank_code?.type ?? '').toUpperCase(); - switch (type) { - case "EVENING": record.other_hours.evening_hours += hours; - record.total_hours += hours; - break; - case "EMERGENCY": record.other_hours.emergency_hours += hours; - record.total_hours += hours; - break; - case "OVERTIME": record.other_hours.overtime_hours += hours; - record.total_hours += hours; - break; - case "SICK": record.other_hours.sick_hours += hours; - record.total_hours += hours; - break; - case "HOLIDAY": record.other_hours.holiday_hours += hours; - record.total_hours += hours; - break; - case "VACATION": record.other_hours.vacation_hours += hours; - record.total_hours += hours; - break; - case "REGULAR": record.regular_hours = record.regular_hours += hours; - record.total_hours += hours; - break; - } - - record.is_approved = record.is_approved && shift.timesheet.is_approved; - } - - for (const expense of expenses) { - const exp = expense.timesheet.employee; - const name = `${exp.user.first_name} ${exp.user.last_name}`.trim(); - const record = ensure(exp.id, name, exp.user.email); - - const amount = toMoney(expense.amount); - record.expenses = Number((record.expenses += amount).toFixed(2)); - - const type = (expense.bank_code?.type || "").toUpperCase(); - const rate = expense.bank_code?.modifier ?? 0; - if (type === "MILEAGE" && rate > 0) { - record.mileage = Number((record.mileage += Math.round((amount / rate) * 100) / 100).toFixed(2)); - } - record.is_approved = record.is_approved && expense.timesheet.is_approved; - } - - const employees_overview = Array.from(by_employee.values()).sort((a, b) => - a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), - ); - - return { - success: true, - data: { - pay_period_no: period.period_no, - pay_year: period.pay_year, - payday: toDateString(payd), - period_start: toDateString(start), - period_end: toDateString(end), - label: period.label, - employees_overview, - } - }; - } - - async getSupervisor(email: string) { - return this.prisma.employees.findFirst({ - where: { user: { email } }, - select: { id: true, is_supervisor: true }, - }); - } - - async findAll(): Promise> { - const currentPayYear = payYearOfDate(new Date()); - return { - success: true, - data: listPayYear(currentPayYear).map(period => ({ - pay_period_no: period.period_no, - pay_year: period.pay_year, - payday: period.payday, - period_start: period.period_start, - period_end: period.period_end, - label: period.label, - })) - }; - } - - async findOne(period_no: number): Promise> { - const row = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no: period_no }, - orderBy: { pay_year: "desc" }, - }); - if (!row) return { success: false, error: `PAY_PERIOD_NOT_FOUND` } - return { success: true, data: mapPayPeriodToDto(row) }; - } - - async findCurrent(date?: string): Promise> { - const iso_day = date ?? new Date().toISOString().slice(0, 10); - const pay_period = await this.findByDate(iso_day); - if (!pay_period.success) return { success: false, error: 'INVALID_PAY_PERIOD' } - return { success: true, data: pay_period.data } - } + constructor( + private readonly prisma: PrismaService) { } async findOneByYearPeriod(pay_year: number, period_no: number): Promise> { const row = await this.prisma.payPeriods.findFirst({ @@ -469,20 +57,4 @@ export class PayPeriodsQueryService { } } } - - async getPeriodWindow(pay_year: number, period_no: number) { - return this.prisma.payPeriods.findFirst({ - where: { pay_year, pay_period_no: period_no }, - select: { period_start: true, period_end: true }, - }); - } - - private checkForInactiveDate = (last_work_day: Date) => { - const limit = new Date(last_work_day); - limit.setDate(limit.getDate() + 14); - if (limit >= new Date()) { - return true; - } - return false; - } } diff --git a/src/time-and-attendance/time-and-attendance.module.ts b/src/time-and-attendance/time-and-attendance.module.ts index bdc20fd..767e06f 100644 --- a/src/time-and-attendance/time-and-attendance.module.ts +++ b/src/time-and-attendance/time-and-attendance.module.ts @@ -17,6 +17,7 @@ import { EmployeeTimesheetResolver } from "src/common/mappers/timesheet.mapper"; import { PayperiodsModule } from "src/time-and-attendance/pay-period/pay-periods.module"; import { PayPeriodsController } from "src/time-and-attendance/pay-period/pay-periods.controller"; +import { GetOverviewService } from "src/time-and-attendance/pay-period/services/pay-periods-build-overview.service"; import { PayPeriodsQueryService } from "src/time-and-attendance/pay-period/services/pay-periods-query.service"; import { PayPeriodsCommandService } from "src/time-and-attendance/pay-period/services/pay-periods-command.service"; @@ -73,6 +74,7 @@ import { SchedulePresetsApplyService } from "src/time-and-attendance/schedule-pr BankCodesResolver, TimesheetApprovalService, EmployeeTimesheetResolver, + GetOverviewService, PayPeriodsQueryService, PayPeriodsCommandService, CsvExportService, diff --git a/src/time-and-attendance/utils/selects.utils.ts b/src/time-and-attendance/utils/selects.utils.ts index 2e40431..eec4c0b 100644 --- a/src/time-and-attendance/utils/selects.utils.ts +++ b/src/time-and-attendance/utils/selects.utils.ts @@ -13,7 +13,7 @@ export const expense_select = { comment: true, supervisor_comment: true, is_approved: true, - + } satisfies Prisma.ExpensesSelect; export const shift_select = { @@ -61,4 +61,25 @@ export const timesheet_select = { expense: true, start_date: true, is_approved: true, -} satisfies Prisma.TimesheetsSelect; \ No newline at end of file +} satisfies Prisma.TimesheetsSelect; + +export const select_employee_timesheet = { + timesheet: { + select: { + id: true, + is_approved: true, + employee: { + select: { + id: true, + user: { + select: { + first_name: true, + last_name: true, + email: true, + }, + }, + }, + }, + }, + }, +}; \ No newline at end of file From d613d97a1aa130eed90391a0b913d3c7a31abd7a Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 23 Dec 2025 14:22:28 -0500 Subject: [PATCH 3/5] fix(pay-period): small fix to query --- .../pay-period/services/pay-periods-build-overview.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts b/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts index c83447f..023addc 100644 --- a/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts +++ b/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts @@ -30,7 +30,7 @@ export class GetOverviewService { const employee_overviews = await this.prisma.employees.findMany({ where: { OR: [ - { last_work_day: { gte: overview.period_start } }, + { last_work_day: { gte: toDateFromString(overview.period_start) } }, { last_work_day: null }, ] }, @@ -56,7 +56,7 @@ export class GetOverviewService { }, timesheet: { where: { - start_date: { gte: overview.period_start, lte: overview.period_end }, + start_date: { gte: toDateFromString(overview.period_start), lte: toDateFromString(overview.period_end) }, }, select: { id: true, From 6adbcad3523ff897f254113ba4a22c00619a1072 Mon Sep 17 00:00:00 2001 From: "Nic D." <91558719+Venti-Bear@users.noreply.github.com> Date: Mon, 29 Dec 2025 06:53:16 -0500 Subject: [PATCH 4/5] ???(???): holiday work, forget what it was. Probably related to timesheet approval and its filters --- .../dtos/overview-pay-period.dto.ts | 10 +++- .../pay-periods-build-overview.service.ts | 51 ++++++++++++++----- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts b/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts index 6dd4ebf..34a1a51 100644 --- a/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts +++ b/src/time-and-attendance/pay-period/dtos/overview-pay-period.dto.ts @@ -10,7 +10,13 @@ export class PayPeriodOverviewDto { export class EmployeePeriodOverviewDto { email: string; - employee_name: string; + employee_first_name: string; + employee_last_name: string; + supervisor?: { + first_name: string; + last_name: string; + email: string; + } | null; is_active: boolean; regular_hours: number; other_hours: { @@ -53,5 +59,5 @@ export type Overview = { export type options = { filtered_employee_ids?: number[]; - seed_names?: Map + seed_names?: Map } \ No newline at end of file diff --git a/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts b/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts index 023addc..fca6fad 100644 --- a/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts +++ b/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts @@ -50,6 +50,7 @@ export class GetOverviewService { select: { first_name: true, last_name: true, + email: true, }, }, }, @@ -84,27 +85,36 @@ export class GetOverviewService { // seed for employee without data if (overview.options?.seed_names) { - for (const [id, { name, email }] of overview.options.seed_names.entries()) { - by_employee.set(id, this.createEmployeeSeeds(email, name)); + for (const [id, { first_name, last_name, email }] of overview.options.seed_names.entries()) { + by_employee.set(id, this.createEmployeeSeeds(email, first_name, last_name)); } } else { for (const employee of employee_overviews) { - const name = `${employee.user.first_name} ${employee.user.last_name}`; - const record = this.createEmployeeSeeds(employee.user.email, name); + const record = this.createEmployeeSeeds( + employee.user.email, + employee.user.first_name, + employee.user.last_name, + employee.supervisor?.user ?? null, + ); by_employee.set(employee.id, record); } } - const ensure = (id: number, name: string, email: string) => { + const ensure = (id: number, first_name: string, last_name: string, email: string) => { if (!by_employee.has(id)) { - by_employee.set(id, this.createEmployeeSeeds(email, name)); + by_employee.set(id, this.createEmployeeSeeds(email, first_name, last_name)); } return by_employee.get(id)!; }; for (const employee of employee_overviews) { - const name = `${employee.user.first_name} ${employee.user.last_name}`.trim(); - const record = ensure(employee.id, name, employee.user.email); + const record = ensure( + employee.id, + employee.user.first_name, + employee.user.last_name, + employee.user.email + ); + for (const timesheet of employee.timesheet) { //totals by types for shifts for (const shift of timesheet.shift) { @@ -153,11 +163,17 @@ export class GetOverviewService { if (!record) continue; const timesheets = employee.timesheet; const has_data = timesheets.some(timesheet => timesheet.shift.length > 0 || timesheet.expense.length > 0); + + const cutoff_date = new Date(); + cutoff_date.setDate(cutoff_date.getDate() + 14); + const is_active = employee.last_work_day ? employee.last_work_day.getTime() >= cutoff_date.getTime() : true; + record.is_approved = has_data && timesheets.every(timesheet => timesheet.is_approved === true); + record.is_active = is_active; } const employees_overview = Array.from(by_employee.values()).sort((a, b) => - a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), + a.employee_first_name.localeCompare(b.employee_first_name, "fr", { sensitivity: "base" }), ); return { @@ -174,10 +190,21 @@ export class GetOverviewService { }; } - createEmployeeSeeds = (email: string, employee_name: string, is_active = true): EmployeePeriodOverviewDto => ({ + createEmployeeSeeds = ( + email: string, + employee_first_name: string, + employee_last_name: string, + supervisor: { + first_name: string; + last_name:string; + email: string; + } | null = null, + ): EmployeePeriodOverviewDto => ({ email, - employee_name, - is_active, + employee_first_name, + employee_last_name, + supervisor: supervisor ?? null, + is_active: true, regular_hours: 0, other_hours: { evening_hours: 0, From 03e42ce8d7ab4d2eacef56c7b25bbd342a3eb7f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9rick=20Pruneau?= Date: Mon, 29 Dec 2025 08:42:12 -0500 Subject: [PATCH 5/5] test pour le git pull --- Dockerfile | 93 +++++++++++++++++++++++++++--------------------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/Dockerfile b/Dockerfile index 080b450..c6a68b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,46 +1,47 @@ -# Use the official Node.js image as the base image -FROM node:22 - -# Set the working directory inside the container -WORKDIR /app - -# Set the environment variables -ENV DATABASE_URL_PROD="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db?schema=public" -ENV DATABASE_URL_STAGING="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db_staging?schema=public" -ENV DATABASE_URL_DEV="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db_dev?schema=public" - -ENV AUTHENTIK_ISSUER="https://auth.targo.ca/application/o/montargo/" -ENV AUTHENTIK_CLIENT_ID="KUmSmvpu2aDDy4JfNwas7XriNFtPcj2Ka2PyLO5v" -ENV AUTHENTIK_CLIENT_SECRET="N55BgX1mxT7eiY99LOo5zXr5cKz9FgTsaCA9MdC7D8ZuhOGqozvqtNXVGbpY1eCg2kkYwJeJLP89sQ8R4cYybIJI7EwKijb19bzZQpUPwBosWwG3irUwdTnZOyw8yW5i" -ENV AUTHENTIK_CALLBACK_URL="http://10.100.251.2:3420/auth/callback" -ENV AUTHENTIK_AUTH_URL="https://auth.targo.ca/application/o/authorize/" -ENV AUTHENTIK_TOKEN_URL="https://auth.targo.ca/application/o/token/" -ENV AUTHENTIK_USERINFO_URL="https://auth.targo.ca/application/o/userinfo/" - -ENV TARGO_FRONTEND_URI="http://10.100.251.2/" - -ENV ATTACHMENTS_SERVER_ID="server" -ENV ATTACHMENTS_ROOT=C:/ -ENV MAX_UPLOAD_MB=25 -ENV ALLOWED_MIME=image/jpeg,image/png,image/webp,application/pdf - -# Copy package.json and package-lock.json to the working directory -COPY package*.json ./ - -# Install the application dependencies -RUN npm install - -# Copy the rest of the application files -COPY . . - -# Generate Prisma client -RUN npx prisma generate - -# Build the NestJS application -RUN npm run build - -# Expose the application port -EXPOSE 3000 - -# Command to run the application -CMD ["node", "dist/src/main"] +# Use the official Node.js image as the base image +FROM node:22 + +# Set the working directory inside the container +WORKDIR /app + +#Commentaire de fred pour faire un test +# Set the environment variables +ENV DATABASE_URL_PROD="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db?schema=public" +ENV DATABASE_URL_STAGING="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db_staging?schema=public" +ENV DATABASE_URL_DEV="postgresql://apptargo:6wLAZrb0HZnd3mrmqXiArPcqLyui0o9e@10.100.0.116/app_targo_db_dev?schema=public" + +ENV AUTHENTIK_ISSUER="https://auth.targo.ca/application/o/montargo/" +ENV AUTHENTIK_CLIENT_ID="KUmSmvpu2aDDy4JfNwas7XriNFtPcj2Ka2PyLO5v" +ENV AUTHENTIK_CLIENT_SECRET="N55BgX1mxT7eiY99LOo5zXr5cKz9FgTsaCA9MdC7D8ZuhOGqozvqtNXVGbpY1eCg2kkYwJeJLP89sQ8R4cYybIJI7EwKijb19bzZQpUPwBosWwG3irUwdTnZOyw8yW5i" +ENV AUTHENTIK_CALLBACK_URL="http://10.100.251.2:3420/auth/callback" +ENV AUTHENTIK_AUTH_URL="https://auth.targo.ca/application/o/authorize/" +ENV AUTHENTIK_TOKEN_URL="https://auth.targo.ca/application/o/token/" +ENV AUTHENTIK_USERINFO_URL="https://auth.targo.ca/application/o/userinfo/" + +ENV TARGO_FRONTEND_URI="http://10.100.251.2/" + +ENV ATTACHMENTS_SERVER_ID="server" +ENV ATTACHMENTS_ROOT=C:/ +ENV MAX_UPLOAD_MB=25 +ENV ALLOWED_MIME=image/jpeg,image/png,image/webp,application/pdf + +# Copy package.json and package-lock.json to the working directory +COPY package*.json ./ + +# Install the application dependencies +RUN npm install + +# Copy the rest of the application files +COPY . . + +# Generate Prisma client +RUN npx prisma generate + +# Build the NestJS application +RUN npm run build + +# Expose the application port +EXPOSE 3000 + +# Command to run the application +CMD ["node", "dist/src/main"]