diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index edabfa3..5b19dfa 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -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 (1–26)" + }, + "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", diff --git a/src/modules/notifications/controllers/notifications.controller.ts b/src/modules/notifications/controllers/notifications.controller.ts index e715105..a12c366 100644 --- a/src/modules/notifications/controllers/notifications.controller.ts +++ b/src/modules/notifications/controllers/notifications.controller.ts @@ -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) {} diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts index 704cdba..787a023 100644 --- a/src/modules/notifications/notifications.module.ts +++ b/src/modules/notifications/notifications.module.ts @@ -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], diff --git a/src/modules/pay-periods/controllers/pay-periods.controller.ts b/src/modules/pay-periods/controllers/pay-periods.controller.ts index 9579b7e..e680fda 100644 --- a/src/modules/pay-periods/controllers/pay-periods.controller.ts +++ b/src/modules/pay-periods/controllers/pay-periods.controller.ts @@ -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 { + @ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true }) + 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(':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 { + 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 { - 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 { + @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 { + 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); + } + } diff --git a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts index 008ba96..3416733 100644 --- a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts +++ b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts @@ -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; -} \ No newline at end of file +} diff --git a/src/modules/pay-periods/dtos/overview-pay-period.dto.ts b/src/modules/pay-periods/dtos/overview-pay-period.dto.ts index 6c1b4b7..81399f0 100644 --- a/src/modules/pay-periods/dtos/overview-pay-period.dto.ts +++ b/src/modules/pay-periods/dtos/overview-pay-period.dto.ts @@ -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 (1–26)' }) 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[]; -} \ No newline at end of file +} diff --git a/src/modules/pay-periods/dtos/pay-period.dto.ts b/src/modules/pay-periods/dtos/pay-period.dto.ts new file mode 100644 index 0000000..a7e31ba --- /dev/null +++ b/src/modules/pay-periods/dtos/pay-period.dto.ts @@ -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; +} \ No newline at end of file diff --git a/src/modules/pay-periods/dtos/swagger-entities/pay-period.entity.ts b/src/modules/pay-periods/dtos/swagger-entities/pay-period.entity.ts deleted file mode 100644 index dbc5cac..0000000 --- a/src/modules/pay-periods/dtos/swagger-entities/pay-period.entity.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/modules/pay-periods/mappers/pay-periods.mapper.ts b/src/modules/pay-periods/mappers/pay-periods.mapper.ts new file mode 100644 index 0000000..5ff1ecf --- /dev/null +++ b/src/modules/pay-periods/mappers/pay-periods.mapper.ts @@ -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); +} diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index 3d8ce3a..6f31da0 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -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 {} \ No newline at end of file diff --git a/src/modules/pay-periods/services/pay-periods-approval.service.ts b/src/modules/pay-periods/services/pay-periods-command.service.ts similarity index 88% rename from src/modules/pay-periods/services/pay-periods-approval.service.ts rename to src/modules/pay-periods/services/pay-periods-command.service.ts index 66d080b..dd7bd22 100644 --- a/src/modules/pay-periods/services/pay-periods-approval.service.ts +++ b/src/modules/pay-periods/services/pay-periods-command.service.ts @@ -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 { + async approvalPayPeriod(year: number , periodNumber: number): Promise { const period = await this.prisma.payPeriods.findUnique({ where: { period_number: periodNumber }, }); diff --git a/src/modules/pay-periods/services/pay-periods-overview.service.ts b/src/modules/pay-periods/services/pay-periods-overview.service.ts deleted file mode 100644 index 3fc984c..0000000 --- a/src/modules/pay-periods/services/pay-periods-overview.service.ts +++ /dev/null @@ -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 { - //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 = 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()), - }; - } -} \ No newline at end of file diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts new file mode 100644 index 0000000..5dda386 --- /dev/null +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -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 { + 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 { + 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 }, + ): 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 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(); + + // 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 { + // 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(crew.map(c => [c.id, `${c.first_name} ${c.last_name}`.trim()])); + + // 4) Construire l’overview filtré par ce crew + return this.buildOverview(period, { restrictEmployeeIds: crewIds, seedNames }); + } + + private async resolveCrew(supervisorId: number, includeSubtree: boolean): Promise> { + 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; + } +} diff --git a/src/modules/pay-periods/services/pay-periods.service.ts b/src/modules/pay-periods/services/pay-periods.service.ts index cca3068..fc1440b 100644 --- a/src/modules/pay-periods/services/pay-periods.service.ts +++ b/src/modules/pay-periods/services/pay-periods.service.ts @@ -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 { - return this.prisma.payPeriods.findMany({ - orderBy: { period_number: 'asc'}, + async findAll(): Promise { + const rows = await this.prisma.payPeriods.findMany({ + orderBy: [{ year: 'desc'}, { period_number: "asc"}], }); + return mapMany(rows); } - async findOne(periodNumber: number): Promise { - return this.prisma.payPeriods.findUnique({ - where: { period_number: periodNumber}, + async findOne(periodNumber: number): Promise { + 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 { + 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 { + 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 }, - }, + 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; - } - - @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`}; - } - - + if (!row) throw new NotFoundException(`No period found for this date: ${date}`); + return mapPayPeriodToDto(row); + } } \ No newline at end of file diff --git a/src/modules/shifts/shifts.module.ts b/src/modules/shifts/shifts.module.ts index 2f55b80..f4bf24e 100644 --- a/src/modules/shifts/shifts.module.ts +++ b/src/modules/shifts/shifts.module.ts @@ -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 {}