diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index dad070b..83b53ec 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -3,7 +3,7 @@ "paths": { "/": { "get": { - "operationId": "ShiftsValidationController_getSummary", + "operationId": "ShiftsOverviewController_getSummary", "parameters": [], "responses": { "200": { @@ -11,7 +11,7 @@ } }, "tags": [ - "ShiftsValidation" + "ShiftsOverview" ] } }, @@ -739,6 +739,34 @@ ] } }, + "/Expenses/{id}/approval": { + "patch": { + "operationId": "ExpensesController_approve", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "access-token": [] + } + ], + "tags": [ + "Expenses" + ] + } + }, "/shifts": { "post": { "operationId": "ShiftsController_create", @@ -933,9 +961,37 @@ ] } }, + "/shifts/{id}/approval": { + "patch": { + "operationId": "ShiftsController_approve", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "access-token": [] + } + ], + "tags": [ + "Shifts" + ] + } + }, "/export.csv": { "get": { - "operationId": "ShiftsValidationController_exportCsv", + "operationId": "ShiftsOverviewController_exportCsv", "parameters": [], "responses": { "200": { @@ -943,7 +999,7 @@ } }, "tags": [ - "ShiftsValidation" + "ShiftsOverview" ] } }, @@ -1288,6 +1344,29 @@ ] } }, + "/exports/csv": { + "get": { + "operationId": "CsvExportController_exportCsv", + "parameters": [ + { + "name": "period", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "CsvExport" + ] + } + }, "/customers": { "post": { "operationId": "CustomersController_create", diff --git a/prisma/migrations/20250806151558_adding_validation_status_to_shifts_enum_wedding_added/migration.sql b/prisma/migrations/20250806151558_adding_validation_status_to_shifts_enum_wedding_added/migration.sql new file mode 100644 index 0000000..6944f34 --- /dev/null +++ b/prisma/migrations/20250806151558_adding_validation_status_to_shifts_enum_wedding_added/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "shifts" ADD COLUMN "is_approved" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3d317ee..7adc6f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -175,6 +175,7 @@ model Shifts { date DateTime @db.Date start_time DateTime @db.Time(0) end_time DateTime @db.Time(0) + is_approved Boolean @default(false) archive ShiftsArchive[] @relation("ShiftsToArchive") @@ -283,7 +284,8 @@ enum LeaveTypes { BEREAVEMENT // deuil de famille PARENTAL // maternite/paternite/adoption LEGAL // obligations legales comme devoir de juree - + WEDDING // mariage + @@map("leave_types") } diff --git a/src/app.module.ts b/src/app.module.ts index c22aaab..96b4aa9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,8 +18,8 @@ import { ArchivalModule } from './modules/archival/archival.module'; import { BankCodesModule } from './modules/bank-codes/bank-codes.module'; import { OvertimeService } from './modules/business-logics/services/overtime.service'; import { BusinessLogicsModule } from './modules/business-logics/business-logics.module'; -import { ShiftsValidationModule } from './modules/shifts/validation/shifts-validation.module'; import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module'; +import { CsvExportModule } from './modules/exports/csv-exports.module'; @Module({ imports: [ @@ -28,6 +28,7 @@ import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.mod AuthenticationModule, BankCodesModule, BusinessLogicsModule, + CsvExportModule, CustomersModule, EmployeesModule, ExpensesModule, @@ -37,7 +38,6 @@ import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.mod PayperiodsModule, PrismaModule, ShiftsModule, - ShiftsValidationModule, TimesheetsModule, UsersModule, ], diff --git a/src/common/shared/base-approval.service.ts b/src/common/shared/base-approval.service.ts new file mode 100644 index 0000000..d3e3931 --- /dev/null +++ b/src/common/shared/base-approval.service.ts @@ -0,0 +1,26 @@ +import { NotFoundException } from "@nestjs/common"; +import { PrismaService } from "src/prisma/prisma.service"; + +//abstract class for approving or rejecting a shift, expense, timesheet or pay-period +export abstract class BaseApprovalService { + protected constructor(protected readonly prisma: PrismaService) {} + + //returns the corresponding Prisma delegate + protected abstract get delegate(): { + update(args: {where: {id: number }; + data: { is_approved: boolean } + }): Promise; + }; + + //standard update Aproval + async updateApproval(id: number, isApproved: boolean): Promise { + const entity = await this.delegate.update({ + where: { id }, + data: { is_approved: isApproved }, + }); + + if(!entity) throw new NotFoundException(`Entity #${id} not found`); + + return entity; + } +} \ No newline at end of file diff --git a/src/common/shared/build-prisma-where.ts b/src/common/shared/build-prisma-where.ts new file mode 100644 index 0000000..1b8f8fc --- /dev/null +++ b/src/common/shared/build-prisma-where.ts @@ -0,0 +1,21 @@ +//Prisma 'where' clause for DTO filters +export function buildPrismaWhere>(dto: T): Record { + const where: Record = {}; + + for (const [key,value] of Object.entries(dto)) { + if (value === undefined || value === null) continue; + + if (key.endsWith('_contains')) { + const field = key.slice(0, - '_contains'.length); + where[field] = { constains: value }; + } else if (key === 'start_date' || key === 'end_date') { + where.date = where.date || {}; + const op = key === 'start_date' ? 'gte' : 'lte'; + where.date[op] = new Date(value); + } else { + where[key] = value; + } + } + + return where; +} \ No newline at end of file diff --git a/src/common/utils/date-utils.ts b/src/common/utils/date-utils.ts new file mode 100644 index 0000000..e8aeb69 --- /dev/null +++ b/src/common/utils/date-utils.ts @@ -0,0 +1,50 @@ + +//lenght of a shift, rouded to nearest 'x' minute +export function computeHours(start: Date, end: Date, roundToMinutes?: number): number { + const diffMs = end.getTime() - start.getTime(); + const totalMinutes = diffMs / 60000; + const minutes = roundToMinutes ? + Math.round(totalMinutes / roundToMinutes) * roundToMinutes : + totalMinutes; + return +(minutes / 60).toFixed(2); +} + +//round the amount of hours to quarter +export function roundToQuarterHour(hours: number): number { + return Math.round(hours *4) / 4; +} + +//calculate the number of the week (1 or 2) +export function computeWeekNumber(periodStart: Date, targetDate: Date): number { + const days = Math.floor( targetDate.getTime() - periodStart.getTime()) / + (1000 * 60 * 60 * 24); + return Math.floor(days / 7) +1; +} + +//Date format YYY-MM-DD +export function formatDateISO(d:Date): string { + return d.toISOString().split('T')[0]; +} + +//fetch firts day of the week (Sunday) +export function getWeekStart(date:Date, firstDayOfWeek = 0): Date { + const d = new Date(date); + const day = d.getDay(); + const diff = (day < firstDayOfWeek ? 7 : 0) + (day - firstDayOfWeek); + d.setDate(d.getDate() - diff); + d.setHours(0,0,0,0); + return d; +} + +//fetch last day of the week (Saturday) +export function getWeekEnd(startOfWeek: Date): Date { + const d = new Date(startOfWeek); + d.setDate(d.getDate() + 6); + d.setHours(23,59,59,999); + return d; +} + +//returns january 1st of the selected date's year +export function getYearStart(date:Date): Date { + return new Date(date.getFullYear(),0,1,0,0,0,0); +} diff --git a/src/modules/authentication/services/express-session.serializer.ts b/src/modules/authentication/services/express-session.serializer.ts new file mode 100644 index 0000000..3508ced --- /dev/null +++ b/src/modules/authentication/services/express-session.serializer.ts @@ -0,0 +1,18 @@ +import { PassportSerializer } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; + +@Injectable() +export class ExpressSessionSerializer extends PassportSerializer { + serializeUser(user: any, done: (err: any, user: any) => void): any { + if (!user){ + done(new UnauthorizedException('Serialize user error'), user); + } + done(null, user); + } + deserializeUser(payload: any, done: (err: any, payload: string) => void): any { + if (!payload){ + done(new UnauthorizedException('Deserialize user error'), payload); + } + done(null, payload); + } +} \ No newline at end of file diff --git a/src/modules/business-logics/services/after-hours.service.ts b/src/modules/business-logics/services/after-hours.service.ts index 0f857fd..bc0e271 100644 --- a/src/modules/business-logics/services/after-hours.service.ts +++ b/src/modules/business-logics/services/after-hours.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "../../../prisma/prisma.service"; -//THIS SERVICE IS NOT USED RULES TO BE DETERMINED WITH MIKE/HR/ACCOUNTING +//THIS SERVICE IS NOT USED, RULES TO BE DETERMINED WITH MIKE/HR/ACCOUNTING @Injectable() export class AfterHoursService { private readonly logger = new Logger(AfterHoursService.name); diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index cf898ad..2d377b6 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "../../../prisma/prisma.service"; +import { computeHours, getWeekStart } from "src/common/utils/date-utils"; @Injectable() export class HolidayService { @@ -7,32 +8,15 @@ export class HolidayService { constructor(private readonly prisma: PrismaService) {} - //return the sunday of the current week that includes the holiday - private getWeekStart(date: Date): Date { - const day = new Date(date); - const offset = day.getDay(); - day.setDate(day.getDate() - offset); - day.setHours(0,0,0,0); - return day; - } - - //rounds minutes to 5s - private computeHours(start: Date, end: Date): number { - const durationMS = end.getTime() - start.getTime(); - const totalMinutes = durationMS / 60000; - const rounded = Math.round(totalMinutes / 5) * 5; - return rounded / 60; - } - private async computeHoursPrevious4Weeks(employeeId: number, holidayDate: Date): Promise { //sets the end of the window to 1ms before the week with the holiday - const holidayWeekStart = this.getWeekStart(holidayDate); + const holidayWeekStart = getWeekStart(holidayDate); const windowEnd = new Date(holidayWeekStart.getTime() - 1); //sets the start of the window to 28 days ( 4 completed weeks ) before the week with the holiday const windowStart = new Date(windowEnd.getTime() - 28 * 24 * 60 * 60000 + 1 ) const validCodes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700']; - //fetches all shift of the employee in said window ( 4 completed weeks ) + //fetches all shift of the employee in said window ( 4 previous completed weeks ) const shifts = await this.prisma.shifts.findMany({ where: { timesheet: { employee_id: employeeId } , date: { gte: windowStart, lte: windowEnd }, @@ -41,16 +25,16 @@ export class HolidayService { select: { date: true, start_time: true, end_time: true }, }); - const totalHours = shifts.map(s => this.computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0); + const totalHours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0); const dailyHours = totalHours / 20; return dailyHours; } async calculateHolidayPay( employeeId: number, holidayDate: Date, modifier: number): Promise { - const hours = await this. computeHoursPrevious4Weeks(employeeId, holidayDate); + const hours = await this.computeHoursPrevious4Weeks(employeeId, holidayDate); const dailyRate = Math.min(hours, 8); - this.logger.debug(`Holiday pay calculation: hours=${hours.toFixed(2)}`); + this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`); return dailyRate * modifier; } } \ No newline at end of file diff --git a/src/modules/business-logics/services/overtime.service.ts b/src/modules/business-logics/services/overtime.service.ts index 8107c11..b58c511 100644 --- a/src/modules/business-logics/services/overtime.service.ts +++ b/src/modules/business-logics/services/overtime.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils'; @Injectable() export class OvertimeService { @@ -10,47 +11,18 @@ export class OvertimeService { constructor(private prisma: PrismaService) {} - // calculate decimal hours rounded to nearest 5 min - computedHours(start: Date, end: Date): number { - const durationMs = end.getTime() - start.getTime(); - const totalMinutes = durationMs / 60000; - - //rounded to 5 min - const rounded = Math.round(totalMinutes / 5) * 5; - const hours = rounded / 60; - this.logger.debug(`computedHours: raw=${totalMinutes.toFixed(1)}min rounded = ${rounded}min (${hours.toFixed(2)}h)`); - return hours; - } - //calculate Daily overtime getDailyOvertimeHours(start: Date, end: Date): number { - const hours = this.computedHours(start, end); + const hours = computeHours(start, end, 5); const overtime = Math.max(0, hours - this.dailyMax); this.logger.debug(`getDailyOvertimeHours : ${overtime.toFixed(2)}h (threshold ${this.dailyMax})`); return overtime; } - //sets first day of the week to be sunday - private getWeekStart(date:Date): Date { - const d = new Date(date); - const day = d.getDay(); // return sunday = 0, monday = 1, etc - d.setDate(d.getDate() - day); - d.setHours(0,0,0,0,); // puts start of the week at sunday morning at 00:00 - return d; - } - - //sets last day of the week to be saturday - private getWeekEnd(startDate:Date): Date { - const d = new Date(startDate); - d.setDate(d.getDate() +6); //sets last day to be saturday - d.setHours(23,59,59,999); //puts end of the week at saturday night at 00:00 minus 1ms - return d; - } - //calculate Weekly overtime async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise { - const weekStart = this.getWeekStart(refDate); - const weekEnd = this.getWeekEnd(weekStart); + const weekStart = getWeekStart(refDate); + const weekEnd = getWeekEnd(weekStart); //fetches all shifts containing hours const shifts = await this.prisma.shifts.findMany({ @@ -63,7 +35,7 @@ export class OvertimeService { }); //calculate total hours of those shifts minus weekly Max to find total overtime hours - const total = shifts.map(shift => this.computedHours(shift.start_time, shift.end_time)) + const total = shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5)) .reduce((sum, hours)=> sum+hours, 0); const overtime = Math.max(0, total - this.weeklyMax); diff --git a/src/modules/business-logics/services/sick-leave.service.ts b/src/modules/business-logics/services/sick-leave.service.ts index a36bf29..1c8d9ee 100644 --- a/src/modules/business-logics/services/sick-leave.service.ts +++ b/src/modules/business-logics/services/sick-leave.service.ts @@ -1,5 +1,6 @@ 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 { @@ -7,10 +8,10 @@ export class SickLeaveService { private readonly logger = new Logger(SickLeaveService.name); - async calculateSickLeavePay(employeeId: number, startDate: Date, daysRequested: number, modifier: number): Promise { + async calculateSickLeavePay(employeeId: number, referenceDate: Date, daysRequested: number, modifier: number): Promise { //sets the year to jan 1st to dec 31st - const periodStart = new Date(startDate.getFullYear(), 0, 1); - const periodEnd = startDate; + const periodStart = getYearStart(referenceDate); + const periodEnd = referenceDate; //fetches all shifts of a selected employee const shifts = await this.prisma.shifts.findMany({ @@ -54,7 +55,7 @@ export class SickLeaveService { const payableDays = Math.min(acquiredDays, daysRequested); const rawHours = payableDays * 8 * modifier; - const rounded = Math.round(rawHours * 4) / 4; + const rounded = roundToQuarterHour(rawHours) this.logger.debug(`Sick leave pay: days= ${payableDays}, modifier= ${modifier}, hours= ${rounded}`); return rounded; } diff --git a/src/modules/expenses/controllers/expenses.controller.ts b/src/modules/expenses/controllers/expenses.controller.ts index 33d7b72..a86564e 100644 --- a/src/modules/expenses/controllers/expenses.controller.ts +++ b/src/modules/expenses/controllers/expenses.controller.ts @@ -1,19 +1,24 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; import { ExpensesService } from "../services/expenses.service"; -import { CreateExpenseDto } from "../dtos/create-expense"; +import { CreateExpenseDto } from "../dtos/create-expense.dto"; import { Expenses } from "@prisma/client"; import { Roles as RoleEnum } from '.prisma/client'; -import { UpdateExpenseDto } from "../dtos/update-expense"; +import { UpdateExpenseDto } from "../dtos/update-expense.dto"; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { ExpenseEntity } from "../dtos/swagger-entities/expenses.entity"; +import { ExpensesApprovalService } from "../services/expenses-approval.service"; +import { SearchExpensesDto } from "../dtos/search-expense.dto"; @ApiTags('Expenses') @ApiBearerAuth('access-token') // @UseGuards() @Controller('Expenses') export class ExpensesController { - constructor(private readonly expensesService: ExpensesService) {} + constructor( + private readonly expensesService: ExpensesService, + private readonly expensesApprovalService: ExpensesApprovalService, + ) {} @Post() @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) @@ -29,8 +34,9 @@ export class ExpensesController { @ApiOperation({ summary: 'Find all expenses' }) @ApiResponse({ status: 201, description: 'List of expenses found',type: ExpenseEntity, isArray: true }) @ApiResponse({ status: 400, description: 'List of expenses not found' }) - findAll(): Promise { - return this.expensesService.findAll(); + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + findAll(@Query() filters: SearchExpensesDto): Promise { + return this.expensesService.findAll(filters); } @Get(':id') @@ -60,4 +66,10 @@ export class ExpensesController { return this.expensesService.remove(id); } + @Patch(':id/approval') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { + return this.expensesApprovalService.updateApproval(id, isApproved); + } + } \ No newline at end of file diff --git a/src/modules/expenses/dtos/create-expense.ts b/src/modules/expenses/dtos/create-expense.dto.ts similarity index 100% rename from src/modules/expenses/dtos/create-expense.ts rename to src/modules/expenses/dtos/create-expense.dto.ts diff --git a/src/modules/expenses/dtos/search-expense.dto.ts b/src/modules/expenses/dtos/search-expense.dto.ts new file mode 100644 index 0000000..5058167 --- /dev/null +++ b/src/modules/expenses/dtos/search-expense.dto.ts @@ -0,0 +1,26 @@ +import { Type } from "class-transformer"; +import { IsDateString, IsInt, IsOptional, IsString } from "class-validator"; + +export class SearchExpensesDto { + @IsOptional() + @Type(()=> Number) + @IsInt() + timesheet_id?: number; + + @IsOptional() + @Type(()=> Number) + @IsInt() + bank_code_id?: number; + + @IsOptional() + @IsString() + description_contains?: string; + + @IsOptional() + @IsDateString() + start_date: string; + + @IsOptional() + @IsDateString() + end_date: string; +} \ No newline at end of file diff --git a/src/modules/expenses/dtos/update-expense.ts b/src/modules/expenses/dtos/update-expense.dto.ts similarity index 67% rename from src/modules/expenses/dtos/update-expense.ts rename to src/modules/expenses/dtos/update-expense.dto.ts index dda40eb..1b2426f 100644 --- a/src/modules/expenses/dtos/update-expense.ts +++ b/src/modules/expenses/dtos/update-expense.dto.ts @@ -1,4 +1,4 @@ import { PartialType } from "@nestjs/swagger"; -import { CreateExpenseDto } from "./create-expense"; +import { CreateExpenseDto } from "./create-expense.dto"; export class UpdateExpenseDto extends PartialType(CreateExpenseDto) {} \ No newline at end of file diff --git a/src/modules/expenses/expenses.module.ts b/src/modules/expenses/expenses.module.ts index 527d783..e18e154 100644 --- a/src/modules/expenses/expenses.module.ts +++ b/src/modules/expenses/expenses.module.ts @@ -1,13 +1,13 @@ -import { PrismaService } from "src/prisma/prisma.service"; import { ExpensesController } from "./controllers/expenses.controller"; import { Module } from "@nestjs/common"; import { ExpensesService } from "./services/expenses.service"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; +import { ExpensesApprovalService } from "./services/expenses-approval.service"; @Module({ imports: [BusinessLogicsModule], controllers: [ExpensesController], - providers: [ExpensesService], + providers: [ExpensesService, ExpensesApprovalService], exports: [ ExpensesService ], }) diff --git a/src/modules/expenses/services/expenses-approval.service.ts b/src/modules/expenses/services/expenses-approval.service.ts new file mode 100644 index 0000000..412cf1c --- /dev/null +++ b/src/modules/expenses/services/expenses-approval.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from "@nestjs/common"; +import { Expenses } from "@prisma/client"; +import { BaseApprovalService } from "src/common/shared/base-approval.service"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class ExpensesApprovalService extends BaseApprovalService { + constructor(prisma: PrismaService) { super(prisma); } + + protected get delegate() { + return this.prisma.expenses; + } +} \ No newline at end of file diff --git a/src/modules/expenses/services/expenses.service.ts b/src/modules/expenses/services/expenses.service.ts index 48b05b3..9d1515c 100644 --- a/src/modules/expenses/services/expenses.service.ts +++ b/src/modules/expenses/services/expenses.service.ts @@ -1,9 +1,11 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; -import { CreateExpenseDto } from "../dtos/create-expense"; +import { CreateExpenseDto } from "../dtos/create-expense.dto"; import { Expenses, ExpensesArchive } from "@prisma/client"; -import { UpdateExpenseDto } from "../dtos/update-expense"; +import { UpdateExpenseDto } from "../dtos/update-expense.dto"; import { MileageService } from "src/modules/business-logics/services/mileage.service"; +import { SearchExpensesDto } from "../dtos/search-expense.dto"; +import { buildPrismaWhere } from "src/common/shared/build-prisma-where"; @Injectable() export class ExpensesService { @@ -42,10 +44,10 @@ export class ExpensesService { }) } - findAll(): Promise { - return this.prisma.expenses.findMany({ - include: { timesheet: { include: { employee: { include: { user: true } } } } }, - }); + async findAll(filters: SearchExpensesDto): Promise { + const where = buildPrismaWhere(filters); + const expenses = await this.prisma.expenses.findMany({ where }) + return expenses; } async findOne(id: number): Promise { diff --git a/src/modules/leave-requests/controllers/leave-requests.controller.ts b/src/modules/leave-requests/controllers/leave-requests.controller.ts index 7f4163f..3b770e3 100644 --- a/src/modules/leave-requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave-requests/controllers/leave-requests.controller.ts @@ -1,12 +1,13 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common"; +import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseEnumPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; import { LeaveRequestsService } from "../services/leave-requests.service"; import { CreateLeaveRequestsDto } from "../dtos/create-leave-requests.dto"; import { LeaveRequests } from "@prisma/client"; import { UpdateLeaveRequestsDto } from "../dtos/update-leave-requests.dto"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; -import { Roles as RoleEnum } from '.prisma/client'; +import { LeaveApprovalStatus, Roles as RoleEnum } from '.prisma/client'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { LeaveRequestEntity } from "../dtos/swagger-entities/leave-requests.entity"; +import { SearchLeaveRequestsDto } from "../dtos/search-leave-requests.dto"; @ApiTags('Leave Requests') @ApiBearerAuth('access-token') @@ -29,8 +30,9 @@ export class LeaveRequestController { @ApiOperation({summary: 'Find all leave request' }) @ApiResponse({ status: 201, description: 'List of Leave requests found',type: LeaveRequestEntity, isArray: true }) @ApiResponse({ status: 400, description: 'List of leave request not found' }) - findAll(): Promise { - return this.leaveRequetsService.findAll(); + @UsePipes(new ValidationPipe({transform: true, whitelist: true})) + findAll(@Query() filters: SearchLeaveRequestsDto): Promise<(LeaveRequests & {daysRequested:number; cost: number})[]> { + return this.leaveRequetsService.findAll(filters); } @Get(':id') @@ -59,4 +61,14 @@ export class LeaveRequestController { remove(@Param('id', ParseIntPipe) id: number): Promise { return this.leaveRequetsService.remove(id); } -} \ No newline at end of file + + @Patch(':id/approval') + updateApproval( @Param('id', ParseIntPipe) id: number, + @Body('is_approved', ParseBoolPipe) isApproved: boolean): Promise { + const approvalStatus = isApproved ? + LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED; + return this.leaveRequetsService.update(id, { approval_status: approvalStatus }); + } + + } + diff --git a/src/modules/leave-requests/dtos/search-leave-requests.dto.ts b/src/modules/leave-requests/dtos/search-leave-requests.dto.ts new file mode 100644 index 0000000..cfa566b --- /dev/null +++ b/src/modules/leave-requests/dtos/search-leave-requests.dto.ts @@ -0,0 +1,27 @@ +import { LeaveApprovalStatus } from "@prisma/client"; +import { Type } from "class-transformer"; +import { IsOptional, IsInt, IsEnum, IsDateString } from "class-validator"; + +export class SearchLeaveRequestsDto { + @IsOptional() + @Type(()=> Number) + @IsInt() + employee_id?: number; + + @IsOptional() + @Type(()=> Number) + @IsInt() + bank_code_id?: number; + + @IsOptional() + @IsEnum(LeaveApprovalStatus) + approval_status?: LeaveApprovalStatus + + @IsOptional() + @IsDateString() + start_date?: Date; + + @IsOptional() + @IsDateString() + end_date?: Date; +} \ 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 2cd2bed..0be337e 100644 --- a/src/modules/leave-requests/services/leave-requests.service.ts +++ b/src/modules/leave-requests/services/leave-requests.service.ts @@ -6,6 +6,8 @@ import { UpdateLeaveRequestsDto } from "../dtos/update-leave-requests.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-requests.dto"; +import { buildPrismaWhere } from "src/common/shared/build-prisma-where"; @Injectable() export class LeaveRequestsService { @@ -30,8 +32,19 @@ export class LeaveRequestsService { }); } - async findAll(): Promise { + async findAll(filters: SearchLeaveRequestsDto): Promise { + const {start_date, end_date, ...otherFilters } = filters; + const where: Record = buildPrismaWhere(otherFilters); + + 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) }; + } + const list = await this.prisma.leaveRequests.findMany({ + where, include: { employee: { include: { user: true } }, bank_code: true, }, diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index 142c582..3d8ce3a 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -3,12 +3,14 @@ import { PayPeriodsService } from "./services/pay-periods.service"; import { PayPeriodsController } from "./controllers/pay-periods.controller"; import { Module } from "@nestjs/common"; import { PayPeriodsOverviewService } from "./services/pay-periods-overview.service"; +import { PayPeriodsApprovalService } from "./services/pay-periods-approval.service"; @Module({ imports: [PrismaModule], providers: [ PayPeriodsService, PayPeriodsOverviewService, + PayPeriodsApprovalService, ], controllers: [PayPeriodsController], }) diff --git a/src/modules/pay-periods/services/pay-periods-approval.service.ts b/src/modules/pay-periods/services/pay-periods-approval.service.ts new file mode 100644 index 0000000..66d080b --- /dev/null +++ b/src/modules/pay-periods/services/pay-periods-approval.service.ts @@ -0,0 +1,40 @@ +import { NotFoundException } from "@nestjs/common"; +import { TimesheetsApprovalService } from "src/modules/timesheets/services/timesheets-approval.service"; +import { PrismaService } from "src/prisma/prisma.service"; + +export class PayPeriodsApprovalService { + constructor( + private readonly prisma: PrismaService, + private readonly timesheetsApproval: TimesheetsApprovalService, + ) {} + + async approvaPayperdiod(periodNumber: number): Promise { + const period = await this.prisma.payPeriods.findUnique({ + where: { period_number: periodNumber }, + }); + if (!period) throw new NotFoundException(`PayPeriod #${periodNumber} not found`); + + //fetches timesheet of selected period if the timesheet as atleast 1 shift or 1 expense + const timesheetList = await this.prisma.timesheets.findMany({ + where: { + OR: [ + { shift: {some: { date: { gte: period.start_date, + lte: period.end_date, + }, + }}, + }, + { expense: { some: { date: { gte: period.start_date, + lte: period.end_date, + }, + }}, + }, + ], + }, + }); + + //approval of both timesheet (cascading to the approval of related shifts and expenses) + for(const timesheet of timesheetList) { + await this.timesheetsApproval.updateApproval(timesheet.id, true); + } + } +} \ No newline at end of file diff --git a/src/modules/pay-periods/services/pay-periods-overview.service.ts b/src/modules/pay-periods/services/pay-periods-overview.service.ts index cc3ce0c..3fc984c 100644 --- a/src/modules/pay-periods/services/pay-periods-overview.service.ts +++ b/src/modules/pay-periods/services/pay-periods-overview.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { EmployeePeriodOverviewDto } from "../dtos/overview-employee-period.dto"; import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto"; import { PrismaService } from "src/prisma/prisma.service"; +import { computeHours } from "src/common/utils/date-utils"; @Injectable() export class PayPeriodsOverviewService { @@ -40,7 +41,7 @@ export class PayPeriodsOverviewService { const user = employee_record.user; const employee_id = employee_record.user_id; const employee_name = `${user.first_name} ${user.last_name}`; - const hours = (shift.end_time.getTime() - shift.start_time.getTime() / 3600000); + const hours = computeHours(shift.start_time, shift.end_time); //check if employee had prior shifts and adds hours of found shift to the total hours if (map.has(employee_id)) { diff --git a/src/modules/pay-periods/services/pay-periods.service.ts b/src/modules/pay-periods/services/pay-periods.service.ts index 0202774..cca3068 100644 --- a/src/modules/pay-periods/services/pay-periods.service.ts +++ b/src/modules/pay-periods/services/pay-periods.service.ts @@ -1,10 +1,15 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { Injectable, NotFoundException, Param, ParseIntPipe, Patch } from "@nestjs/common"; import { PayPeriods } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; +import { PayPeriodsApprovalService } from "./pay-periods-approval.service"; +import { RolesAllowed } from "src/common/decorators/roles.decorators"; +import { Roles as RoleEnum } from '.prisma/client'; @Injectable() export class PayPeriodsService { - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly prisma: PrismaService, + private readonly payperiodsApprovalService: PayPeriodsApprovalService + ) {} async findAll(): Promise { return this.prisma.payPeriods.findMany({ @@ -32,4 +37,13 @@ export class PayPeriodsService { } return period; } + + @Patch(':periodNumber/approval') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + async approve(@Param('periodNumber', ParseIntPipe) periodNumber: number): Promise<{message:string}> { + await this.payperiodsApprovalService.approvaPayperdiod(periodNumber); + return {message: `Pay-period #${periodNumber} approved`}; + } + + } \ No newline at end of file diff --git a/src/modules/shifts/validation/controllers/shifts-validation.controller.ts b/src/modules/shifts/controllers/shifts-overview.controller.ts similarity index 77% rename from src/modules/shifts/validation/controllers/shifts-validation.controller.ts rename to src/modules/shifts/controllers/shifts-overview.controller.ts index 298808f..d1ef6d4 100644 --- a/src/modules/shifts/validation/controllers/shifts-validation.controller.ts +++ b/src/modules/shifts/controllers/shifts-overview.controller.ts @@ -1,21 +1,21 @@ import { Controller, Get, Header, Query } from "@nestjs/common"; -import { ShiftsValidationService, ValidationRow } from "../services/shifts-validation.service"; -import { GetShiftsValidationDto } from "../dtos/get-shifts-validation.dto"; +import { OverviewRow, ShiftsOverviewService } from "../services/shifts-overview.service"; +import { GetShiftsOverviewDto } from "../dtos/get-shifts-overview.dto"; @Controller() -export class ShiftsValidationController { - constructor(private readonly shiftsValidationService: ShiftsValidationService) {} +export class ShiftsOverviewController { + constructor(private readonly shiftsValidationService: ShiftsOverviewService) {} @Get() - async getSummary( @Query() query: GetShiftsValidationDto): Promise { - return this.shiftsValidationService.getSummary(query.periodId); + async getSummary( @Query() query: GetShiftsOverviewDto): Promise { + return this.shiftsValidationService.getSummary(query.period_id); } @Get('export.csv') @Header('Content-Type', 'text/csv; charset=utf-8') @Header('Content-Disposition', 'attachment; filename="shifts-validation.csv"') - async exportCsv(@Query() query: GetShiftsValidationDto): Promise{ - const rows = await this.shiftsValidationService.getSummary(query.periodId); + async exportCsv(@Query() query: GetShiftsOverviewDto): Promise{ + const rows = await this.shiftsValidationService.getSummary(query.period_id); //CSV Headers const header = [ diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index f3a0704..4666f01 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -1,63 +1,75 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; import { ShiftsService } from "../services/shifts.service"; import { Shifts } from "@prisma/client"; -import { CreateShiftDto } from "../dtos/create-shifts.dto"; -import { UpdateShiftsDto } from "../dtos/update-shifts.dto"; +import { CreateShiftDto } from "../dtos/create-shift.dto"; +import { UpdateShiftsDto } from "../dtos/update-shift.dto"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { Roles as RoleEnum } from '.prisma/client'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ShiftEntity } from "../dtos/swagger-entities/shift.entity"; +import { ShiftsApprovalService } from "../services/shifts-approval.service"; +import { SearchShiftsDto } from "../dtos/search-shifts.dto"; @ApiTags('Shifts') @ApiBearerAuth('access-token') // @UseGuards() @Controller('shifts') export class ShiftsController { - constructor(private readonly shiftsService: ShiftsService){} - - @Post() - @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Create shift' }) - @ApiResponse({ status: 201, description: 'Shift created',type: ShiftEntity }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateShiftDto): Promise { - return this.shiftsService.create(dto); - } + constructor( + private readonly shiftsService: ShiftsService, + private readonly shiftsApprovalService: ShiftsApprovalService, + ){} - @Get() - @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find all shifts' }) - @ApiResponse({ status: 201, description: 'List of shifts found',type: ShiftEntity, isArray: true }) - @ApiResponse({ status: 400, description: 'List of shifts not found' }) - findAll(): Promise { - return this.shiftsService.findAll(); - } - - @Get(':id') - @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find shift' }) - @ApiResponse({ status: 201, description: 'Shift found',type: ShiftEntity }) - @ApiResponse({ status: 400, description: 'Shift not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.shiftsService.findOne(id); - } - - @Patch(':id') - @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Update shift' }) - @ApiResponse({ status: 201, description: 'Shift updated',type: ShiftEntity }) - @ApiResponse({ status: 400, description: 'Shift not found' }) - update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateShiftsDto): Promise { - return this.shiftsService.update(id, dto); - } - - @Delete(':id') - @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Delete shift' }) - @ApiResponse({ status: 201, description: 'Shift deleted',type: ShiftEntity }) - @ApiResponse({ status: 400, description: 'Shift not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.shiftsService.remove(id); - } + @Post() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + @ApiOperation({ summary: 'Create shift' }) + @ApiResponse({ status: 201, description: 'Shift created',type: ShiftEntity }) + @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) + create(@Body() dto: CreateShiftDto): Promise { + return this.shiftsService.create(dto); + } + + @Get() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + @ApiOperation({ summary: 'Find all shifts' }) + @ApiResponse({ status: 201, description: 'List of shifts found',type: ShiftEntity, isArray: true }) + @ApiResponse({ status: 400, description: 'List of shifts not found' }) + @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + findAll(@Query() filters: SearchShiftsDto) { + return this.shiftsService.findAll(filters); + } + + @Get(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + @ApiOperation({ summary: 'Find shift' }) + @ApiResponse({ status: 201, description: 'Shift found',type: ShiftEntity }) + @ApiResponse({ status: 400, description: 'Shift not found' }) + findOne(@Param('id', ParseIntPipe) id: number): Promise { + return this.shiftsService.findOne(id); + } + + @Patch(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + @ApiOperation({ summary: 'Update shift' }) + @ApiResponse({ status: 201, description: 'Shift updated',type: ShiftEntity }) + @ApiResponse({ status: 400, description: 'Shift not found' }) + update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateShiftsDto): Promise { + return this.shiftsService.update(id, dto); + } + + @Delete(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + @ApiOperation({ summary: 'Delete shift' }) + @ApiResponse({ status: 201, description: 'Shift deleted',type: ShiftEntity }) + @ApiResponse({ status: 400, description: 'Shift not found' }) + remove(@Param('id', ParseIntPipe) id: number): Promise { + return this.shiftsService.remove(id); + } + + @Patch(':id/approval') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { + return this.shiftsApprovalService.updateApproval(id, isApproved); + } } \ No newline at end of file diff --git a/src/modules/shifts/dtos/create-shifts.dto.ts b/src/modules/shifts/dtos/create-shift.dto.ts similarity index 100% rename from src/modules/shifts/dtos/create-shifts.dto.ts rename to src/modules/shifts/dtos/create-shift.dto.ts diff --git a/src/modules/shifts/validation/dtos/get-shifts-validation.dto.ts b/src/modules/shifts/dtos/get-shifts-overview.dto.ts similarity index 72% rename from src/modules/shifts/validation/dtos/get-shifts-validation.dto.ts rename to src/modules/shifts/dtos/get-shifts-overview.dto.ts index 44b656b..e8ccdd2 100644 --- a/src/modules/shifts/validation/dtos/get-shifts-validation.dto.ts +++ b/src/modules/shifts/dtos/get-shifts-overview.dto.ts @@ -1,10 +1,10 @@ import { Type } from "class-transformer"; import { IsInt, Min, Max } from "class-validator"; -export class GetShiftsValidationDto { +export class GetShiftsOverviewDto { @Type(()=> Number) @IsInt() @Min(1) @Max(26) - periodId: number; + period_id: number; } \ No newline at end of file diff --git a/src/modules/shifts/dtos/search-shifts.dto.ts b/src/modules/shifts/dtos/search-shifts.dto.ts new file mode 100644 index 0000000..b1b772d --- /dev/null +++ b/src/modules/shifts/dtos/search-shifts.dto.ts @@ -0,0 +1,29 @@ +import { Type } from "class-transformer"; +import { IsDateString, IsInt, IsOptional, IsString } from "class-validator"; + +export class SearchShiftsDto { + @IsOptional() + @Type(()=> Number) + @IsInt() + employee_id?: number; + + @IsOptional() + @Type(()=> Number) + @IsInt() + bank_code_id?: number; + + @IsOptional() + @IsString() + description_contains?: string; + + @IsOptional() + @IsDateString() + end_date?: string; + + @IsOptional() + @Type(()=> Number) + @IsInt() + timesheet_id?: number; + + +} \ No newline at end of file diff --git a/src/modules/shifts/dtos/update-shifts.dto.ts b/src/modules/shifts/dtos/update-shift.dto.ts similarity index 67% rename from src/modules/shifts/dtos/update-shifts.dto.ts rename to src/modules/shifts/dtos/update-shift.dto.ts index 8b10139..53f033f 100644 --- a/src/modules/shifts/dtos/update-shifts.dto.ts +++ b/src/modules/shifts/dtos/update-shift.dto.ts @@ -1,4 +1,4 @@ import { PartialType } from "@nestjs/swagger"; -import { CreateShiftDto } from "./create-shifts.dto"; +import { CreateShiftDto } from "./create-shift.dto"; export class UpdateShiftsDto extends PartialType(CreateShiftDto){} \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-approval.service.ts b/src/modules/shifts/services/shifts-approval.service.ts new file mode 100644 index 0000000..5e07e5b --- /dev/null +++ b/src/modules/shifts/services/shifts-approval.service.ts @@ -0,0 +1,13 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Shifts } from "@prisma/client"; +import { BaseApprovalService } from "src/common/shared/base-approval.service"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class ShiftsApprovalService extends BaseApprovalService { + constructor(prisma: PrismaService) { super(prisma); } + + protected get delegate() { + return this.prisma.shifts; + } +} \ No newline at end of file diff --git a/src/modules/shifts/validation/services/shifts-validation.service.ts b/src/modules/shifts/services/shifts-overview.service.ts similarity index 86% rename from src/modules/shifts/validation/services/shifts-validation.service.ts rename to src/modules/shifts/services/shifts-overview.service.ts index 5d05f40..ae539f1 100644 --- a/src/modules/shifts/validation/services/shifts-validation.service.ts +++ b/src/modules/shifts/services/shifts-overview.service.ts @@ -1,7 +1,8 @@ import { Injectable, NotFoundException } from "@nestjs/common"; +import { computeHours } from "src/common/utils/date-utils"; import { PrismaService } from "src/prisma/prisma.service"; -export interface ValidationRow { +export interface OverviewRow { fullName: string; supervisor: string; totalRegularHrs: number; @@ -13,22 +14,16 @@ export interface ValidationRow { } @Injectable() -export class ShiftsValidationService { +export class ShiftsOverviewService { constructor(private readonly prisma: PrismaService) {} - private computeHours(start: Date, end: Date): number { - const diffMs = end.getTime() - start.getTime(); - const hours = diffMs / 1000 / 3600; - return parseFloat(hours.toFixed(2)); - } - - async getSummary(periodId: number): Promise { + async getSummary(period_id: number): Promise { //fetch pay-period to display const period = await this.prisma.payPeriods.findUnique({ - where: { period_number: periodId }, + where: { period_number: period_id }, }); if(!period) { - throw new NotFoundException(`pay-period ${periodId} not found`); + throw new NotFoundException(`pay-period ${period_id} not found`); } const { start_date, end_date } = period; @@ -57,7 +52,7 @@ export class ShiftsValidationService { }, }); - const mapRow = new Map(); + const mapRow = new Map(); for(const s of shifts) { const employeeId = s.timesheet.employee.user_id; @@ -77,7 +72,7 @@ export class ShiftsValidationService { isValidated: false, }; } - const hours = this.computeHours(s.start_time, s.end_time); + const hours = computeHours(s.start_time, s.end_time); switch(s.bank_code.type) { case 'regular' : row.totalRegularHrs += hours; @@ -119,4 +114,5 @@ export class ShiftsValidationService { //return by default the list of employee in ascending alphabetical order return Array.from(mapRow.values()).sort((a,b) => a.fullName.localeCompare(b.fullName)); } + } diff --git a/src/modules/shifts/services/shifts.service.ts b/src/modules/shifts/services/shifts.service.ts index d1961c0..6a8ae99 100644 --- a/src/modules/shifts/services/shifts.service.ts +++ b/src/modules/shifts/services/shifts.service.ts @@ -1,8 +1,10 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; -import { CreateShiftDto } from "../dtos/create-shifts.dto"; +import { CreateShiftDto } from "../dtos/create-shift.dto"; import { Shifts, ShiftsArchive } from "@prisma/client"; -import { UpdateShiftsDto } from "../dtos/update-shifts.dto"; +import { UpdateShiftsDto } from "../dtos/update-shift.dto"; +import { buildPrismaWhere } from "src/common/shared/build-prisma-where"; +import { SearchShiftsDto } from "../dtos/search-shifts.dto"; @Injectable() export class ShiftsService { @@ -18,10 +20,10 @@ export class ShiftsService { }); } - findAll(): Promise { - return this.prisma.shifts.findMany({ - include: { timesheet: { include: { employee: { include: { user:true } } } } }, - }); + async findAll(filters: SearchShiftsDto): Promise { + const where = buildPrismaWhere(filters); + const shifts = await this.prisma.shifts.findMany({ where }) + return shifts; } async findOne(id: number): Promise { diff --git a/src/modules/shifts/shifts.module.ts b/src/modules/shifts/shifts.module.ts index 57cd331..2f55b80 100644 --- a/src/modules/shifts/shifts.module.ts +++ b/src/modules/shifts/shifts.module.ts @@ -2,15 +2,14 @@ import { Module } from '@nestjs/common'; import { ShiftsController } from './controllers/shifts.controller'; import { ShiftsService } from './services/shifts.service'; import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; -import { ShiftsValidationModule } from './validation/shifts-validation.module'; +import { ShiftsOverviewController } from './controllers/shifts-overview.controller'; +import { ShiftsOverviewService } from './services/shifts-overview.service'; +import { ShiftsApprovalService } from './services/shifts-approval.service'; @Module({ - imports: [ - BusinessLogicsModule, - ShiftsValidationModule, - ], - controllers: [ShiftsController], - providers: [ShiftsService], - exports: [ShiftsService], + imports: [BusinessLogicsModule], + controllers: [ShiftsController, ShiftsOverviewController], + providers: [ShiftsService, ShiftsOverviewService, ShiftsApprovalService], + exports: [ShiftsService, ShiftsOverviewService], }) export class ShiftsModule {} diff --git a/src/modules/shifts/validation/shifts-validation.module.ts b/src/modules/shifts/validation/shifts-validation.module.ts deleted file mode 100644 index 277d2ac..0000000 --- a/src/modules/shifts/validation/shifts-validation.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from "@nestjs/common"; -import { ShiftsValidationController } from "./controllers/shifts-validation.controller"; -import { ShiftsValidationService } from "./services/shifts-validation.service"; -import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; - -@Module({ - imports: [BusinessLogicsModule], - controllers: [ShiftsValidationController], - providers: [ShiftsValidationService], -}) -export class ShiftsValidationModule {} \ No newline at end of file diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index 7a1bfa2..ccbf10b 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; import { TimesheetsService } from '../services/timesheets.service'; import { CreateTimesheetDto } from '../dtos/create-timesheet.dto'; import { Timesheets } from '@prisma/client'; @@ -7,13 +7,18 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { Roles as RoleEnum } from '.prisma/client'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { TimesheetEntity } from '../dtos/swagger-entities/timesheet.entity'; +import { TimesheetsApprovalService } from '../services/timesheets-approval.service'; +import { SearchTimesheetDto } from '../dtos/search-timesheets.dto'; @ApiTags('Timesheets') @ApiBearerAuth('access-token') // @UseGuards() @Controller('timesheets') export class TimesheetsController { - constructor(private readonly timesheetsService: TimesheetsService) {} + constructor( + private readonly timesheetsService: TimesheetsService, + private readonly timesheetsApprovalService: TimesheetsApprovalService, + ) {} @Post() @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) @@ -29,8 +34,9 @@ export class TimesheetsController { @ApiOperation({ summary: 'Find all timesheets' }) @ApiResponse({ status: 201, description: 'List of timesheet found', type: TimesheetEntity, isArray: true }) @ApiResponse({ status: 400, description: 'List of timesheets not found' }) - findAll(): Promise { - return this.timesheetsService.findAll(); + @UsePipes(new ValidationPipe({transform: true, whitelist: true })) + findAll(@Query() filters: SearchTimesheetDto): Promise { + return this.timesheetsService.findAll(filters); } @Get(':id') @@ -62,4 +68,10 @@ export class TimesheetsController { remove(@Param('id', ParseIntPipe) id: number): Promise { return this.timesheetsService.remove(id); } + + @Patch(':id/approval') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { + return this.timesheetsApprovalService.updateApproval(id, isApproved); + } } diff --git a/src/modules/timesheets/dtos/search-timesheets.dto.ts b/src/modules/timesheets/dtos/search-timesheets.dto.ts new file mode 100644 index 0000000..0d61d59 --- /dev/null +++ b/src/modules/timesheets/dtos/search-timesheets.dto.ts @@ -0,0 +1,20 @@ +import { Type } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional } from "class-validator"; + + +export class SearchTimesheetDto { + @IsOptional() + @Type(() => Number) + @IsInt() + timesheet_id: number; + + @IsOptional() + @Type(()=> Number) + @IsInt() + employee_id: number; + + @IsOptional() + @Type(()=> Boolean) + @IsBoolean() + is_approved: boolean; +} \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheets-approval.service.ts b/src/modules/timesheets/services/timesheets-approval.service.ts new file mode 100644 index 0000000..880e035 --- /dev/null +++ b/src/modules/timesheets/services/timesheets-approval.service.ts @@ -0,0 +1,37 @@ + +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Timesheets } from "@prisma/client"; +import { BaseApprovalService } from "src/common/shared/base-approval.service"; +import { ExpensesApprovalService } from "src/modules/expenses/services/expenses-approval.service"; +import { ShiftsApprovalService } from "src/modules/shifts/services/shifts-approval.service"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class TimesheetsApprovalService extends BaseApprovalService{ + constructor( + prisma: PrismaService, + private readonly shiftsApproval: ShiftsApprovalService, + private readonly expensesApproval: ExpensesApprovalService, + ) {super(prisma);} + + protected get delegate() { + return this.prisma.timesheets; + } + + async updateApproval(timesheetId: number, isApproved: boolean): Promise { + const timesheet = await super.updateApproval(timesheetId, isApproved); + + await this.prisma.shifts.updateMany({ + where: { timesheet_id: timesheetId }, + data: { is_approved: isApproved }, + }); + + await this.prisma.expenses.updateMany({ + where: { timesheet_id: timesheetId }, + data: { is_approved: isApproved }, + }); + + return timesheet; + } + +} \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheets.service.ts b/src/modules/timesheets/services/timesheets.service.ts index 48b5703..97cfda5 100644 --- a/src/modules/timesheets/services/timesheets.service.ts +++ b/src/modules/timesheets/services/timesheets.service.ts @@ -4,6 +4,9 @@ import { CreateTimesheetDto } from '../dtos/create-timesheet.dto'; import { Timesheets, TimesheetsArchive } from '@prisma/client'; import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto'; import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; +import { computeHours } from 'src/common/utils/date-utils'; +import { buildPrismaWhere } from 'src/common/shared/build-prisma-where'; +import { SearchTimesheetDto } from '../dtos/search-timesheets.dto'; @Injectable() export class TimesheetsService { @@ -23,32 +26,38 @@ export class TimesheetsService { }); } - async findAll(): Promise { - const list = await this.prisma.timesheets.findMany({ - include: { - shift: { include: { bank_code: true } }, - expense: { include: { bank_code: true } }, - employee: { include: { user : true } }, + async findAll(filters: SearchTimesheetDto): Promise { + const where = buildPrismaWhere(filters); + + //fetchs lists of shifts and expenses for a selected timesheet + const rawlist = await this.prisma.timesheets.findMany({ + where, include: { + shift: { include: {bank_code: true } }, + expense: { include: { bank_code: true } }, + employee: { include: { user : true } }, }, }); - return Promise.all( - list.map(async timesheet => { - const detailedShifts = timesheet.shift.map(s => { - const hours = this.overtime.computedHours(s.start_time, s.end_time); - const regularHours = Math.min(8, hours); - const dailyOvertime = this.overtime.getDailyOvertimeHours(s.start_time, s.end_time); - const payRegular = regularHours * s.bank_code.modifier; - const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, s.bank_code.modifier); - return { ...s, hours, payRegular, payOvertime }; + const detailedlist = await Promise.all( + rawlist.map(async timesheet => { + //detailed shifts + const detailedShifts = timesheet.shift.map(shift => { + const totalhours = computeHours(shift.start_time, shift.end_time,5); + const regularHours = Math.min(8, totalhours); + const dailyOvertime = this.overtime.getDailyOvertimeHours(shift.start_time, shift.end_time); + const payRegular = regularHours * shift.bank_code.modifier; + const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, shift.bank_code.modifier); + return { ...shift, totalhours, payRegular, payOvertime }; }); + //calculate overtime weekly const weeklyOvertimeHours = detailedShifts.length ? await this.overtime.getWeeklyOvertimeHours( timesheet.employee_id, timesheet.shift[0].date): 0; return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours }; - }) + }), ); + return detailedlist; } async findOne(id: number): Promise { @@ -65,7 +74,7 @@ export class TimesheetsService { } const detailedShifts = timesheet.shift.map( s => { - const hours = this.overtime.computedHours(s.start_time, s.end_time); + const hours = computeHours(s.start_time, s.end_time); const regularHours = Math.min(8, hours); const dailyOvertime = this.overtime.getDailyOvertimeHours(s.start_time, s.end_time); const payRegular = regularHours * s.bank_code.modifier; diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index debf169..6c20537 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -2,11 +2,19 @@ import { Module } from '@nestjs/common'; import { TimesheetsController } from './controllers/timesheets.controller'; import { TimesheetsService } from './services/timesheets.service'; import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; +import { TimesheetsApprovalService } from './services/timesheets-approval.service'; +import { ShiftsApprovalService } from '../shifts/services/shifts-approval.service'; +import { ExpensesApprovalService } from '../expenses/services/expenses-approval.service'; @Module({ imports: [BusinessLogicsModule], controllers: [TimesheetsController], - providers: [ TimesheetsService ], + providers: [ + TimesheetsService, + TimesheetsApprovalService, + ShiftsApprovalService, + ExpensesApprovalService + ], exports: [TimesheetsService], }) export class TimesheetsModule {}