diff --git a/package-lock.json b/package-lock.json index 5b5f67b..0622bea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^6.14.0", + "prisma": "^6.16.3", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", @@ -3148,9 +3148,9 @@ } }, "node_modules/@prisma/config": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz", - "integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz", + "integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==", "devOptional": true, "dependencies": { "c12": "3.1.0", @@ -3160,48 +3160,48 @@ } }, "node_modules/@prisma/debug": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz", - "integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz", + "integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==", "devOptional": true }, "node_modules/@prisma/engines": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz", - "integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz", + "integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/debug": "6.14.0", - "@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", - "@prisma/fetch-engine": "6.14.0", - "@prisma/get-platform": "6.14.0" + "@prisma/debug": "6.16.3", + "@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", + "@prisma/fetch-engine": "6.16.3", + "@prisma/get-platform": "6.16.3" } }, "node_modules/@prisma/engines-version": { - "version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz", - "integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==", + "version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz", + "integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==", "devOptional": true }, "node_modules/@prisma/fetch-engine": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz", - "integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz", + "integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==", "devOptional": true, "dependencies": { - "@prisma/debug": "6.14.0", - "@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", - "@prisma/get-platform": "6.14.0" + "@prisma/debug": "6.16.3", + "@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", + "@prisma/get-platform": "6.16.3" } }, "node_modules/@prisma/get-platform": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz", - "integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz", + "integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==", "devOptional": true, "dependencies": { - "@prisma/debug": "6.14.0" + "@prisma/debug": "6.16.3" } }, "node_modules/@scarf/scarf": { @@ -9450,15 +9450,15 @@ } }, "node_modules/nypm": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz", - "integrity": "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", "devOptional": true, "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", - "pkg-types": "^2.2.0", + "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { @@ -9967,9 +9967,9 @@ } }, "node_modules/pkg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz", - "integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "devOptional": true, "dependencies": { "confbox": "^0.2.2", @@ -10049,14 +10049,14 @@ } }, "node_modules/prisma": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz", - "integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==", + "version": "6.16.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz", + "integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/config": "6.14.0", - "@prisma/engines": "6.14.0" + "@prisma/config": "6.16.3", + "@prisma/engines": "6.16.3" }, "bin": { "prisma": "build/index.js" diff --git a/package.json b/package.json index db16934..68eb03f 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^6.14.0", + "prisma": "^6.16.3", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fb0414b..b32b202 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,16 +105,19 @@ model LeaveRequests { id Int @id @default(autoincrement()) employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id]) employee_id Int - bank_code BankCodes? @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id]) + bank_code BankCodes @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int leave_type LeaveTypes - start_date_time DateTime @db.Date - end_date_time DateTime? @db.Date + date DateTime @db.Date + payable_hours Decimal? @db.Decimal(5,2) + requested_hours Decimal? @db.Decimal(5,2) comment String approval_status LeaveApprovalStatus @default(PENDING) archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive") + @@unique([employee_id, leave_type, date], name: "leave_per_employee_date") + @@index([employee_id, date]) @@map("leave_requests") } @@ -125,11 +128,14 @@ model LeaveRequestsArchive { archived_at DateTime @default(now()) employee_id Int leave_type LeaveTypes - start_date_time DateTime @db.Date - end_date_time DateTime? @db.Date + date DateTime @db.Date + payable_hours Decimal? @db.Decimal(5,2) + requested_hours Decimal? @db.Decimal(5,2) comment String approval_status LeaveApprovalStatus + @@unique([leave_request_id]) + @@index([employee_id, date]) @@map("leave_requests_archive") } @@ -340,6 +346,7 @@ enum LeaveTypes { PARENTAL // maternite/paternite/adoption LEGAL // obligations legales comme devoir de juree WEDDING // mariage + HOLIDAY // férier @@map("leave_types") } diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index c0c8393..8b8a31b 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -2,6 +2,14 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../../../prisma/prisma.service"; import { computeHours, getWeekStart } from "src/common/utils/date-utils"; +const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000; + +/* + le calcul est 1/20 des 4 dernières semaines, précédent la semaine incluant le férier. + Un maximum de 08h00 est allouable pour le férier + Un maximum de 40hrs par semaine est retenue pour faire le calcul. +*/ + @Injectable() export class HolidayService { private readonly logger = new Logger(HolidayService.name); @@ -11,7 +19,7 @@ export class HolidayService { //fetch employee_id by email private async resolveEmployeeByEmail(email: string): Promise { const employee = await this.prisma.employees.findFirst({ - where: { + where: { user: { email } }, select: { id: true }, @@ -22,36 +30,49 @@ export class HolidayService { private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise { const employee_id = await this.resolveEmployeeByEmail(email); - return this.computeHoursPrevious4Weeks(employee_id, holiday_date) + return this.computeHoursPrevious4Weeks(employee_id, holiday_date); } private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise { - //sets the end of the window to 1ms before the week with the holiday const holiday_week_start = getWeekStart(holiday_date); + const window_start = new Date(holiday_week_start.getTime() - 4 * WEEK_IN_MS); const window_end = new Date(holiday_week_start.getTime() - 1); - //sets the start of the window to 28 days ( 4 completed weeks ) before the week with the holiday - const window_start = new Date(window_end.getTime() - 28 * 24 * 60 * 60000 + 1 ) const valid_codes = ['G1', 'G43', 'G56', 'G104', 'G105', 'G700']; - //fetches all shift of the employee in said window ( 4 previous completed weeks ) const shifts = await this.prisma.shifts.findMany({ - where: { timesheet: { employee_id: employee_id } , - date: { gte: window_start, lte: window_end }, - bank_code: { bank_code: { in: valid_codes } }, + where: { + timesheet: { employee_id: employee_id }, + date: { gte: window_start, lte: window_end }, + bank_code: { bank_code: { in: valid_codes } }, }, select: { date: true, start_time: true, end_time: true }, }); - const total_hours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0); - const daily_hours = total_hours / 20; + const hours_by_week = new Map(); + for(const shift of shifts) { + const hours = computeHours(shift.start_time, shift.end_time); + if(hours <= 0) continue; + const shift_week_start = getWeekStart(shift.date); + const key = shift_week_start.getTime(); + hours_by_week.set(key, (hours_by_week.get(key) ?? 0) + hours); + } - return daily_hours; + let capped_total = 0; + for(let offset = 1; offset <= 4; offset++) { + const week_start = new Date(holiday_week_start.getTime() - offset * WEEK_IN_MS); + const key = week_start.getTime(); + const weekly_hours = hours_by_week.get(key) ?? 0; + capped_total += Math.min(weekly_hours, 40); + } + + const average_daily_hours = capped_total / 20; + return average_daily_hours; } async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise { - const hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date); - const daily_rate = Math.min(hours, 8); - this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`); + const average_daily_hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date); + const daily_rate = Math.min(average_daily_hours, 8); + this.logger.debug(`Holiday pay calculation: cappedHoursPerDay= ${average_daily_hours.toFixed(2)}, appliedDailyRate= ${daily_rate.toFixed(2)}`); return daily_rate * modifier; } } \ No newline at end of file diff --git a/src/modules/leave-requests/controllers/leave-requests.controller.ts b/src/modules/leave-requests/controllers/leave-requests.controller.ts index d3251ad..e6b8e8e 100644 --- a/src/modules/leave-requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave-requests/controllers/leave-requests.controller.ts @@ -1,76 +1,13 @@ -import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; +import { Controller } from "@nestjs/common"; import { LeaveRequestsService } from "../services/leave-requests.service"; -import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto"; -import { LeaveRequests } from "@prisma/client"; -import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto"; -import { RolesAllowed } from "src/common/decorators/roles.decorators"; -import { LeaveApprovalStatus, Roles as RoleEnum } from '.prisma/client'; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; -import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto"; -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; + +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; @ApiTags('Leave Requests') @ApiBearerAuth('access-token') // @UseGuards() @Controller('leave-requests') export class LeaveRequestController { - constructor(private readonly leaveRequetsService: LeaveRequestsService){} - - @Post() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Create leave request' }) - @ApiResponse({ status: 201, description: 'Leave request created',type: CreateLeaveRequestsDto }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateLeaveRequestsDto): Promise { - return this. leaveRequetsService.create(dto); - } - - @Get() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Find all leave request' }) - @ApiResponse({ status: 201, description: 'List of Leave requests found',type: LeaveRequestViewDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of leave request not found' }) - @UsePipes(new ValidationPipe({transform: true, whitelist: true})) - findAll(@Query() filters: SearchLeaveRequestsDto): Promise { - return this.leaveRequetsService.findAll(filters); - } - //remove emp_id and use email - @Get(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Find leave request' }) - @ApiResponse({ status: 201, description: 'Leave request found',type: LeaveRequestViewDto }) - @ApiResponse({ status: 400, description: 'Leave request not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.leaveRequetsService.findOne(id); - } - //remove emp_id and use email - @Patch(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Update leave request' }) - @ApiResponse({ status: 201, description: 'Leave request updated',type: LeaveRequestViewDto }) - @ApiResponse({ status: 400, description: 'Leave request not found' }) - update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateLeaveRequestsDto): Promise { - return this.leaveRequetsService.update(id, dto); - } - - //remove emp_id and use email - @Delete(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Delete leave request' }) - @ApiResponse({ status: 201, description: 'Leave request deleted',type: CreateLeaveRequestsDto }) - @ApiResponse({ status: 400, description: 'Leave request not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.leaveRequetsService.remove(id); - } - - //remove emp_id and use email - @Patch('approval/:id') - updateApproval( @Param('id', ParseIntPipe) id: number, - @Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise { - const approvalStatus = is_approved ? - LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED; - return this.leaveRequetsService.update(id, { approval_status: approvalStatus }); - } - - } + constructor(private readonly leave_service: LeaveRequestsService){} +} diff --git a/src/modules/leave-requests/dtos/create-leave-request.dto.ts b/src/modules/leave-requests/dtos/create-leave-request.dto.ts deleted file mode 100644 index 136c858..0000000 --- a/src/modules/leave-requests/dtos/create-leave-request.dto.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; -import { Type } from "class-transformer"; -import { IsDateString, IsEmail, IsEnum, IsInt, IsISO8601, IsNotEmpty, IsOptional, IsString } from "class-validator"; - -export class CreateLeaveRequestsDto { - - @IsEmail() - email: string; - - @ApiProperty({ - example: 7, - description: 'ID number of a leave-request code (link with bank-codes)', - }) - @Type(()=> Number) - @IsInt() - bank_code_id: number; - - @ApiProperty({ - example: 'Sick or Vacation or Unpaid or Bereavement or Parental or Legal', - description: 'type of leave request for an accounting perception', - }) - @IsEnum(LeaveTypes) - leave_type: LeaveTypes; - - @ApiProperty({ - example: '22/06/2463', - description: 'Leave request`s start date', - }) - @IsISO8601() - start_date_time:string; - - @ApiProperty({ - example: '25/03/3019', - description: 'Leave request`s end date', - }) - @IsOptional() - @IsISO8601() - end_date_time?: string; - - @ApiProperty({ - example: 'My precious', - description: 'Leave request`s comment', - }) - @IsString() - @IsNotEmpty() - comment: string; - - @ApiProperty({ - example: 'True or False or Pending or Denied or Cancelled or Escalated', - description: 'Leave request`s approval status', - }) - @IsEnum(LeaveApprovalStatus) - @IsOptional() - approval_status?: LeaveApprovalStatus; -} diff --git a/src/modules/leave-requests/dtos/leave-request-view.dto.ts b/src/modules/leave-requests/dtos/leave-request-view.dto.ts new file mode 100644 index 0000000..db36da7 --- /dev/null +++ b/src/modules/leave-requests/dtos/leave-request-view.dto.ts @@ -0,0 +1,14 @@ +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; + +export class LeaveRequestViewDto { + id: number; + leave_type!: LeaveTypes; + date!: string; + comment!: string; + approval_status: LeaveApprovalStatus; + email!: string; + employee_full_name!: string; + payable_hours?: number; + requested_hours?: number; + action?: 'created' | 'updated' | 'deleted'; +} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/leave-request.view.dto.ts b/src/modules/leave-requests/dtos/leave-request.view.dto.ts deleted file mode 100644 index 693368d..0000000 --- a/src/modules/leave-requests/dtos/leave-request.view.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; - -export class LeaveRequestViewDto { - id!: number; - leave_type!: LeaveTypes; - start_date_time!: string; - end_date_time!: string | null; - comment!: string | null; - approval_status: LeaveApprovalStatus; - email!: string; - employee_full_name: string; - days_requested?: number; -} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/search-leave-request.dto.ts b/src/modules/leave-requests/dtos/search-leave-request.dto.ts deleted file mode 100644 index 15ce8e4..0000000 --- a/src/modules/leave-requests/dtos/search-leave-request.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; -import { Type } from "class-transformer"; -import { IsOptional, IsInt, IsEnum, IsDateString, IsEmail } from "class-validator"; - -export class SearchLeaveRequestsDto { - - @IsEmail() - email: string; - - @IsOptional() - @Type(()=> Number) - @IsInt() - bank_code_id?: number; - - @IsOptional() - @IsEnum(LeaveApprovalStatus) - approval_status?: LeaveApprovalStatus - - @IsOptional() - @IsDateString() - start_date?: string; - - @IsOptional() - @IsDateString() - end_date?: string; - - @IsOptional() - @IsEnum(LeaveTypes) - leave_type?: LeaveTypes; -} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/sick.dto.ts b/src/modules/leave-requests/dtos/sick.dto.ts new file mode 100644 index 0000000..13d5b45 --- /dev/null +++ b/src/modules/leave-requests/dtos/sick.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsEmail, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; + +export class UpsertSickDto { + @ApiProperty({ example: "jane.doe@example.com" }) + @IsEmail() + email!: string; + + @ApiProperty({ + type: [String], + example: ["2025-03-04"], + description: "ISO dates that represent the sick leave request.", + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsISO8601({}, { each: true }) + dates!: string[]; + + @ApiProperty({ + required: false, + example: "Medical note provided", + description: "Optional comment applied to every date.", + }) + @IsOptional() + @IsString() + comment?: string; + + @ApiProperty({ + required: false, + example: 8, + description: "Hours requested per day. Lets you keep the user input even if the calculation differs.", + }) + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(24) + requested_hours?: number; +} diff --git a/src/modules/leave-requests/dtos/update-leave-request.dto.ts b/src/modules/leave-requests/dtos/update-leave-request.dto.ts deleted file mode 100644 index ec4bb86..0000000 --- a/src/modules/leave-requests/dtos/update-leave-request.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from "@nestjs/swagger"; -import { CreateLeaveRequestsDto } from "./create-leave-request.dto"; - -export class UpdateLeaveRequestsDto extends PartialType(CreateLeaveRequestsDto){} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/upsert-holiday.dto.ts b/src/modules/leave-requests/dtos/upsert-holiday.dto.ts new file mode 100644 index 0000000..d1f0f51 --- /dev/null +++ b/src/modules/leave-requests/dtos/upsert-holiday.dto.ts @@ -0,0 +1,42 @@ +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsEmail, + IsIn, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; + +export const HOLIDAY_UPSERT_ACTIONS = ['create', 'update', 'delete'] as const; +export type HolidayUpsertAction = typeof HOLIDAY_UPSERT_ACTIONS[number]; + +export class UpsertHolidayDto { + @IsEmail() + email!: string; + + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsISO8601({}, { each: true }) + dates!: string[]; + + @IsIn(HOLIDAY_UPSERT_ACTIONS) + action!: HolidayUpsertAction; + + @IsOptional() + @IsString() + comment?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(24) + requested_hours?: number; +} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/vacation.dto.ts b/src/modules/leave-requests/dtos/vacation.dto.ts new file mode 100644 index 0000000..79d558d --- /dev/null +++ b/src/modules/leave-requests/dtos/vacation.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsEmail, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from "class-validator"; + +export class UpsertVacationDto { + @ApiProperty({ example: "jane.doe@example.com" }) + @IsEmail() + email!: string; + + @ApiProperty({ + type: [String], + example: ["2025-07-14", "2025-07-15"], + description: "ISO dates that represent the vacation request.", + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsISO8601({}, { each: true }) + dates!: string[]; + + @ApiProperty({ + required: false, + example: "Summer break", + description: "Optional comment applied to every date.", + }) + @IsOptional() + @IsString() + comment?: string; + + @ApiProperty({ + required: false, + example: 8, + description: "Hours requested per day. Used as default when creating shifts.", + }) + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(24) + requested_hours?: number; +} diff --git a/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts b/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts index 48d91e0..36d05fa 100644 --- a/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts +++ b/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts @@ -1,14 +1,20 @@ -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; +import { Prisma } from "@prisma/client"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; import { LeaveRequestArchiveRow } from "../utils/leave-requests-archive.select"; -const toISO = (date: Date | null): string | null => (date ? date.toISOString().slice(0,10): null); +const toNum = (value?: Prisma.Decimal | null) => value ? Number(value) : undefined; export function mapArchiveRowToView(row: LeaveRequestArchiveRow, email: string, employee_full_name:string): LeaveRequestViewDto { + const isoDate = row.date?.toISOString().slice(0, 10); + if (!isoDate) { + throw new Error(`Leave request #${row.id} has no date set.`); + } return { id: row.id, leave_type: row.leave_type, - start_date_time: toISO(row.start_date_time)!, - end_date_time: toISO(row.end_date_time), + date: isoDate, + payable_hours: toNum(row.payable_hours), + requested_hours: toNum(row.requested_hours), comment: row.comment, approval_status: row.approval_status, email, diff --git a/src/modules/leave-requests/mappers/leave-requests.mapper.ts b/src/modules/leave-requests/mappers/leave-requests.mapper.ts index 4fe2133..fda4f6d 100644 --- a/src/modules/leave-requests/mappers/leave-requests.mapper.ts +++ b/src/modules/leave-requests/mappers/leave-requests.mapper.ts @@ -1,19 +1,23 @@ -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; +import { Prisma } from "@prisma/client"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; import { LeaveRequestRow } from "../utils/leave-requests.select"; -function toISODateString(date:Date | null): string | null { - return date ? date.toISOString().slice(0,10) : null; -} +const toNum = (value?: Prisma.Decimal | null) => + value !== null && value !== undefined ? Number(value) : undefined; export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto { + const isoDate = row.date?.toISOString().slice(0, 10); + if (!isoDate) throw new Error(`Leave request #${row.id} has no date set.`); + return { id: row.id, leave_type: row.leave_type, - start_date_time: toISODateString(row.start_date_time)!, - end_date_time: toISODateString(row.end_date_time), + date: isoDate, + payable_hours: toNum(row.payable_hours), + requested_hours: toNum(row.requested_hours), comment: row.comment, approval_status: row.approval_status, email: row.employee.user.email, - employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}` - } + employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`, + }; } \ No newline at end of file diff --git a/src/modules/leave-requests/services/leave-requests.service.ts b/src/modules/leave-requests/services/leave-requests.service.ts index 1231ed6..a325965 100644 --- a/src/modules/leave-requests/services/leave-requests.service.ts +++ b/src/modules/leave-requests/services/leave-requests.service.ts @@ -1,175 +1,224 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { LeaveTypes, LeaveRequestsArchive } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; -import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto"; -import { LeaveRequests, LeaveRequestsArchive } from "@prisma/client"; -import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto"; import { HolidayService } from "src/modules/business-logics/services/holiday.service"; -import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; -import { VacationService } from "src/modules/business-logics/services/vacation.service"; -import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto"; -import { buildPrismaWhere } from "src/common/shared/build-prisma-where"; -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; -import { LeaveRequestRow, leaveRequestsSelect } from "../utils/leave-requests.select"; + +import { UpsertHolidayDto, HolidayUpsertAction } from "../dtos/upsert-holiday.dto"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; import { mapRowToView } from "../mappers/leave-requests.mapper"; -import { LeaveRequestsArchiveController } from "src/modules/archival/controllers/leave-requests-archive.controller"; +import { mapArchiveRowToViewWithDays } from "../utils/leave-request.transform"; import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select"; -import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper"; -import { mapArchiveRowToViewWithDays, mapRowToViewWithDays } from "../utils/leave-request.transform"; +import { leaveRequestsSelect } from "../utils/leave-requests.select"; @Injectable() export class LeaveRequestsService { constructor( private readonly prisma: PrismaService, private readonly holidayService: HolidayService, - private readonly vacationService: VacationService, - private readonly sickLeaveService: SickLeaveService ) {} - //function to avoid using employee_id as identifier in the frontend. + //-------------------- helpers -------------------- private async resolveEmployeeIdByEmail(email: string): Promise { const employee = await this.prisma.employees.findFirst({ - where: { user: { email} }, - select: { id:true }, + where: { user: { email } }, + select: { id: true }, }); - if(!employee) throw new NotFoundException(`Employee with email ${email} not found`); + if (!employee) { + throw new NotFoundException(`Employee with email ${email} not found`); + } return employee.id; } - //create a leave-request without the use of employee_id - async create(dto: CreateLeaveRequestsDto): Promise { - const employee_id = await this.resolveEmployeeIdByEmail(dto.email); - const row: LeaveRequestRow = await this.prisma.leaveRequests.create({ - data: { - employee_id, - bank_code_id: dto.bank_code_id, - leave_type: dto.leave_type, - start_date_time: new Date(dto.start_date_time), - end_date_time: dto.end_date_time ? new Date(dto.end_date_time) : null, - comment: dto.comment, - approval_status: dto.approval_status ?? undefined, + private async resolveHolidayBankCode() { + const bankCode = await this.prisma.bankCodes.findFirst({ + where: { type: 'HOLIDAY' }, + select: { id: true, bank_code: true, modifier: true }, + }); + if (!bankCode) { + throw new BadRequestException('Bank code type "HOLIDAY" not found'); + } + return bankCode; + } + + async handleHoliday(dto: UpsertHolidayDto): Promise<{ action: HolidayUpsertAction; leave_requests: LeaveRequestViewDto[] }> { + switch (dto.action) { + case 'create': + return this.createHoliday(dto); + case 'update': + return this.updateHoliday(dto); + case 'delete': + return this.deleteHoliday(dto); + default: + throw new BadRequestException(`Unknown action: ${dto.action}`); + } + } + + private async createHoliday(dto: UpsertHolidayDto): Promise<{ action: 'create'; leave_requests: LeaveRequestViewDto[] }> { + const email = dto.email.trim(); + const employeeId = await this.resolveEmployeeIdByEmail(email); + const bankCode = await this.resolveHolidayBankCode(); + const dates = normalizeDates(dto.dates); + if (!dates.length) { + throw new BadRequestException('Dates array must not be empty'); + } + + const created: LeaveRequestViewDto[] = []; + for (const isoDate of dates) { + const date = toDateOnly(isoDate); + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employeeId, + leave_type: LeaveTypes.HOLIDAY, + date, + }, + }, + select: { id: true }, + }); + if (existing) { + throw new BadRequestException(`A holiday request already exists for ${isoDate}`); + } + + const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier); + const row = await this.prisma.leaveRequests.create({ + data: { + employee_id: employeeId, + bank_code_id: bankCode.id, + leave_type: LeaveTypes.HOLIDAY, + date, + comment: dto.comment ?? '', + approval_status: undefined, + requested_hours: dto.requested_hours ?? 8, + payable_hours: payable, + }, + select: leaveRequestsSelect, + }); + created.push({ ...mapRowToView(row), action: 'create' }); + } + + return { action: 'create', leave_requests: created }; + } + + private async updateHoliday(dto: UpsertHolidayDto): Promise<{ action: 'update'; leave_requests: LeaveRequestViewDto[] }> { + const email = dto.email.trim(); + const employeeId = await this.resolveEmployeeIdByEmail(email); + const bankCode = await this.resolveHolidayBankCode(); + const dates = normalizeDates(dto.dates); + if (!dates.length) { + throw new BadRequestException('Dates array must not be empty'); + } + + const updated: LeaveRequestViewDto[] = []; + for (const isoDate of dates) { + const date = toDateOnly(isoDate); + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employeeId, + leave_type: LeaveTypes.HOLIDAY, + date, + }, + }, + select: leaveRequestsSelect, + }); + if (!existing) { + throw new NotFoundException(`No HOLIDAY request found for ${isoDate}`); + } + + const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier); + const row = await this.prisma.leaveRequests.update({ + where: { id: existing.id }, + data: { + comment: dto.comment ?? existing.comment, + requested_hours: dto.requested_hours ?? undefined, + payable_hours: payable, + bank_code_id: bankCode.id, + }, + select: leaveRequestsSelect, + }); + updated.push({ ...mapRowToView(row), action: 'update' }); + } + + return { action: 'update', leave_requests: updated }; + } + + private async deleteHoliday(dto: UpsertHolidayDto): Promise<{ action: 'delete'; leave_requests: LeaveRequestViewDto[] }> { + const email = dto.email.trim(); + const employeeId = await this.resolveEmployeeIdByEmail(email); + const dates = normalizeDates(dto.dates); + if (!dates.length) { + throw new BadRequestException('Dates array must not be empty'); + } + + const rows = await this.prisma.leaveRequests.findMany({ + where: { + employee_id: employeeId, + leave_type: LeaveTypes.HOLIDAY, + date: { in: dates.map((d) => toDateOnly(d)) }, }, select: leaveRequestsSelect, }); - return mapRowToViewWithDays(row); - } - //fetches all leave-requests using email - async findAll(filters: SearchLeaveRequestsDto): Promise { - const {start_date, end_date,email, leave_type, approval_status, bank_code_id } = filters; - const where: any = {}; + if (rows.length !== dates.length) { + const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate)); + throw new NotFoundException(`No HOLIDAY request found for: ${missing.join(', ')}`); + } - if (start_date) where.start_date_time = { ...(where.start_date_time ?? {}), gte: new Date(start_date) }; - if (end_date) where.end_date_time = { ...(where.end_date_time ?? {}), lte: new Date(end_date) }; - if (email) where.employee = { user: { email } }; - if (leave_type) where.leave_type = leave_type; - if (approval_status) where.approval_status = approval_status; - if (typeof bank_code_id === 'number') where.bank_code_id = bank_code_id; - - const rows= await this.prisma.leaveRequests.findMany({ - where, - select: leaveRequestsSelect, - orderBy: { start_date_time: 'desc' }, + await this.prisma.leaveRequests.deleteMany({ + where: { id: { in: rows.map((row) => row.id) } }, }); - return rows.map(mapRowToViewWithDays); + const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' as const })); + return { action: 'delete', leave_requests: deleted }; } - //fetch 1 leave-request using email - async findOne(id:number): Promise { - const row: LeaveRequestRow | null = await this.prisma.leaveRequests.findUnique({ - where: { id }, - select: leaveRequestsSelect, - }); - if(!row) throw new NotFoundException(`Leave Request #${id} not found`); - return mapRowToViewWithDays(row); - } - - //updates 1 leave-request using email - async update(id: number, dto: UpdateLeaveRequestsDto): Promise { - await this.findOne(id); - const data: Record = {}; - - if(dto.email !== undefined) data.employee_id = await this.resolveEmployeeIdByEmail(dto.email); - if(dto.leave_type !== undefined) data.bank_code_id = dto.bank_code_id; - if(dto.start_date_time !== undefined) data.start_date_time = new Date(dto.start_date_time); - if(dto.end_date_time !== undefined) data.end_date_time = new Date(dto.end_date_time); - if(dto.comment !== undefined) data.comment = dto.comment; - if(dto.approval_status !== undefined) data.approval_status = dto.approval_status; - - const row: LeaveRequestRow = await this.prisma.leaveRequests.update({ - where: { id }, - data, - select: leaveRequestsSelect, - }); - return mapRowToViewWithDays(row); - } - - //removes 1 leave-request using email - async remove(id:number): Promise { - await this.findOne(id); - const row: LeaveRequestRow = await this.prisma.leaveRequests.delete({ - where: { id }, - select: leaveRequestsSelect, - }); - return mapRowToViewWithDays(row); - } - - //archivation functions ****************************************************** - + //-------------------- archival -------------------- async archiveExpired(): Promise { - const now = new Date(); - - await this.prisma.$transaction(async transaction => { - //fetches expired leave requests - const expired = await transaction.leaveRequests.findMany({ - where: { end_date_time: { lt: now } }, - }); - if(expired.length === 0) { - return; - } - //copy unto archive table - await transaction.leaveRequestsArchive.createMany({ - data: expired.map(request => ({ - leave_request_id: request.id, - employee_id: request.employee_id, - leave_type: request.leave_type, - start_date_time: request.start_date_time, - end_date_time: request.end_date_time, - comment: request.comment, - approval_status: request.approval_status, - })), - }); - //delete from leave_requests table - await transaction.leaveRequests.deleteMany({ - where: { id: { in: expired.map(request => request.id ) } }, - }); - }); + // TODO: adjust logic to the new LeaveRequests structure } - //fetches all archived leave-requests async findAllArchived(): Promise { - return this.prisma.leaveRequestsArchive.findMany(); + return this.prisma.leaveRequestsArchive.findMany(); } - //remove emp_id and use email - //fetches an archived employee async findOneArchived(id: number): Promise { const row: LeaveRequestArchiveRow | null = await this.prisma.leaveRequestsArchive.findUnique({ where: { id }, select: leaveRequestsArchiveSelect, }); - if(!row) throw new NotFoundException(`Archived Leave Request #${id} not found`); + if (!row) { + throw new NotFoundException(`Archived Leave Request #${id} not found`); + } - const emp = await this.prisma.employees.findUnique({ + const emp = await this.prisma.employees.findUnique({ where: { id: row.employee_id }, - select: { user: {select: { email:true, - first_name: true, - last_name: true, - }}}, + select: { + user: { + select: { + email: true, + first_name: true, + last_name: true, + }, + }, + }, }); - const email = emp?.user.email ?? ""; - const full_name = emp ? `${emp.user.first_name} ${emp.user.last_name}` : ""; + const email = emp?.user.email ?? ''; + const fullName = emp ? `${emp.user.first_name} ${emp.user.last_name}` : ''; - return mapArchiveRowToViewWithDays(row, email, full_name); + return mapArchiveRowToViewWithDays(row, email, fullName); } -} \ No newline at end of file +} + +const toDateOnly = (iso: string): Date => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException(`Invalid date: ${iso}`); + } + date.setHours(0, 0, 0, 0); + return date; +}; + +const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10); + +const normalizeDates = (dates: string[]): string[] => + Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso))))); \ No newline at end of file diff --git a/src/modules/leave-requests/utils/leave-requests-archive.select.ts b/src/modules/leave-requests/utils/leave-requests-archive.select.ts index 5dbbd36..be06345 100644 --- a/src/modules/leave-requests/utils/leave-requests-archive.select.ts +++ b/src/modules/leave-requests/utils/leave-requests-archive.select.ts @@ -6,11 +6,11 @@ export const leaveRequestsArchiveSelect = { archived_at: true, employee_id: true, leave_type: true, - start_date_time: true, - end_date_time: true, + date: true, + payable_hours: true, + requested_hours: true, comment: true, approval_status: true, - } satisfies Prisma.LeaveRequestsArchiveSelect; export type LeaveRequestArchiveRow = Prisma.LeaveRequestsArchiveGetPayload<{ select: typeof leaveRequestsArchiveSelect}>; \ No newline at end of file diff --git a/src/modules/leave-requests/utils/leave-requests.select.ts b/src/modules/leave-requests/utils/leave-requests.select.ts index 9636334..e48a930 100644 --- a/src/modules/leave-requests/utils/leave-requests.select.ts +++ b/src/modules/leave-requests/utils/leave-requests.select.ts @@ -5,8 +5,9 @@ export const leaveRequestsSelect = { id: true, bank_code_id: true, leave_type: true, - start_date_time: true, - end_date_time: true, + date: true, + payable_hours: true, + requested_hours: true, comment: true, approval_status: true, employee: { select: {