From 4d538fc78a8bb277f16419195cb1ba82428ce332 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 25 Jul 2025 15:33:39 -0400 Subject: [PATCH] feat(modules): added pay-periods view module with functions to navigate and search by date --- prisma/schema.prisma | 11 +++ src/app.module.ts | 2 + .../controllers/leave-requests.controller.ts | 2 +- .../controllers/pay-periods.controller.ts | 53 ++++++++++++++ .../dtos/employee-period-overview.dto.ts | 27 +++++++ .../dtos/pay-period-overview.dto.ts | 38 ++++++++++ .../dtos/swagger-entities/pay-period.dto.ts | 33 +++++++++ src/modules/pay-periods/pay-periods.module.ts | 12 ++++ .../services/pay-periods-overview.service.ts | 70 +++++++++++++++++++ .../services/pay-periods.service.ts | 37 ++++++++++ 10 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 src/modules/pay-periods/controllers/pay-periods.controller.ts create mode 100644 src/modules/pay-periods/dtos/employee-period-overview.dto.ts create mode 100644 src/modules/pay-periods/dtos/pay-period-overview.dto.ts create mode 100644 src/modules/pay-periods/dtos/swagger-entities/pay-period.dto.ts create mode 100644 src/modules/pay-periods/pay-periods.module.ts create mode 100644 src/modules/pay-periods/services/pay-periods-overview.service.ts create mode 100644 src/modules/pay-periods/services/pay-periods.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 43c2cda..ad27041 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,6 +66,17 @@ model LeaveRequests { @@map("leave_requests") } +//pay-period vue +model PayPeriods { + period_number Int @id + start_date DateTime + end_date DateTime + year Int + label String + + @@map("pay_period") +} + model Timesheets { id Int @id @default(autoincrement()) employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id]) diff --git a/src/app.module.ts b/src/app.module.ts index 3fe6f7f..ce8cf7b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { TimesheetsModule } from './modules/timesheets/timesheets.module'; import { AuthenticationModule } from './modules/authentication/auth.module'; import { ExpensesModule } from './modules/expenses/expenses.module'; import { ExpenseCodesModule } from './modules/expense-codes/expense-codes.module'; +import { PayperiodModule } from './modules/pay-periods/pay-periods.module'; @Module({ imports: [ @@ -31,6 +32,7 @@ import { ExpenseCodesModule } from './modules/expense-codes/expense-codes.module ShiftsModule, TimesheetsModule, AuthenticationModule, + PayperiodModule, ], controllers: [AppController, HealthController], providers: [AppService], diff --git a/src/modules/leave-requests/controllers/leave-requests.controller.ts b/src/modules/leave-requests/controllers/leave-requests.controller.ts index 6c13ea3..a71c49a 100644 --- a/src/modules/leave-requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave-requests/controllers/leave-requests.controller.ts @@ -29,7 +29,7 @@ export class LeaveRequestController { @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: LeaveRequestEntity, isArray: true }) - @ApiResponse({ status: 400, description: 'Leave request not found' }) + @ApiResponse({ status: 400, description: 'List of leave request not found' }) findAll(): Promise { return this.leaveRequetsService.findAll(); } diff --git a/src/modules/pay-periods/controllers/pay-periods.controller.ts b/src/modules/pay-periods/controllers/pay-periods.controller.ts new file mode 100644 index 0000000..e8cc190 --- /dev/null +++ b/src/modules/pay-periods/controllers/pay-periods.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Get, Param, ParseIntPipe } from "@nestjs/common"; +import { PayPeriods } from "@prisma/client"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { PayPeriodsService } from "../services/pay-periods.service"; +import { PayPeriodsOverviewService } from "../services/pay-periods-overview.service"; +import { PayPeriodEntity } from "../dtos/swagger-entities/pay-period.dto"; +import { PayPeriodOverviewDto } from "../dtos/pay-period-overview.dto"; + + +@ApiTags('pay-periods') +@Controller('pay-periods') +export class PayPeriodsController { + + constructor( + private readonly payPeriodsService: PayPeriodsService, + private readonly overviewService: PayPeriodsOverviewService + ) {} + + @Get() + @ApiOperation({ summary: 'Find all pay period' }) + @ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodEntity, isArray: true }) + @ApiResponse({status: 400, description: 'List of pay period not found' }) + async findAll(): Promise { + return this.payPeriodsService.findAll(); + } + + @Get(':periodNumber') + @ApiOperation({ summary: 'Find pay period' }) + @ApiResponse({status: 200,description: 'Pay period found', type: PayPeriodEntity }) + @ApiResponse({status: 400, description: 'Pay period not found' }) + findOne(@Param('periodNumber', ParseIntPipe) periodNumber: number): Promise { + return this.payPeriodsService.findOne(periodNumber); + } + + @Get(':periodNumber/overview') + @ApiOperation({ summary: 'detailed view of a pay period'}) + @ApiResponse({ status: 200,description: 'Pay period overview found', type: PayPeriodOverviewDto }) + @ApiResponse({status: 400, description: 'Pay period not found' }) + + async getOverview(@Param('periodNumber', ParseIntPipe) periodNumber: number): + Promise { + return this.overviewService.getOverview(periodNumber); + } + + @Get('date/:date') + @ApiOperation({ summary: 'cherry picking a date to find a period'}) + @ApiResponse({status:200, description: 'Pay period found for the selected date', type: PayPeriodEntity }) + @ApiResponse({status:400, description: 'Pay period not found for the selected date date' }) + async findByDate(@Param('date') date: string ): + Promise { + return this.payPeriodsService.findByDate(date); + } +} diff --git a/src/modules/pay-periods/dtos/employee-period-overview.dto.ts b/src/modules/pay-periods/dtos/employee-period-overview.dto.ts new file mode 100644 index 0000000..008ba96 --- /dev/null +++ b/src/modules/pay-periods/dtos/employee-period-overview.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class EmployeePeriodOverviewDto { + @ApiProperty({ + example: 'a1b2c3d4', + description: "Employee`s ID", + }) + employee_id: string; + + @ApiProperty({ + example: 'Alex Dupont', + description: 'Employee`s full name', + }) + employee_name: string; + + @ApiProperty({ + example: 34, + description: 'period`s total worked hours', + }) + total_hours: number; + + @ApiProperty({ + example: true, + description: 'All timesheets are approved for this employee', + }) + is_approved: boolean; +} \ No newline at end of file diff --git a/src/modules/pay-periods/dtos/pay-period-overview.dto.ts b/src/modules/pay-periods/dtos/pay-period-overview.dto.ts new file mode 100644 index 0000000..cb77164 --- /dev/null +++ b/src/modules/pay-periods/dtos/pay-period-overview.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EmployeePeriodOverviewDto } from './employee-period-overview.dto'; + +export class PayPeriodOverviewDto { + @ApiProperty({ + example: 1, + description: 'period`s number ( 1-26 )', + }) + period_number: number; + + @ApiProperty({ + example: '2023-12-17', + type: String, + format: 'date', + description: 'Period`s starting date', + }) + start_date: Date; + + @ApiProperty({ + example: '2023-12-30', + type: String, + format: 'date', + description: 'Period`s ending date', + }) + end_date: Date; + + @ApiProperty({ + example: '2023-12-17 → 2023-12-30', + description: 'period`s label for showing', + }) + label: string; + + @ApiProperty({ + type: [EmployeePeriodOverviewDto], + description: 'Detailed view by employee for a chosen period', + }) + employees_overview: EmployeePeriodOverviewDto[]; +} \ No newline at end of file diff --git a/src/modules/pay-periods/dtos/swagger-entities/pay-period.dto.ts b/src/modules/pay-periods/dtos/swagger-entities/pay-period.dto.ts new file mode 100644 index 0000000..dbc5cac --- /dev/null +++ b/src/modules/pay-periods/dtos/swagger-entities/pay-period.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class PayPeriodEntity { + @ApiProperty({ + example: 1, + description: 'numéro cyclique de la période entre 1 et 26' + }) + period_number: number; + + @ApiProperty({ + example: '2023-12-17', + type: String, + format: 'date' + }) + start_date: Date; + + @ApiProperty({ + example: '2023-12-30', + type: String, + format: 'date' + }) + end_date: Date; + + @ApiProperty({ + example: 2023 + }) + year: number; + + @ApiProperty({ + example: '2023-12-17 → 2023-12-30' + }) + label: string; +} \ No newline at end of file diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts new file mode 100644 index 0000000..b1b6bfe --- /dev/null +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from "src/prisma/prisma.module"; +import { PayPeriodsService } from "./services/pay-periods.service"; +import { PayPeriodsController } from "./controllers/pay-periods.controller"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [PayPeriodsService], + controllers: [PayPeriodsController], +}) + +export class PayperiodModule {} \ No newline at end of file diff --git a/src/modules/pay-periods/services/pay-periods-overview.service.ts b/src/modules/pay-periods/services/pay-periods-overview.service.ts new file mode 100644 index 0000000..c05144e --- /dev/null +++ b/src/modules/pay-periods/services/pay-periods-overview.service.ts @@ -0,0 +1,70 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { EmployeePeriodOverviewDto } from "../dtos/employee-period-overview.dto"; +import { PayPeriodOverviewDto } from "../dtos/pay-period-overview.dto"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class PayPeriodsOverviewService { + constructor(private readonly prisma: PrismaService) {} + //function to get a full overview of a selected period filtered by employee ID + async getOverview(periodNumber: number): Promise { + //fetch the period + const period = await this.prisma.payPeriods.findUnique({ + where: { period_number: periodNumber }, + }); + if(!period) { + throw new NotFoundException(`Period #${periodNumber} not found`); + } + + //fetch all included shifts for that period + const shifts = await this.prisma.shifts.findMany({ + where: { + date: { + gte: period.start_date, + lte: period.end_date, + }, + }, + include: { + timesheet: { + include: { + employee: { include: { user: true }}, + }, + }, + }, + }); + + //regroup by employee + const map = new Map(); + for (const shift of shifts) { + const employee_record = shift.timesheet.employee; + const user = employee_record.user; + const employee_id = employee_record.user_id; + const employee_name = `${user.first_name} ${user.last_name}`; + const hours = (shift.end_time.getTime() - shift.start_time.getTime() / 3600000); + + //check if employee had prior shifts and adds hours of found shift to the total hours + if (map.has(employee_id)) { + const summary = map.get(employee_id)!; + summary.total_hours += hours; + //keeps is_approved false as long as a single shift is left un-validated + summary.is_approved = summary.is_approved && shift.timesheet.is_approved; + } else { + //if first shift of an employee is found, it adds a new entry + map.set(employee_id, { + employee_id: employee_id, + employee_name: employee_name, + total_hours: hours, + is_approved: shift.timesheet.is_approved, + }); + } + } + + return { + period_number: period.period_number, + start_date: period.start_date, + end_date: period.end_date, + label: period.label, + employees_overview: Array.from(map.values()), + }; + } +} \ No newline at end of file diff --git a/src/modules/pay-periods/services/pay-periods.service.ts b/src/modules/pay-periods/services/pay-periods.service.ts new file mode 100644 index 0000000..81f6091 --- /dev/null +++ b/src/modules/pay-periods/services/pay-periods.service.ts @@ -0,0 +1,37 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PayPeriods } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; +import { PayPeriodOverviewDto } from "../dtos/pay-period-overview.dto"; +import { EmployeePeriodOverviewDto } from "../dtos/employee-period-overview.dto"; + +@Injectable() +export class PayPeriodsService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(): Promise { + return this.prisma.payPeriods.findMany({ + orderBy: { period_number: 'asc'}, + }); + } + + async findOne(periodNumber: number): Promise { + return this.prisma.payPeriods.findUnique({ + where: { period_number: periodNumber}, + }); + } + + //function to cherry pick a Date to find a period + async findByDate(date:string): Promise { + const dt = new Date(date); + const period = await this.prisma.payPeriods.findFirst({ + where: { + start_date: { lte: dt }, + end_date: { gte: dt }, + }, + }); + if(!period) { + throw new NotFoundException(`No period found for this date: ${date}`); + } + return period; + } +} \ No newline at end of file