From 79153c6de3936d895cbf8286905abc37cf71045c Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 6 Oct 2025 12:15:51 -0400 Subject: [PATCH] feat(leave-requests): implementation of vacation, sick and holiday leave-requests. --- docs/swagger/swagger-spec.json | 8 +- .../services/holiday.service.ts | 2 +- .../services/sick-leave.service.ts | 55 ++- .../services/vacation.service.ts | 14 +- .../controllers/leave-requests.controller.ts | 16 +- src/modules/leave-requests/dtos/sick.dto.ts | 52 --- .../leave-requests/dtos/upsert-holiday.dto.ts | 48 --- .../dtos/upsert-leave-request.dto.ts | 51 +++ .../leave-requests/dtos/vacation.dto.ts | 52 --- .../leave-requests/leave-requests.module.ts | 19 +- .../holiday-leave-requests.service.ts | 295 ++------------- .../services/leave-request.service.ts | 339 ++++++++++++++++++ .../services/sick-leave-requests.service.ts | 105 ++++++ .../vacation-leave-requests.service.ts | 98 +++++ 14 files changed, 688 insertions(+), 466 deletions(-) delete mode 100644 src/modules/leave-requests/dtos/sick.dto.ts delete mode 100644 src/modules/leave-requests/dtos/upsert-holiday.dto.ts create mode 100644 src/modules/leave-requests/dtos/upsert-leave-request.dto.ts delete mode 100644 src/modules/leave-requests/dtos/vacation.dto.ts create mode 100644 src/modules/leave-requests/services/leave-request.service.ts create mode 100644 src/modules/leave-requests/services/sick-leave-requests.service.ts create mode 100644 src/modules/leave-requests/services/vacation-leave-requests.service.ts diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 8f2bd52..2e7fd71 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -1232,16 +1232,16 @@ ] } }, - "/leave-requests/holiday": { + "/leave-requests/upsert": { "post": { - "operationId": "LeaveRequestController_upsertHoliday", + "operationId": "LeaveRequestController_upsertLeaveRequest", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpsertHolidayDto" + "$ref": "#/components/schemas/UpsertLeaveRequestDto" } } } @@ -2474,7 +2474,7 @@ } } }, - "UpsertHolidayDto": { + "UpsertLeaveRequestDto": { "type": "object", "properties": {} }, diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index 8b8a31b..48e602c 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { PrismaService } from "../../../prisma/prisma.service"; import { computeHours, getWeekStart } from "src/common/utils/date-utils"; +import { PrismaService } from "../../../prisma/prisma.service"; const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000; diff --git a/src/modules/business-logics/services/sick-leave.service.ts b/src/modules/business-logics/services/sick-leave.service.ts index 6ebb3d9..6c00113 100644 --- a/src/modules/business-logics/services/sick-leave.service.ts +++ b/src/modules/business-logics/services/sick-leave.service.ts @@ -1,6 +1,6 @@ +import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils"; import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "../../../prisma/prisma.service"; -import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils"; @Injectable() export class SickLeaveService { @@ -9,28 +9,38 @@ export class SickLeaveService { private readonly logger = new Logger(SickLeaveService.name); //switch employeeId for email - async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number): - Promise { + async calculateSickLeavePay( + employee_id: number, + reference_date: Date, + days_requested: number, + hours_per_day: number, + modifier: number, + ): Promise { + if (days_requested <= 0 || hours_per_day <= 0 || modifier <= 0) { + return 0; + } + //sets the year to jan 1st to dec 31st const period_start = getYearStart(reference_date); - const period_end = reference_date; + const period_end = reference_date; //fetches all shifts of a selected employee const shifts = await this.prisma.shifts.findMany({ where: { timesheet: { employee_id: employee_id }, - date: { gte: period_start, lte: period_end}, + date: { gte: period_start, lte: period_end }, }, select: { date: true }, }); //count the amount of worked days const worked_dates = new Set( - shifts.map(shift => shift.date.toISOString().slice(0,10)) + shifts.map((shift) => shift.date.toISOString().slice(0, 10)), ); const days_worked = worked_dates.size; - this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} - -> ${period_end.toDateString()}`); + this.logger.debug( + `Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} -> ${period_end.toDateString()}`, + ); //less than 30 worked days returns 0 if (days_worked < 30) { @@ -45,22 +55,31 @@ export class SickLeaveService { const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day //calculate each completed month, starting the 1st of the next month - const first_bonus_date = new Date(threshold_date.getFullYear(), threshold_date.getMonth() +1, 1); - let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 + - (period_end.getMonth() - first_bonus_date.getMonth()) + 1; - if(months < 0) months = 0; + const first_bonus_date = new Date( + threshold_date.getFullYear(), + threshold_date.getMonth() + 1, + 1, + ); + let months = + (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 + + (period_end.getMonth() - first_bonus_date.getMonth()) + + 1; + if (months < 0) months = 0; acquired_days += months; //cap of 10 days if (acquired_days > 10) acquired_days = 10; - this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()} - , bonusMonths = ${months}, acquired Days = ${acquired_days}`); + this.logger.debug( + `Sick leave: threshold Date = ${threshold_date.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquired_days}`, + ); const payable_days = Math.min(acquired_days, days_requested); - const raw_hours = payable_days * 8 * modifier; - const rounded = roundToQuarterHour(raw_hours) - this.logger.debug(`Sick leave pay: days= ${payable_days}, modifier= ${modifier}, hours= ${rounded}`); + const raw_hours = payable_days * hours_per_day * modifier; + const rounded = roundToQuarterHour(raw_hours); + this.logger.debug( + `Sick leave pay: days= ${payable_days}, hoursPerDay= ${hours_per_day}, modifier= ${modifier}, hours= ${rounded}`, + ); return rounded; } -} \ No newline at end of file +} diff --git a/src/modules/business-logics/services/vacation.service.ts b/src/modules/business-logics/services/vacation.service.ts index f3b3447..9445149 100644 --- a/src/modules/business-logics/services/vacation.service.ts +++ b/src/modules/business-logics/services/vacation.service.ts @@ -6,16 +6,8 @@ export class VacationService { constructor(private readonly prisma: PrismaService) {} private readonly logger = new Logger(VacationService.name); - /** - * Calculate the ammount allowed for vacation days. - * - * @param employee_id employee ID - * @param startDate first day of vacation - * @param daysRequested number of days requested - * @param modifier Coefficient of hours(1) - * @returns amount of payable hours - */ - //switch employeeId for email + + //switch employeeId for email async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise { //fetch hiring date const employee = await this.prisma.employees.findUnique({ @@ -56,7 +48,7 @@ export class VacationService { const segment_end = boundaries[i+1]; //number of days in said segment - const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day); + const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day); const years_since_hire = (segment_start.getFullYear() - hire_date.getFullYear()) - (segment_start < new Date(segment_start.getFullYear(), hire_date.getMonth()) ? 1 : 0); let alloc_days: number; diff --git a/src/modules/leave-requests/controllers/leave-requests.controller.ts b/src/modules/leave-requests/controllers/leave-requests.controller.ts index 15311a5..fc934ff 100644 --- a/src/modules/leave-requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave-requests/controllers/leave-requests.controller.ts @@ -1,21 +1,21 @@ import { Body, Controller, Post } from "@nestjs/common"; -import { HolidayLeaveRequestsService } from "../services/holiday-leave-requests.service"; - import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; -import { UpsertHolidayDto } from "../dtos/upsert-holiday.dto"; +import { LeaveRequestsService } from "../services/leave-request.service"; +import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto"; +import { LeaveTypes } from "@prisma/client"; @ApiTags('Leave Requests') @ApiBearerAuth('access-token') // @UseGuards() @Controller('leave-requests') export class LeaveRequestController { - constructor(private readonly leave_service: HolidayLeaveRequestsService){} + constructor(private readonly leave_service: LeaveRequestsService){} - @Post('holiday') - async upsertHoliday(@Body() dto: UpsertHolidayDto) { - const { action, leave_requests } = await this.leave_service.handleHoliday(dto); + @Post('upsert') + async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) { + const { action, leave_requests } = await this.leave_service.handle(dto); return { action, leave_requests }; - } + }q //TODO: /* diff --git a/src/modules/leave-requests/dtos/sick.dto.ts b/src/modules/leave-requests/dtos/sick.dto.ts deleted file mode 100644 index 13d5b45..0000000 --- a/src/modules/leave-requests/dtos/sick.dto.ts +++ /dev/null @@ -1,52 +0,0 @@ -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/upsert-holiday.dto.ts b/src/modules/leave-requests/dtos/upsert-holiday.dto.ts deleted file mode 100644 index 376b7a4..0000000 --- a/src/modules/leave-requests/dtos/upsert-holiday.dto.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { LeaveApprovalStatus } from "@prisma/client"; -import { Type } from "class-transformer"; -import { - ArrayNotEmpty, - ArrayUnique, - IsArray, - IsEmail, - IsEnum, - 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; - - @IsOptional() - @IsEnum(LeaveApprovalStatus) - approval_status?: LeaveApprovalStatus; -} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/upsert-leave-request.dto.ts b/src/modules/leave-requests/dtos/upsert-leave-request.dto.ts new file mode 100644 index 0000000..0f420e7 --- /dev/null +++ b/src/modules/leave-requests/dtos/upsert-leave-request.dto.ts @@ -0,0 +1,51 @@ +import { IsEmail, IsArray, ArrayNotEmpty, ArrayUnique, IsISO8601, IsIn, IsOptional, IsString, IsNumber, Min, Max, IsEnum } from "class-validator"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { LeaveRequestViewDto } from "./leave-request-view.dto"; +import { Type } from "class-transformer"; + +//sets wich function to call +export const UPSERT_ACTIONS = ['create', 'update', 'delete'] as const; +export type UpsertAction = (typeof UPSERT_ACTIONS)[number]; + +//sets wich types to use +export const REQUEST_TYPES = Object.values(LeaveTypes) as readonly LeaveTypes[]; +export type RequestTypes = (typeof REQUEST_TYPES)[number]; + +//filter requests by type and action +export interface UpsertResult { + action: UpsertAction; + leave_requests: LeaveRequestViewDto[]; +} + +export class UpsertLeaveRequestDto { + @IsEmail() + email!: string; + + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsISO8601({}, { each: true }) + dates!: string[]; + + @IsOptional() + @IsEnum(LeaveTypes) + type!: string; + + @IsIn(UPSERT_ACTIONS) + action!: UpsertAction; + + @IsOptional() + @IsString() + comment?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(24) + requested_hours?: number; + + @IsOptional() + @IsEnum(LeaveApprovalStatus) + approval_status?: LeaveApprovalStatus +} \ 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 deleted file mode 100644 index 79d558d..0000000 --- a/src/modules/leave-requests/dtos/vacation.dto.ts +++ /dev/null @@ -1,52 +0,0 @@ -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/leave-requests.module.ts b/src/modules/leave-requests/leave-requests.module.ts index 394b6be..1b52938 100644 --- a/src/modules/leave-requests/leave-requests.module.ts +++ b/src/modules/leave-requests/leave-requests.module.ts @@ -1,21 +1,26 @@ import { PrismaService } from "src/prisma/prisma.service"; -import { HolidayService } from "../business-logics/services/holiday.service"; import { LeaveRequestController } from "./controllers/leave-requests.controller"; import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service"; import { Module } from "@nestjs/common"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; -import { ShiftsCommandService } from "../shifts/services/shifts-command.service"; +import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service"; +import { SickLeaveRequestsService } from "./services/sick-leave-requests.service"; +import { LeaveRequestsService } from "./services/leave-request.service"; +import { ShiftsModule } from "../shifts/shifts.module"; @Module({ - imports: [BusinessLogicsModule], + imports: [BusinessLogicsModule, ShiftsModule], controllers: [LeaveRequestController], providers: [ - HolidayService, + VacationLeaveRequestsService, + SickLeaveRequestsService, HolidayLeaveRequestsService, - PrismaService, - ShiftsCommandService, + LeaveRequestsService, + PrismaService + ], + exports: [ + LeaveRequestsService, ], - exports: [HolidayLeaveRequestsService], }) export class LeaveRequestsModule {} \ No newline at end of file diff --git a/src/modules/leave-requests/services/holiday-leave-requests.service.ts b/src/modules/leave-requests/services/holiday-leave-requests.service.ts index c3f614d..580a190 100644 --- a/src/modules/leave-requests/services/holiday-leave-requests.service.ts +++ b/src/modules/leave-requests/services/holiday-leave-requests.service.ts @@ -1,89 +1,72 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; -import { HolidayUpsertAction, UpsertHolidayDto } from '../dtos/upsert-holiday.dto'; +import { LeaveRequestsService, normalizeDates, toDateOnly } from './leave-request.service'; +import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto'; +import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto'; +import { BadRequestException, Inject, Injectable, forwardRef } from '@nestjs/common'; +import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; import { HolidayService } from 'src/modules/business-logics/services/holiday.service'; -import { ShiftsCommandService } from 'src/modules/shifts/services/shifts-command.service'; import { PrismaService } from 'src/prisma/prisma.service'; -import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto'; import { mapRowToView } from '../mappers/leave-requests.mapper'; import { leaveRequestsSelect } from '../utils/leave-requests.select'; -interface HolidayUpsertResult { - action: HolidayUpsertAction; - leave_requests: LeaveRequestViewDto[]; -} @Injectable() export class HolidayLeaveRequestsService { constructor( - private readonly prisma: PrismaService, private readonly holidayService: HolidayService, - private readonly shiftsCommand: ShiftsCommandService, + @Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService, + private readonly prisma: PrismaService, ) {} - // --------------------------------------------------------------------- - // Public API - // --------------------------------------------------------------------- - - async handleHoliday(dto: UpsertHolidayDto): Promise { + //handle distribution to the right service according to the selected action + async handle(dto: UpsertLeaveRequestDto): Promise { switch (dto.action) { case 'create': return this.createHoliday(dto); case 'update': - return this.updateHoliday(dto); + return this.leaveService.update(dto, LeaveTypes.HOLIDAY); case 'delete': - return this.deleteHoliday(dto); + return this.leaveService.delete(dto, LeaveTypes.HOLIDAY); default: throw new BadRequestException(`Unknown action: ${dto.action}`); } } - // --------------------------------------------------------------------- - // Create - // --------------------------------------------------------------------- - - private async createHoliday(dto: UpsertHolidayDto): Promise { - 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'); - } + private async createHoliday(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.HOLIDAY); + 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); + for (const iso_date of dates) { + const date = toDateOnly(iso_date); const existing = await this.prisma.leaveRequests.findUnique({ where: { leave_per_employee_date: { - employee_id: employeeId, - leave_type: LeaveTypes.HOLIDAY, + employee_id: employee_id, + leave_type: LeaveTypes.HOLIDAY, date, }, }, select: { id: true }, }); if (existing) { - throw new BadRequestException(`Holiday request already exists for ${isoDate}`); + throw new BadRequestException(`Holiday request already exists for ${iso_date}`); } - const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier); - const row = await this.prisma.leaveRequests.create({ + const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier); + const row = await this.prisma.leaveRequests.create({ data: { - employee_id: employeeId, - bank_code_id: bankCode.id, - leave_type: LeaveTypes.HOLIDAY, + employee_id: employee_id, + bank_code_id: bank_code.id, + leave_type: LeaveTypes.HOLIDAY, date, - comment: dto.comment ?? '', + comment: dto.comment ?? '', requested_hours: dto.requested_hours ?? 8, - payable_hours: payable, + payable_hours: payable, approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, }, select: leaveRequestsSelect, @@ -91,7 +74,7 @@ export class HolidayLeaveRequestsService { const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); if (row.approval_status === LeaveApprovalStatus.APPROVED) { - await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment); + await this.leaveService.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment); } created.push({ ...mapRowToView(row), action: 'create' }); @@ -99,223 +82,5 @@ export class HolidayLeaveRequestsService { return { action: 'create', leave_requests: created }; } - - // --------------------------------------------------------------------- - // Update - // --------------------------------------------------------------------- - - private async updateHoliday(dto: UpsertHolidayDto): Promise { - 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 previousStatus = existing.approval_status; - - const row = await this.prisma.leaveRequests.update({ - where: { id: existing.id }, - data: { - comment: dto.comment ?? existing.comment, - requested_hours: dto.requested_hours ?? existing.requested_hours ?? 8, - payable_hours: payable, - bank_code_id: bankCode.id, - approval_status: dto.approval_status ?? existing.approval_status, - }, - select: leaveRequestsSelect, - }); - - const wasApproved = previousStatus === LeaveApprovalStatus.APPROVED; - const isApproved = row.approval_status === LeaveApprovalStatus.APPROVED; - const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); - - if (!wasApproved && isApproved) { - await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment); - } else if (wasApproved && !isApproved) { - await this.removeHolidayShift(email, employeeId, isoDate); - } else if (wasApproved && isApproved) { - await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment); - } - - updated.push({ ...mapRowToView(row), action: 'update' }); - } - - return { action: 'update', leave_requests: updated }; - } - - // --------------------------------------------------------------------- - // Delete - // --------------------------------------------------------------------- - - private async deleteHoliday(dto: UpsertHolidayDto): Promise { - 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, - }); - - 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(', ')}`); - } - - for (const row of rows) { - if (row.approval_status === LeaveApprovalStatus.APPROVED) { - const iso = toISODateKey(row.date); - await this.removeHolidayShift(email, employeeId, iso); - } - } - - await this.prisma.leaveRequests.deleteMany({ - where: { id: { in: rows.map((row) => row.id) } }, - }); - - const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' as const})); - return { action: 'delete', leave_requests: deleted }; - } - - // --------------------------------------------------------------------- - // Shift synchronisation - // --------------------------------------------------------------------- - - private async syncHolidayShift( - email: string, - employeeId: number, - isoDate: string, - hours: number, - comment?: string, - ) { - if (hours <= 0) return; - - const durationMinutes = Math.round(hours * 60); - if (durationMinutes > 8 * 60) { - throw new BadRequestException('Holiday hours cannot exceed 8 hours.'); - } - - const startMinutes = 8 * 60; - const endMinutes = startMinutes + durationMinutes; - const toHHmm = (total: number) => `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; - - const existing = await this.prisma.shifts.findFirst({ - where: { - date: new Date(isoDate), - bank_code: { type: 'HOLIDAY' }, - timesheet: { employee_id: employeeId }, - }, - include: { bank_code: true }, - }); - - await this.shiftsCommand.upsertShiftsByDate(email, isoDate, { - old_shift: existing - ? { - start_time: existing.start_time.toISOString().slice(11, 16), - end_time: existing.end_time.toISOString().slice(11, 16), - type: existing.bank_code?.type ?? 'HOLIDAY', - is_remote: existing.is_remote, - comment: existing.comment ?? undefined, - } - : undefined, - new_shift: { - start_time: toHHmm(startMinutes), - end_time: toHHmm(endMinutes), - type: 'HOLIDAY', - is_remote: existing?.is_remote ?? false, - comment: comment ?? existing?.comment ?? '', - }, - }); - } - - private async removeHolidayShift(email: string, employeeId: number, isoDate: string) { - const existing = await this.prisma.shifts.findFirst({ - where: { - date: new Date(isoDate), - bank_code: { type: 'HOLIDAY' }, - timesheet: { employee_id: employeeId }, - }, - include: { bank_code: true }, - }); - if (!existing) return; - - await this.shiftsCommand.upsertShiftsByDate(email, isoDate, { - old_shift: { - start_time: existing.start_time.toISOString().slice(11, 16), - end_time: existing.end_time.toISOString().slice(11, 16), - type: existing.bank_code?.type ?? 'HOLIDAY', - is_remote: existing.is_remote, - comment: existing.comment ?? undefined, - }, - }); - } - - // --------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------- - - private async resolveEmployeeIdByEmail(email: string): Promise { - const employee = await this.prisma.employees.findFirst({ - where: { user: { email } }, - select: { id: true }, - }); - if (!employee) { - throw new NotFoundException(`Employee with email ${email} not found`); - } - return employee.id; - } - - 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; - } } -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))))); diff --git a/src/modules/leave-requests/services/leave-request.service.ts b/src/modules/leave-requests/services/leave-request.service.ts new file mode 100644 index 0000000..0351c38 --- /dev/null +++ b/src/modules/leave-requests/services/leave-request.service.ts @@ -0,0 +1,339 @@ +import { BadRequestException, Inject, Injectable, NotFoundException, forwardRef } from "@nestjs/common"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { roundToQuarterHour } from "src/common/utils/date-utils"; +import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; +import { mapRowToView } from "../mappers/leave-requests.mapper"; +import { leaveRequestsSelect } from "../utils/leave-requests.select"; +import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service"; +import { SickLeaveRequestsService } from "./sick-leave-requests.service"; +import { VacationLeaveRequestsService } from "./vacation-leave-requests.service"; +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 { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class LeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + private readonly holidayService: HolidayService, + private readonly sickLogic: SickLeaveService, + private readonly vacationLogic: VacationService, + @Inject(forwardRef(() => HolidayLeaveRequestsService)) private readonly holidayLeaveService: HolidayLeaveRequestsService, + @Inject(forwardRef(() => SickLeaveRequestsService)) private readonly sickLeaveService: SickLeaveRequestsService, + private readonly shiftsCommand: ShiftsCommandService, + @Inject(forwardRef(() => VacationLeaveRequestsService)) private readonly vacationLeaveService: VacationLeaveRequestsService, + ) {} + + async handle(dto: UpsertLeaveRequestDto): Promise { + switch (dto.type) { + case LeaveTypes.HOLIDAY: + return this.holidayLeaveService.handle(dto); + case LeaveTypes.VACATION: + return this.vacationLeaveService.handle(dto); + case LeaveTypes.SICK: + return this.sickLeaveService.handle(dto); + default: + throw new BadRequestException(`Unsupported leave type: ${dto.type}`); + } + } + + async resolveEmployeeIdByEmail(email: string): Promise { + const employee = await this.prisma.employees.findFirst({ + where: { user: { email } }, + select: { id: true }, + }); + if (!employee) { + throw new NotFoundException(`Employee with email ${email} not found`); + } + return employee.id; + } + + async resolveBankCodeByType(type: LeaveTypes) { + const bankCode = await this.prisma.bankCodes.findFirst({ + where: { type }, + select: { id: true, bank_code: true, modifier: true }, + }); + if (!bankCode) { + throw new BadRequestException(`Bank code type "${type}" not found`); + } + return bankCode; + } + + async syncShift( + email: string, + employee_id: number, + iso_date: string, + hours: number, + type: LeaveTypes, + comment?: string, + ) { + if (hours <= 0) return; + + const duration_minutes = Math.round(hours * 60); + if (duration_minutes > 8 * 60) { + throw new BadRequestException("Amount of hours cannot exceed 8 hours per day."); + } + + const start_minutes = 8 * 60; + const end_minutes = start_minutes + duration_minutes; + const toHHmm = (total: number) => + `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; + + const existing = await this.prisma.shifts.findFirst({ + where: { + date: new Date(iso_date), + bank_code: { type }, + timesheet: { employee_id: employee_id }, + }, + include: { bank_code: true }, + }); + + await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { + old_shift: existing + ? { + start_time: existing.start_time.toISOString().slice(11, 16), + end_time: existing.end_time.toISOString().slice(11, 16), + type: existing.bank_code?.type ?? type, + is_remote: existing.is_remote, + comment: existing.comment ?? undefined, + } + : undefined, + new_shift: { + start_time: toHHmm(start_minutes), + end_time: toHHmm(end_minutes), + is_remote: existing?.is_remote ?? false, + comment: comment ?? existing?.comment ?? "", + type: type, + }, + }); + } + + async removeShift( + email: string, + employee_id: number, + iso_date: string, + type: LeaveTypes, + ) { + const existing = await this.prisma.shifts.findFirst({ + where: { + date: new Date(iso_date), + bank_code: { type }, + timesheet: { employee_id: employee_id }, + }, + include: { bank_code: true }, + }); + if (!existing) return; + + await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { + old_shift: { + start_time: existing.start_time.toISOString().slice(11, 16), + end_time: existing.end_time.toISOString().slice(11, 16), + type: existing.bank_code?.type ?? type, + is_remote: existing.is_remote, + comment: existing.comment ?? undefined, + }, + }); + } + + async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise { + const email = dto.email.trim(); + const employee_id = 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: employee_id, + leave_type: type, + date: { in: dates.map((d) => toDateOnly(d)) }, + }, + select: leaveRequestsSelect, + }); + + if (rows.length !== dates.length) { + const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate)); + throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`); + } + + for (const row of rows) { + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + const iso = toISODateKey(row.date); + await this.removeShift(email, employee_id, iso, type); + } + } + + await this.prisma.leaveRequests.deleteMany({ + where: { id: { in: rows.map((row) => row.id) } }, + }); + + const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const })); + return { action: "delete", leave_requests: deleted }; + } + + async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise { + const email = dto.email.trim(); + const employee_id = await this.resolveEmployeeIdByEmail(email); + const bank_code = await this.resolveBankCodeByType(type); + const modifier = Number(bank_code.modifier ?? 1); + const dates = normalizeDates(dto.dates); + if (!dates.length) { + throw new BadRequestException("Dates array must not be empty"); + } + + const entries = await Promise.all( + dates.map(async (iso_date) => { + const date = toDateOnly(iso_date); + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employee_id, + leave_type: type, + date, + }, + }, + select: leaveRequestsSelect, + }); + if (!existing) { + throw new NotFoundException(`No Leave request found for ${iso_date}`); + } + return { iso_date, date, existing }; + }), + ); + + const updated: LeaveRequestViewDto[] = []; + + if (type === LeaveTypes.SICK) { + const firstExisting = entries[0].existing; + const fallbackRequested = + firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined + ? Number(firstExisting.requested_hours) + : 8; + const requested_hours_per_day = dto.requested_hours ?? fallbackRequested; + const reference_date = entries.reduce( + (latest, entry) => (entry.date > latest ? entry.date : latest), + entries[0].date, + ); + const total_payable_hours = await this.sickLogic.calculateSickLeavePay( + employee_id, + reference_date, + entries.length, + requested_hours_per_day, + modifier, + ); + let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); + const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); + + for (const { iso_date, existing } of entries) { + const previous_status = existing.approval_status; + const payable = Math.min(remaining_payable_hours, daily_payable_cap); + const payable_rounded = roundToQuarterHour(Math.max(0, payable)); + remaining_payable_hours = roundToQuarterHour( + Math.max(0, remaining_payable_hours - payable_rounded), + ); + + const row = await this.prisma.leaveRequests.update({ + where: { id: existing.id }, + data: { + comment: dto.comment ?? existing.comment, + requested_hours: requested_hours_per_day, + payable_hours: payable_rounded, + bank_code_id: bank_code.id, + approval_status: dto.approval_status ?? existing.approval_status, + }, + select: leaveRequestsSelect, + }); + + const was_approved = previous_status === LeaveApprovalStatus.APPROVED; + const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + + if (!was_approved && is_approved) { + await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } else if (was_approved && !is_approved) { + await this.removeShift(email, employee_id, iso_date, type); + } else if (was_approved && is_approved) { + await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } + + updated.push({ ...mapRowToView(row), action: "update" }); + } + + return { action: "update", leave_requests: updated }; + } + + for (const { iso_date, date, existing } of entries) { + const previous_status = existing.approval_status; + const fallbackRequested = + existing.requested_hours !== null && existing.requested_hours !== undefined + ? Number(existing.requested_hours) + : 8; + const requested_hours = dto.requested_hours ?? fallbackRequested; + + let payable: number; + switch (type) { + case LeaveTypes.HOLIDAY: + payable = await this.holidayService.calculateHolidayPay(email, date, modifier); + break; + case LeaveTypes.VACATION: { + const days_requested = requested_hours / 8; + payable = await this.vacationLogic.calculateVacationPay( + employee_id, + date, + Math.max(0, days_requested), + modifier, + ); + break; + } + default: + payable = existing.payable_hours !== null && existing.payable_hours !== undefined + ? Number(existing.payable_hours) + : requested_hours; + } + + const row = await this.prisma.leaveRequests.update({ + where: { id: existing.id }, + data: { + comment: dto.comment ?? existing.comment, + requested_hours, + payable_hours: payable, + bank_code_id: bank_code.id, + approval_status: dto.approval_status ?? existing.approval_status, + }, + select: leaveRequestsSelect, + }); + + const was_approved = previous_status === LeaveApprovalStatus.APPROVED; + const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + + if (!was_approved && is_approved) { + await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } else if (was_approved && !is_approved) { + await this.removeShift(email, employee_id, iso_date, type); + } else if (was_approved && is_approved) { + await this.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } + + updated.push({ ...mapRowToView(row), action: "update" }); + } + + return { action: "update", leave_requests: updated }; + } +} + +export 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; +}; + +export const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10); + +export const normalizeDates = (dates: string[]): string[] => + Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso))))); diff --git a/src/modules/leave-requests/services/sick-leave-requests.service.ts b/src/modules/leave-requests/services/sick-leave-requests.service.ts new file mode 100644 index 0000000..e489c56 --- /dev/null +++ b/src/modules/leave-requests/services/sick-leave-requests.service.ts @@ -0,0 +1,105 @@ +import { LeaveRequestsService, normalizeDates, toDateOnly } from "./leave-request.service"; +import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; +import { BadRequestException, Inject, Injectable, forwardRef } from "@nestjs/common"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { leaveRequestsSelect } from "../utils/leave-requests.select"; +import { mapRowToView } from "../mappers/leave-requests.mapper"; +import { PrismaService } from "src/prisma/prisma.service"; +import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; +import { roundToQuarterHour } from "src/common/utils/date-utils"; + +@Injectable() +export class SickLeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + @Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService, + private readonly sickService: SickLeaveService, + ) {} + + //handle distribution to the right service according to the selected action + async handle(dto: UpsertLeaveRequestDto): Promise { + switch (dto.action) { + case "create": + return this.createSick(dto); + case "update": + return this.leaveService.update(dto, LeaveTypes.SICK); + case "delete": + return this.leaveService.delete(dto, LeaveTypes.SICK); + default: + throw new BadRequestException(`Unknown action: ${dto.action}`); + } + } + + private async createSick(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.SICK); + const modifier = bank_code.modifier ?? 1; + const dates = normalizeDates(dto.dates); + if (!dates.length) throw new BadRequestException("Dates array must not be empty"); + const requested_hours_per_day = dto.requested_hours ?? 8; + + const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) })); + const reference_date = entries.reduce( + (latest, entry) => (entry.date > latest ? entry.date : latest), + entries[0].date, + ); + const total_payable_hours = await this.sickService.calculateSickLeavePay( + employee_id, + reference_date, + entries.length, + requested_hours_per_day, + modifier, + ); + let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); + const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); + + const created: LeaveRequestViewDto[] = []; + + for (const { iso, date } of entries) { + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employee_id, + leave_type: LeaveTypes.SICK, + date, + }, + }, + select: { id: true }, + }); + if (existing) { + throw new BadRequestException(`Sick request already exists for ${iso}`); + } + + const payable = Math.min(remaining_payable_hours, daily_payable_cap); + const payable_rounded = roundToQuarterHour(Math.max(0, payable)); + remaining_payable_hours = roundToQuarterHour( + Math.max(0, remaining_payable_hours - payable_rounded), + ); + + const row = await this.prisma.leaveRequests.create({ + data: { + employee_id: employee_id, + bank_code_id: bank_code.id, + leave_type: LeaveTypes.SICK, + comment: dto.comment ?? "", + requested_hours: requested_hours_per_day, + payable_hours: payable_rounded, + approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, + date, + }, + select: leaveRequestsSelect, + }); + + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + await this.leaveService.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment); + } + + created.push({ ...mapRowToView(row), action: "create" }); + } + + return { action: "create", leave_requests: created }; + } +} diff --git a/src/modules/leave-requests/services/vacation-leave-requests.service.ts b/src/modules/leave-requests/services/vacation-leave-requests.service.ts new file mode 100644 index 0000000..8128862 --- /dev/null +++ b/src/modules/leave-requests/services/vacation-leave-requests.service.ts @@ -0,0 +1,98 @@ +import { LeaveRequestsService, normalizeDates, toDateOnly } from "./leave-request.service"; +import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; +import { BadRequestException, Inject, Injectable, forwardRef } from "@nestjs/common"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { VacationService } from "src/modules/business-logics/services/vacation.service"; +import { PrismaService } from "src/prisma/prisma.service"; +import { mapRowToView } from "../mappers/leave-requests.mapper"; +import { leaveRequestsSelect } from "../utils/leave-requests.select"; +import { roundToQuarterHour } from "src/common/utils/date-utils"; + +@Injectable() +export class VacationLeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + @Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService, + private readonly vacationService: VacationService, + ) {} + + async handle(dto: UpsertLeaveRequestDto): Promise { + switch (dto.action) { + case "create": + return this.createVacation(dto); + case "update": + return this.leaveService.update(dto, LeaveTypes.VACATION); + case "delete": + return this.leaveService.delete(dto, LeaveTypes.VACATION); + default: + throw new BadRequestException(`Unknown action: ${dto.action}`); + } + } + + private async createVacation(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.VACATION); + const modifier = bank_code.modifier ?? 1; + const dates = normalizeDates(dto.dates); + const requested_hours_per_day = dto.requested_hours ?? 8; + if (!dates.length) throw new BadRequestException("Dates array must not be empty"); + + const entries = dates + .map((iso) => ({ iso, date: toDateOnly(iso) })) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + const start_date = entries[0].date; + const total_payable_hours = await this.vacationService.calculateVacationPay( + employee_id, + start_date, + entries.length, + modifier, + ); + let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); + const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); + + const created: LeaveRequestViewDto[] = []; + + for (const { iso, date } of entries) { + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employee_id, + leave_type: LeaveTypes.VACATION, + date, + }, + }, + select: { id: true }, + }); + if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`); + + const payable = Math.min(remaining_payable_hours, daily_payable_cap); + const payable_rounded = roundToQuarterHour(Math.max(0, payable)); + remaining_payable_hours = roundToQuarterHour( + Math.max(0, remaining_payable_hours - payable_rounded), + ); + + const row = await this.prisma.leaveRequests.create({ + data: { + employee_id: employee_id, + bank_code_id: bank_code.id, + payable_hours: payable_rounded, + requested_hours: requested_hours_per_day, + leave_type: LeaveTypes.VACATION, + comment: dto.comment ?? "", + approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, + date, + }, + select: leaveRequestsSelect, + }); + + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + await this.leaveService.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment); + } + created.push({ ...mapRowToView(row), action: "create" }); + } + return { action: "create", leave_requests: created }; + } +}