refactor(pay-period): refactor module to match required needs

This commit is contained in:
Matthieu Haineault 2025-08-11 09:03:28 -04:00
parent 91ef6685b4
commit 91b718237d
15 changed files with 700 additions and 256 deletions

View File

@ -545,6 +545,34 @@
]
}
},
"/timesheets/{id}/approval": {
"patch": {
"operationId": "TimesheetsController_approve",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Timesheets"
]
}
},
"/Expenses": {
"post": {
"operationId": "ExpensesController_create",
@ -1003,6 +1031,34 @@
]
}
},
"/notifications/summary": {
"get": {
"operationId": "NotificationsController_summary",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Notifications"
]
}
},
"/notifications/stream": {
"get": {
"operationId": "NotificationsController_stream",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Notifications"
]
}
},
"/leave-requests": {
"post": {
"operationId": "LeaveRequestController_create",
@ -1197,6 +1253,34 @@
]
}
},
"/leave-requests/{id}/approval": {
"patch": {
"operationId": "LeaveRequestController_updateApproval",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Leave Requests"
]
}
},
"/auth/v1/login": {
"get": {
"operationId": "AuthController_login",
@ -1767,14 +1851,11 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PayPeriodEntity"
"$ref": "#/components/schemas/PayPeriodDto"
}
}
}
}
},
"400": {
"description": "List of pay period not found"
}
},
"summary": "Find all pay period",
@ -1783,49 +1864,26 @@
]
}
},
"/pay-periods/{periodNumber}": {
"/pay-periods/{year}/{periodNumber}/overview": {
"get": {
"operationId": "PayPeriodsController_findOne",
"operationId": "PayPeriodsController_getOverviewByYear",
"parameters": [
{
"name": "periodNumber",
"name": "year",
"required": true,
"in": "path",
"schema": {
"example": 2024,
"type": "number"
}
}
],
"responses": {
"200": {
"description": "Pay period found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodEntity"
}
}
}
},
"400": {
"description": "Pay period not found"
}
},
"summary": "Find pay period",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/{periodNumber}/overview": {
"get": {
"operationId": "PayPeriodsController_getOverview",
"parameters": [
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
}
}
@ -1841,11 +1899,56 @@
}
}
},
"400": {
"404": {
"description": "Pay period not found"
}
},
"summary": "detailed view of a pay period",
"summary": "Detailed view of a pay period by year + number",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/{year}/{periodNumber}": {
"get": {
"operationId": "PayPeriodsController_findOneByYear",
"parameters": [
{
"name": "year",
"required": true,
"in": "path",
"schema": {
"example": 2024,
"type": "number"
}
},
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
}
}
],
"responses": {
"200": {
"description": "Pay period found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodDto"
}
}
}
},
"404": {
"description": "Pay period not found"
}
},
"summary": "Find pay period by year and period number",
"tags": [
"pay-periods"
]
@ -1870,16 +1973,106 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodEntity"
"$ref": "#/components/schemas/PayPeriodDto"
}
}
}
},
"400": {
"description": "Pay period not found for the selected date date"
"404": {
"description": "Pay period not found for the selected date"
}
},
"summary": "cherry picking a date to find a period",
"summary": "Resolve a period by a date within it",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/{year}/{periodNumber}/approval": {
"patch": {
"operationId": "PayPeriodsController_approve",
"parameters": [
{
"name": "year",
"required": true,
"in": "path",
"schema": {
"example": 2024,
"type": "number"
}
},
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
}
}
],
"responses": {
"200": {
"description": "Pay period approved"
}
},
"summary": "Approve all timesheets with activity in the period",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/{year}/{periodNumber}/crew-overview": {
"get": {
"operationId": "PayPeriodsController_getCrewOverview",
"parameters": [
{
"name": "year",
"required": true,
"in": "path",
"schema": {
"example": 2024,
"type": "number"
}
},
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
}
},
{
"name": "includeSubtree",
"required": false,
"in": "query",
"description": "Include indirect reports",
"schema": {
"example": false,
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "Crew overview",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodOverviewDto"
}
}
}
},
"404": {
"description": "Pay period not found"
}
},
"summary": "Supervisor crew overview for a given pay period",
"tags": [
"pay-periods"
]
@ -2725,7 +2918,7 @@
}
}
},
"PayPeriodEntity": {
"PayPeriodDto": {
"type": "object",
"properties": {
"period_number": {
@ -2764,30 +2957,60 @@
"type": "object",
"properties": {
"employee_id": {
"type": "string",
"example": "a1b2c3d4",
"description": "Employee`s ID"
"type": "number",
"example": 42,
"description": "Employees.id (clé primaire num.)"
},
"employee_name": {
"type": "string",
"example": "Alex Dupont",
"description": "Employee`s full name"
"description": "Nom complet de lemployé"
},
"total_hours": {
"regular_hours": {
"type": "number",
"example": 34,
"description": "period`s total worked hours"
"example": 40,
"description": "pay-period`s regular hours"
},
"evening_hours": {
"type": "number",
"example": 0,
"description": "pay-period`s evening hours"
},
"emergency_hours": {
"type": "number",
"example": 0,
"description": "pay-period`s emergency hours"
},
"overtime_hours": {
"type": "number",
"example": 2,
"description": "pay-period`s overtime hours"
},
"expenses": {
"type": "number",
"example": 420.69,
"description": "pay-period`s total expenses ($)"
},
"mileage": {
"type": "number",
"example": 40,
"description": "pay-period total mileages (km)"
},
"is_approved": {
"type": "boolean",
"example": true,
"description": "All timesheets are approved for this employee"
"description": "Tous les timesheets de la période sont approuvés pour cet employé"
}
},
"required": [
"employee_id",
"employee_name",
"total_hours",
"regular_hours",
"evening_hours",
"emergency_hours",
"overtime_hours",
"expenses",
"mileage",
"is_approved"
]
},
@ -2797,27 +3020,32 @@
"period_number": {
"type": "number",
"example": 1,
"description": "period`s number ( 1-26 )"
"description": "Period number (126)"
},
"year": {
"type": "number",
"example": 2023,
"description": "Calendar year of the period"
},
"start_date": {
"type": "string",
"example": "2023-12-17",
"format": "date",
"description": "Period`s starting date"
"description": "Period start date (YYYY-MM-DD)"
},
"end_date": {
"type": "string",
"example": "2023-12-30",
"format": "date",
"description": "Period`s ending date"
"description": "Period end date (YYYY-MM-DD)"
},
"label": {
"type": "string",
"example": "2023-12-17 → 2023-12-30",
"description": "period`s label for showing"
"description": "Human-readable label"
},
"employees_overview": {
"description": "Detailed view by employee for a chosen period",
"description": "Per-employee overview for the period",
"type": "array",
"items": {
"$ref": "#/components/schemas/EmployeePeriodOverviewDto"
@ -2826,6 +3054,7 @@
},
"required": [
"period_number",
"year",
"start_date",
"end_date",
"label",

View File

@ -1,11 +1,9 @@
import { Controller, Get, Req, Sse, UseGuards,
import { Controller, Get, Req, Sse,
MessageEvent as NestMessageEvent } from "@nestjs/common";
import { JwtAuthGuard } from "../../authentication/guards/jwt-auth.guard";
import { NotificationsService } from "../services/notifications.service";
import { Observable } from "rxjs";
import { map } from 'rxjs/operators';
@UseGuards(JwtAuthGuard)
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}

View File

@ -1,7 +1,6 @@
import { Module } from "@nestjs/common";
import { NotificationsController } from "./controllers/notifications.controller";
import { NotificationsService } from "./services/notifications.service";
@Module({
providers: [NotificationsService],
controllers: [NotificationsController],

View File

@ -1,52 +1,103 @@
import { Controller, Get, Param, ParseIntPipe } from "@nestjs/common";
import { PayPeriods } from "@prisma/client";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { Controller, ForbiddenException, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query } from "@nestjs/common";
import { ApiNotFoundResponse, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "@nestjs/swagger";
import { PayPeriodsService } from "../services/pay-periods.service";
import { PayPeriodEntity } from "../dtos/swagger-entities/pay-period.entity";
import { PayPeriodDto } from "../dtos/pay-period.dto";
import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto";
import { PayPeriodsOverviewService } from "../services/pay-periods-overview.service";
import { PayPeriodsQueryService } from "../services/pay-periods-query.service";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client';
import { Req } from '@nestjs/common';
import { Request } from 'express';
import { PayPeriodsCommandService } from "../services/pay-periods-command.service";
@ApiTags('pay-periods')
@Controller('pay-periods')
export class PayPeriodsController {
constructor(
private readonly payPeriodsService: PayPeriodsService,
private readonly overviewService: PayPeriodsOverviewService
private readonly payPeriodsService: PayPeriodsService,
private readonly queryService: PayPeriodsQueryService,
private readonly commandService: PayPeriodsCommandService,
) {}
@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<PayPeriods[]> {
@ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true })
async findAll(): Promise<PayPeriodDto[]> {
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<PayPeriods | null> {
return this.payPeriodsService.findOne(periodNumber);
@Get(':year/:periodNumber/overview')
@ApiOperation({ summary: 'Detailed view of a pay period by year + number' })
@ApiParam({ name: 'year', type: Number, example: 2024 })
@ApiParam({ name: 'periodNumber', type: Number, example: 1, description: '1..26' })
@ApiResponse({ status: 200, description: 'Pay period overview found', type: PayPeriodOverviewDto })
@ApiNotFoundResponse({ description: 'Pay period not found' })
async getOverviewByYear(
@Param('year', ParseIntPipe) year: number,
@Param('periodNumber', ParseIntPipe) periodNumber: number,
): Promise<PayPeriodOverviewDto> {
return this.queryService.getOverviewByYearPeriod(year, 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<PayPeriodOverviewDto> {
return this.overviewService.getOverview(periodNumber);
@Get(":year/:periodNumber")
@ApiOperation({ summary: "Find pay period by year and period number" })
@ApiParam({ name: "year", type: Number, example: 2024 })
@ApiParam({ name: "periodNumber", type: Number, example: 1, description: "1..26" })
@ApiResponse({ status: 200, description: "Pay period found", type: PayPeriodDto })
@ApiNotFoundResponse({ description: "Pay period not found" })
async findOneByYear(
@Param("year", ParseIntPipe) year: number,
@Param("periodNumber", ParseIntPipe) periodNumber: number,
) {
return this.payPeriodsService.findOneByYearPeriod(year, 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<PayPeriodEntity> {
@Get("date/:date")
@ApiOperation({ summary: "Resolve a period by a date within it" })
@ApiResponse({ status: 200, description: "Pay period found for the selected date", type: PayPeriodDto })
@ApiNotFoundResponse({ description: "Pay period not found for the selected date" })
async findByDate(@Param("date") date: string) {
return this.payPeriodsService.findByDate(date);
}
@Patch(":year/:periodNumber/approval")
@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: "Approve all timesheets with activity in the period" })
@ApiParam({ name: "year", type: Number, example: 2024 })
@ApiParam({ name: "periodNumber", type: Number, example: 1, description: "1..26" })
@ApiResponse({ status: 200, description: "Pay period approved" })
async approve(
@Param("year", ParseIntPipe) year: number,
@Param("periodNumber", ParseIntPipe) periodNumber: number,
) {
await this.commandService.approvalPayPeriod(year, periodNumber);
return { message: `Pay-period ${year}-${periodNumber} approved` };
}
@Get(':year/:periodNumber/crew-overview')
@RolesAllowed(RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Supervisor crew overview for a given pay period' })
@ApiParam({ name: 'year', type: Number, example: 2024 })
@ApiParam({ name: 'periodNumber', type: Number, example: 1, description: '1..26' })
@ApiQuery({ name: 'includeSubtree', required: false, type: Boolean, example: false, description: 'Include indirect reports' })
@ApiResponse({ status: 200, description: 'Crew overview', type: PayPeriodOverviewDto })
@ApiNotFoundResponse({ description: 'Pay period not found' })
async getCrewOverview(
@Param('year', ParseIntPipe) year: number,
@Param('periodNumber', ParseIntPipe) periodNumber: number,
@Query('includeSubtree', new ParseBoolPipe({ optional: true })) includeSubtree = false,
@Req() req: Request,
): Promise<PayPeriodOverviewDto> {
const rawUser = (req as any).user ?? {};
const userId: string | undefined = rawUser.id ?? rawUser.sub ?? rawUser.userId; //needs ajusting according to passport logic
if (!userId) {
throw new ForbiddenException('Authenticated user not found on request');
}
return this.queryService.getCrewOverview(year, periodNumber, userId, includeSubtree);
}
}

View File

@ -2,26 +2,38 @@ import { ApiProperty } from '@nestjs/swagger';
export class EmployeePeriodOverviewDto {
@ApiProperty({
example: 'a1b2c3d4',
description: "Employee`s ID",
example: 42,
description: "Employees.id (clé primaire num.)",
})
employee_id: string;
employee_id: number;
@ApiProperty({
example: 'Alex Dupont',
description: 'Employee`s full name',
description: 'Nom complet de lemployé',
})
employee_name: string;
@ApiProperty({
example: 34,
description: 'period`s total worked hours',
})
total_hours: number;
@ApiProperty({ example: 40, description: 'pay-period`s regular hours' })
regular_hours: number;
@ApiProperty({ example: 0, description: 'pay-period`s evening hours' })
evening_hours: number;
@ApiProperty({ example: 0, description: 'pay-period`s emergency hours' })
emergency_hours: number;
@ApiProperty({ example: 2, description: 'pay-period`s overtime hours' })
overtime_hours: number;
@ApiProperty({ example: 420.69, description: 'pay-period`s total expenses ($)' })
expenses: number;
@ApiProperty({ example: 40, description: 'pay-period total mileages (km)' })
mileage: number;
@ApiProperty({
example: true,
description: 'All timesheets are approved for this employee',
description: 'Tous les timesheets de la période sont approuvés pour cet employé',
})
is_approved: boolean;
}

View File

@ -2,37 +2,37 @@ import { ApiProperty } from '@nestjs/swagger';
import { EmployeePeriodOverviewDto } from './overview-employee-period.dto';
export class PayPeriodOverviewDto {
@ApiProperty({
example: 1,
description: 'period`s number ( 1-26 )',
})
@ApiProperty({ example: 1, description: 'Period number (126)' })
period_number: number;
@ApiProperty({ example: 2023, description: 'Calendar year of the period' })
year: number;
@ApiProperty({
example: '2023-12-17',
type: String,
format: 'date',
description: 'Period`s starting date',
description: "Period start date (YYYY-MM-DD)",
})
start_date: Date;
start_date: string;
@ApiProperty({
example: '2023-12-30',
type: String,
format: 'date',
description: 'Period`s ending date',
description: "Period end date (YYYY-MM-DD)",
})
end_date: Date;
end_date: string;
@ApiProperty({
example: '2023-12-17 → 2023-12-30',
description: 'period`s label for showing',
description: 'Human-readable label',
})
label: string;
@ApiProperty({
type: [EmployeePeriodOverviewDto],
description: 'Detailed view by employee for a chosen period',
description: 'Per-employee overview for the period',
})
employees_overview: EmployeePeriodOverviewDto[];
}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from "@nestjs/swagger";
export class PayPeriodDto {
@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: String;
@ApiProperty({ example: '2023-12-30',
type: String, format: 'date' })
end_date: String;
@ApiProperty({ example: 2023 })
year: number;
@ApiProperty({ example: '2023-12-17 → 2023-12-30' })
label: string;
}

View File

@ -1,33 +0,0 @@
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;
}

View File

@ -0,0 +1,18 @@
import { PayPeriods } from "@prisma/client";
import { PayPeriodDto } from "../dtos/pay-period.dto";
const toDateString = (d: Date) => d.toISOString().slice(0, 10); // "YYYY-MM-DD"
export function mapPayPeriodToDto(row: PayPeriods): PayPeriodDto {
return {
period_number: row.period_number,
start_date: toDateString(row.start_date),
end_date: toDateString(row.end_date),
year: row.year,
label: row.label,
};
}
export function mapMany(rows: PayPeriods[]): PayPeriodDto[] {
return rows.map(mapPayPeriodToDto);
}

View File

@ -2,17 +2,29 @@ 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";
import { PayPeriodsOverviewService } from "./services/pay-periods-overview.service";
import { PayPeriodsApprovalService } from "./services/pay-periods-approval.service";
import { PayPeriodsCommandService } from "./services/pay-periods-command.service";
import { PayPeriodsQueryService } from "./services/pay-periods-query.service";
import { TimesheetsModule } from "../timesheets/timesheets.module";
import { TimesheetsApprovalService } from "../timesheets/services/timesheets-approval.service";
import { ExpensesApprovalService } from "../expenses/services/expenses-approval.service";
import { ShiftsApprovalService } from "../shifts/services/shifts-approval.service";
@Module({
imports: [PrismaModule],
imports: [PrismaModule, TimesheetsModule],
providers: [
PayPeriodsService,
PayPeriodsOverviewService,
PayPeriodsApprovalService,
PayPeriodsQueryService,
PayPeriodsCommandService,
TimesheetsApprovalService,
ExpensesApprovalService,
ShiftsApprovalService,
],
controllers: [PayPeriodsController],
exports: [
PayPeriodsQueryService,
PayPeriodsCommandService,
PayPeriodsService,
]
})
export class PayperiodsModule {}

View File

@ -1,14 +1,15 @@
import { NotFoundException } from "@nestjs/common";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TimesheetsApprovalService } from "src/modules/timesheets/services/timesheets-approval.service";
import { PrismaService } from "src/prisma/prisma.service";
export class PayPeriodsApprovalService {
@Injectable()
export class PayPeriodsCommandService {
constructor(
private readonly prisma: PrismaService,
private readonly timesheetsApproval: TimesheetsApprovalService,
) {}
async approvaPayperdiod(periodNumber: number): Promise<void> {
async approvalPayPeriod(year: number , periodNumber: number): Promise<void> {
const period = await this.prisma.payPeriods.findUnique({
where: { period_number: periodNumber },
});

View File

@ -1,71 +0,0 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { EmployeePeriodOverviewDto } from "../dtos/overview-employee-period.dto";
import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto";
import { PrismaService } from "src/prisma/prisma.service";
import { computeHours } from "src/common/utils/date-utils";
@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<PayPeriodOverviewDto> {
//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<string, EmployeePeriodOverviewDto>();
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 = computeHours(shift.start_time, shift.end_time);
//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()),
};
}
}

View File

@ -0,0 +1,208 @@
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { computeHours } from "src/common/utils/date-utils";
import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto";
import { EmployeePeriodOverviewDto } from "../dtos/overview-employee-period.dto";
@Injectable()
export class PayPeriodsQueryService {
constructor(private readonly prisma: PrismaService) {}
async getOverview(periodNumber: number): Promise<PayPeriodOverviewDto> {
const period = await this.prisma.payPeriods.findFirst({
where: { period_number: periodNumber },
orderBy: { year: "desc" },
});
if (!period) throw new NotFoundException(`Period #${periodNumber} not found`);
return this.buildOverview(period);
}
async getOverviewByYearPeriod(year: number, periodNumber: number): Promise<PayPeriodOverviewDto> {
const period = await this.prisma.payPeriods.findFirst({
where: { year, period_number: periodNumber },
});
if (!period) throw new NotFoundException(`Period ${year}-${periodNumber} not found`);
return this.buildOverview(period);
}
private async buildOverview(
period: { start_date: Date; end_date: Date; period_number: number; year: number; label: string; },
opts?: { restrictEmployeeIds?: number[]; seedNames?: Map<number, string> },
): Promise<PayPeriodOverviewDto> {
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 whereEmployee = opts?.restrictEmployeeIds?.length
? { employee_id: { in: opts.restrictEmployeeIds } }
: {};
// SHIFTS (filtrés par crew si besoin)
const shifts = await this.prisma.shifts.findMany({
where: {
date: { gte: period.start_date, lte: period.end_date },
timesheet: whereEmployee,
},
select: {
start_time: true,
end_time: true,
timesheet: {
select: {
is_approved: true,
employee: { select: { id: true, user: { select: { first_name: true, last_name: true } } } },
},
},
bank_code: { select: { categorie: true } },
},
});
// EXPENSES (filtrés par crew si besoin)
const expenses = await this.prisma.expenses.findMany({
where: {
date: { gte: period.start_date, lte: period.end_date },
timesheet: whereEmployee,
},
select: {
amount: true,
timesheet: {
select: {
is_approved: true,
employee: { select: { id: true, user: { select: { first_name: true, last_name: true } } } },
},
},
bank_code: { select: { categorie: true, modifier: true } },
},
});
// Agrégation
const byEmployee = new Map<number, EmployeePeriodOverviewDto>();
// seed pour employés sans données
if (opts?.seedNames) {
for (const [id, name] of opts.seedNames.entries()) {
byEmployee.set(id, {
employee_id: id,
employee_name: name,
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
expenses: 0,
mileage: 0,
is_approved: true,
});
}
}
const ensure = (id: number, name: string) => {
if (!byEmployee.has(id)) {
byEmployee.set(id, {
employee_id: id,
employee_name: name,
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
expenses: 0,
mileage: 0,
is_approved: true,
});
}
return byEmployee.get(id)!;
};
for (const s of shifts) {
const e = s.timesheet.employee;
const name = `${e.user.first_name} ${e.user.last_name}`.trim();
const rec = ensure(e.id, name);
const hours = computeHours(s.start_time, s.end_time);
const cat = (s.bank_code?.categorie || "REGULAR").toUpperCase();
switch (cat) {
case "EVENING": rec.evening_hours += hours; break;
case "EMERGENCY":
case "URGENT": rec.emergency_hours += hours; break;
case "OVERTIME": rec.overtime_hours += hours; break;
default: rec.regular_hours += hours; break;
}
rec.is_approved = rec.is_approved && s.timesheet.is_approved;
}
for (const ex of expenses) {
const e = ex.timesheet.employee;
const name = `${e.user.first_name} ${e.user.last_name}`.trim();
const rec = ensure(e.id, name);
const amount = toMoney(ex.amount);
rec.expenses += amount;
const cat = (ex.bank_code?.categorie || "").toUpperCase();
const rate = ex.bank_code?.modifier ?? 0;
if (cat === "MILEAGE" && rate > 0) {
rec.mileage += amount / rate;
}
rec.is_approved = rec.is_approved && ex.timesheet.is_approved;
}
const employees_overview = Array.from(byEmployee.values()).sort((a, b) =>
a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }),
);
return {
period_number: period.period_number,
year: period.year,
start_date: toDateString(period.start_date),
end_date: toDateString(period.end_date),
label: period.label,
employees_overview,
};
}
async getCrewOverview(year: number, periodNumber: number, userId: string, includeSubtree: boolean): Promise<PayPeriodOverviewDto> {
// 1) Trouver la période
const period = await this.prisma.payPeriods.findFirst({ where: { year, period_number: periodNumber } });
if (!period) throw new NotFoundException(`Pay period ${year}-${periodNumber} not found`);
// 2) Résoudre l'employé superviseur depuis l'utilisateur courant (Users.id -> Employees)
const supervisor = await this.prisma.employees.findUnique({
where: { user_id: userId },
select: { id: true },
});
if (!supervisor) throw new ForbiddenException('No employee record linked to current user');
// 3) Récupérer la liste des employés du crew (directs ou sous-arbo complète)
const crew = await this.resolveCrew(supervisor.id, includeSubtree); // [{ id, first_name, last_name }]
const crewIds = crew.map(c => c.id);
// seed names map for employés sans données
const seedNames = new Map<number, string>(crew.map(c => [c.id, `${c.first_name} ${c.last_name}`.trim()]));
// 4) Construire loverview filtré par ce crew
return this.buildOverview(period, { restrictEmployeeIds: crewIds, seedNames });
}
private async resolveCrew(supervisorId: number, includeSubtree: boolean): Promise<Array<{ id: number; first_name: string; last_name: string }>> {
const result: Array<{ id: number; first_name: string; last_name: string }> = [];
// niveau 1 (directs)
let frontier = await this.prisma.employees.findMany({
where: { supervisor_id: supervisorId },
select: { id: true, user: { select: { first_name: true, last_name: true } } },
});
result.push(...frontier.map(e => ({ id: e.id, first_name: e.user.first_name, last_name: e.user.last_name })));
if (!includeSubtree) return result;
// BFS pour les niveaux suivants
while (frontier.length) {
const parentIds = frontier.map(e => e.id);
const next = await this.prisma.employees.findMany({
where: { supervisor_id: { in: parentIds } },
select: { id: true, user: { select: { first_name: true, last_name: true } } },
});
if (next.length === 0) break;
result.push(...next.map(e => ({ id: e.id, first_name: e.user.first_name, last_name: e.user.last_name })));
frontier = next;
}
return result;
}
}

View File

@ -1,49 +1,47 @@
import { Injectable, NotFoundException, Param, ParseIntPipe, Patch } from "@nestjs/common";
import { PayPeriods } from "@prisma/client";
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { PayPeriodsApprovalService } from "./pay-periods-approval.service";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client';
import { PayPeriodsCommandService } from "./pay-periods-command.service";
import { mapMany, mapPayPeriodToDto } from "../mappers/pay-periods.mapper";
import { PayPeriodDto } from "../dtos/pay-period.dto";
@Injectable()
export class PayPeriodsService {
constructor(private readonly prisma: PrismaService,
private readonly payperiodsApprovalService: PayPeriodsApprovalService
private readonly payperiodsApprovalService: PayPeriodsCommandService
) {}
async findAll(): Promise<PayPeriods[]> {
return this.prisma.payPeriods.findMany({
orderBy: { period_number: 'asc'},
async findAll(): Promise<PayPeriodDto[]> {
const rows = await this.prisma.payPeriods.findMany({
orderBy: [{ year: 'desc'}, { period_number: "asc"}],
});
return mapMany(rows);
}
async findOne(periodNumber: number): Promise<PayPeriods | null> {
return this.prisma.payPeriods.findUnique({
where: { period_number: periodNumber},
async findOne(periodNumber: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({
where: { period_number: periodNumber },
orderBy: { year: "desc" },
});
if (!row) throw new NotFoundException(`Pay period #${periodNumber} not found`);
return mapPayPeriodToDto(row);
}
async findOneByYearPeriod(year: number, periodNumber: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({
where: { year, period_number: periodNumber },
});
if (!row) throw new NotFoundException(`Pay period ${year}-${periodNumber} not found`);
return mapPayPeriodToDto(row);
}
//function to cherry pick a Date to find a period
async findByDate(date:string): Promise<PayPeriods> {
async findByDate(date: string): Promise<PayPeriodDto> {
const dt = new Date(date);
const period = await this.prisma.payPeriods.findFirst({
where: {
start_date: { lte: dt },
end_date: { gte: dt },
},
const row = 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;
if (!row) throw new NotFoundException(`No period found for this date: ${date}`);
return mapPayPeriodToDto(row);
}
@Patch(':periodNumber/approval')
@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
async approve(@Param('periodNumber', ParseIntPipe) periodNumber: number): Promise<{message:string}> {
await this.payperiodsApprovalService.approvaPayperdiod(periodNumber);
return {message: `Pay-period #${periodNumber} approved`};
}
}

View File

@ -5,11 +5,12 @@ import { BusinessLogicsModule } from 'src/modules/business-logics/business-logic
import { ShiftsOverviewController } from './controllers/shifts-overview.controller';
import { ShiftsOverviewService } from './services/shifts-overview.service';
import { ShiftsApprovalService } from './services/shifts-approval.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [BusinessLogicsModule],
imports: [BusinessLogicsModule, NotificationsModule],
controllers: [ShiftsController, ShiftsOverviewController],
providers: [ShiftsService, ShiftsOverviewService, ShiftsApprovalService],
exports: [ShiftsService, ShiftsOverviewService],
exports: [ShiftsService, ShiftsOverviewService, ShiftsApprovalService],
})
export class ShiftsModule {}