diff --git a/src/modules/archival/controllers/leave-requests-archive.controller.ts b/src/modules/archival/controllers/leave-requests-archive.controller.ts index 51ad8e6..89cb024 100644 --- a/src/modules/archival/controllers/leave-requests-archive.controller.ts +++ b/src/modules/archival/controllers/leave-requests-archive.controller.ts @@ -3,7 +3,7 @@ import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { LeaveRequestsArchive, Roles as RoleEnum } from "@prisma/client"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { LeaveRequestViewDto } from "src/modules/leave-requests/dtos/leave-request.view.dto"; -import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service"; +import { LeaveRequestsService } from "src/modules/leave-requests/services/holiday-leave-requests.service"; @ApiTags('LeaveRequests Archives') // @UseGuards() diff --git a/src/modules/archival/services/archival.service.ts b/src/modules/archival/services/archival.service.ts index bf7a36d..07ab621 100644 --- a/src/modules/archival/services/archival.service.ts +++ b/src/modules/archival/services/archival.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; -import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service"; +import { LeaveRequestsService } from "src/modules/leave-requests/services/holiday-leave-requests.service"; import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 13ccd84..06e3895 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -11,10 +11,7 @@ import { Injectable, NotFoundException } from "@nestjs/common"; -import { - DayExpenseResponse, - UpsertAction -} from "../types and interfaces/expenses.types.interfaces"; +import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; import { assertAndTrimComment, computeMileageAmount, @@ -26,9 +23,9 @@ import { export class ExpensesCommandService extends BaseApprovalService { constructor( prisma: PrismaService, - private readonly bankCodesRepo: BankCodesRepo, + private readonly bankCodesRepo: BankCodesRepo, private readonly timesheetsRepo: TimesheetsRepo, - private readonly employeesRepo: EmployeesRepo, + private readonly employeesRepo: EmployeesRepo, ) { super(prisma); } protected get delegate() { @@ -47,7 +44,7 @@ export class ExpensesCommandService extends BaseApprovalService { //-------------------- Master CRUD function -------------------- readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto, - ): Promise<{ action:UpsertAction; day: DayExpenseResponse[] }> => { + ): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => { //validates if there is an existing expense, at least 1 old or new const { old_expense, new_expense } = dto ?? {}; @@ -68,7 +65,7 @@ export class ExpensesCommandService extends BaseApprovalService { const timesheet_id = await this.ensureTimesheetForDate(employee_id, dateOnly); return this.prisma.$transaction(async (tx) => { - const loadDay = async (): Promise => { + const loadDay = async (): Promise => { const rows = await tx.expenses.findMany({ where: { timesheet_id: timesheet_id, @@ -186,7 +183,7 @@ export class ExpensesCommandService extends BaseApprovalService { }); } await tx.expenses.delete({where: { id: existing.id } }); - action = 'deleted'; + action = 'delete'; } //-------------------- CREATE -------------------- else if (!old_expense && new_expense) { @@ -203,7 +200,7 @@ export class ExpensesCommandService extends BaseApprovalService { is_approved: false, }, }); - action = 'created'; + action = 'create'; } //-------------------- UPDATE -------------------- else if(old_expense && new_expense) { @@ -227,7 +224,7 @@ export class ExpensesCommandService extends BaseApprovalService { attachment: new_exp.attachment, }, }); - action = 'updated'; + action = 'update'; } else { throw new BadRequestException('Invalid upsert combination'); @@ -310,7 +307,7 @@ export class ExpensesCommandService extends BaseApprovalService { comment: string; is_approved: boolean; bank_code: { type: string } | null; - }): DayExpenseResponse => mapDbExpenseToDayResponse(row); + }): ExpenseResponse => mapDbExpenseToDayResponse(row); } \ No newline at end of file diff --git a/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts index e567070..84a82dc 100644 --- a/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts +++ b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts @@ -1,6 +1,6 @@ -export type UpsertAction = 'created' | 'updated' | 'deleted'; +export type UpsertAction = 'create' | 'update' | 'delete'; -export interface DayExpenseResponse { +export interface ExpenseResponse { date: string; type: string; amount: number; @@ -9,6 +9,5 @@ export interface DayExpenseResponse { }; export type UpsertExpenseResult = { - action: UpsertAction; - day: DayExpenseResponse[] + expenses: ExpenseResponse[] }; \ 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 e6b8e8e..15311a5 100644 --- a/src/modules/leave-requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave-requests/controllers/leave-requests.controller.ts @@ -1,13 +1,30 @@ -import { Controller } from "@nestjs/common"; -import { LeaveRequestsService } from "../services/leave-requests.service"; +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"; @ApiTags('Leave Requests') @ApiBearerAuth('access-token') // @UseGuards() @Controller('leave-requests') export class LeaveRequestController { - constructor(private readonly leave_service: LeaveRequestsService){} + constructor(private readonly leave_service: HolidayLeaveRequestsService){} + + @Post('holiday') + async upsertHoliday(@Body() dto: UpsertHolidayDto) { + const { action, leave_requests } = await this.leave_service.handleHoliday(dto); + return { action, leave_requests }; + } + + //TODO: + /* + @Get('archive') + findAllArchived(){...} + + @Get('archive/:id') + findOneArchived(id){...} + */ + } diff --git a/src/modules/leave-requests/dtos/upsert-holiday.dto.ts b/src/modules/leave-requests/dtos/upsert-holiday.dto.ts index d1f0f51..376b7a4 100644 --- a/src/modules/leave-requests/dtos/upsert-holiday.dto.ts +++ b/src/modules/leave-requests/dtos/upsert-holiday.dto.ts @@ -1,9 +1,11 @@ +import { LeaveApprovalStatus } from "@prisma/client"; import { Type } from "class-transformer"; import { ArrayNotEmpty, ArrayUnique, IsArray, IsEmail, + IsEnum, IsIn, IsISO8601, IsNumber, @@ -39,4 +41,8 @@ export class UpsertHolidayDto { @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/leave-requests.module.ts b/src/modules/leave-requests/leave-requests.module.ts index 7762846..2249f21 100644 --- a/src/modules/leave-requests/leave-requests.module.ts +++ b/src/modules/leave-requests/leave-requests.module.ts @@ -1,13 +1,19 @@ +import { PrismaService } from "src/prisma/prisma.service"; +import { HolidayService } from "../business-logics/services/holiday.service"; import { LeaveRequestController } from "./controllers/leave-requests.controller"; -import { LeaveRequestsService } from "./services/leave-requests.service"; +import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service"; import { Module } from "@nestjs/common"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; @Module({ imports: [BusinessLogicsModule], controllers: [LeaveRequestController], - providers: [LeaveRequestsService], - exports: [LeaveRequestsService], + providers: [ + HolidayService, + HolidayLeaveRequestsService, + PrismaService, + ], + exports: [HolidayLeaveRequestsService], }) export class LeaveRequestsModule {} \ No newline at end of file diff --git a/src/modules/leave-requests/services/leave-requests.service.ts b/src/modules/leave-requests/services/holiday-leave-requests.service.ts similarity index 50% rename from src/modules/leave-requests/services/leave-requests.service.ts rename to src/modules/leave-requests/services/holiday-leave-requests.service.ts index a325965..3e5fbcb 100644 --- a/src/modules/leave-requests/services/leave-requests.service.ts +++ b/src/modules/leave-requests/services/holiday-leave-requests.service.ts @@ -1,46 +1,37 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { LeaveTypes, LeaveRequestsArchive } from "@prisma/client"; -import { PrismaService } from "src/prisma/prisma.service"; -import { HolidayService } from "src/modules/business-logics/services/holiday.service"; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; -import { UpsertHolidayDto, HolidayUpsertAction } from "../dtos/upsert-holiday.dto"; -import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; -import { mapRowToView } from "../mappers/leave-requests.mapper"; -import { mapArchiveRowToViewWithDays } from "../utils/leave-request.transform"; -import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select"; -import { leaveRequestsSelect } from "../utils/leave-requests.select"; +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 { HolidayUpsertAction, UpsertHolidayDto } from '../dtos/upsert-holiday.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 LeaveRequestsService { +export class HolidayLeaveRequestsService { constructor( private readonly prisma: PrismaService, private readonly holidayService: HolidayService, + private readonly shiftsCommand: ShiftsCommandService, ) {} - //-------------------- 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; - } + // --------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------- - 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[] }> { + async handleHoliday(dto: UpsertHolidayDto): Promise { switch (dto.action) { case 'create': return this.createHoliday(dto); @@ -53,7 +44,11 @@ export class LeaveRequestsService { } } - private async createHoliday(dto: UpsertHolidayDto): Promise<{ action: 'create'; leave_requests: LeaveRequestViewDto[] }> { + // --------------------------------------------------------------------- + // Create + // --------------------------------------------------------------------- + + private async createHoliday(dto: UpsertHolidayDto): Promise { const email = dto.email.trim(); const employeeId = await this.resolveEmployeeIdByEmail(email); const bankCode = await this.resolveHolidayBankCode(); @@ -63,8 +58,10 @@ export class LeaveRequestsService { } const created: LeaveRequestViewDto[] = []; + for (const isoDate of dates) { const date = toDateOnly(isoDate); + const existing = await this.prisma.leaveRequests.findUnique({ where: { leave_per_employee_date: { @@ -76,7 +73,7 @@ export class LeaveRequestsService { select: { id: true }, }); if (existing) { - throw new BadRequestException(`A holiday request already exists for ${isoDate}`); + throw new BadRequestException(`Holiday request already exists for ${isoDate}`); } const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier); @@ -87,19 +84,29 @@ export class LeaveRequestsService { leave_type: LeaveTypes.HOLIDAY, date, comment: dto.comment ?? '', - approval_status: undefined, requested_hours: dto.requested_hours ?? 8, payable_hours: payable, + approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, }, select: leaveRequestsSelect, }); + + 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); + } + created.push({ ...mapRowToView(row), action: 'create' }); } return { action: 'create', leave_requests: created }; } - private async updateHoliday(dto: UpsertHolidayDto): Promise<{ action: 'update'; leave_requests: LeaveRequestViewDto[] }> { + // --------------------------------------------------------------------- + // Update + // --------------------------------------------------------------------- + + private async updateHoliday(dto: UpsertHolidayDto): Promise { const email = dto.email.trim(); const employeeId = await this.resolveEmployeeIdByEmail(email); const bankCode = await this.resolveHolidayBankCode(); @@ -109,8 +116,10 @@ export class LeaveRequestsService { } const updated: LeaveRequestViewDto[] = []; + for (const isoDate of dates) { const date = toDateOnly(isoDate); + const existing = await this.prisma.leaveRequests.findUnique({ where: { leave_per_employee_date: { @@ -126,23 +135,43 @@ export class LeaveRequestsService { } 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 ?? undefined, + 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 }; } - private async deleteHoliday(dto: UpsertHolidayDto): Promise<{ action: 'delete'; leave_requests: LeaveRequestViewDto[] }> { + // --------------------------------------------------------------------- + // Delete + // --------------------------------------------------------------------- + + private async deleteHoliday(dto: UpsertHolidayDto): Promise { const email = dto.email.trim(); const employeeId = await this.resolveEmployeeIdByEmail(email); const dates = normalizeDates(dto.dates); @@ -164,48 +193,118 @@ export class LeaveRequestsService { 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 })); + const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' })); return { action: 'delete', leave_requests: deleted }; } - //-------------------- archival -------------------- - async archiveExpired(): Promise { - // TODO: adjust logic to the new LeaveRequests structure - } + // --------------------------------------------------------------------- + // Shift synchronisation + // --------------------------------------------------------------------- - async findAllArchived(): Promise { - return this.prisma.leaveRequestsArchive.findMany(); - } + private async syncHolidayShift( + email: string, + employeeId: number, + isoDate: string, + hours: number, + comment?: string, + ) { + if (hours <= 0) return; - 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`); + const durationMinutes = Math.round(hours * 60); + if (durationMinutes > 8 * 60) { + throw new BadRequestException('Holiday hours cannot exceed 8 hours.'); } - const emp = await this.prisma.employees.findUnique({ - where: { id: row.employee_id }, - select: { - user: { - select: { - email: true, - first_name: true, - last_name: true, - }, - }, + 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 ?? '', }, }); - const email = emp?.user.email ?? ''; - const fullName = emp ? `${emp.user.first_name} ${emp.user.last_name}` : ''; + } - return mapArchiveRowToViewWithDays(row, email, fullName); + 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; } }