feat(pay-period): added approval of timesheets, shifts and expenses by bulk. added route to controller

This commit is contained in:
Matthieu Haineault 2025-11-03 14:14:09 -05:00
parent 5268737bd1
commit bdbec4f68c
11 changed files with 177 additions and 155 deletions

View File

@ -162,7 +162,31 @@
]
}
},
"/pay-periods/{year}/{periodNumber}/{email}": {
"/pay-periods/crew/pay-period-approval": {
"patch": {
"operationId": "PayPeriodsController_bulkApproval",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkCrewApprovalDto"
}
}
}
},
"responses": {
"200": {
"description": ""
}
},
"tags": [
"PayPeriods"
]
}
},
"/pay-periods/crew/{year}/{periodNumber}": {
"get": {
"operationId": "PayPeriodsController_getCrewOverview",
"parameters": [
@ -224,6 +248,37 @@
]
}
},
"/timesheets": {
"get": {
"operationId": "TimesheetController_getTimesheetByIds",
"parameters": [
{
"name": "year",
"required": true,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "period_number",
"required": true,
"in": "query",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Timesheet"
]
}
},
"/preferences": {
"patch": {
"operationId": "PreferencesController_updatePreferences",
@ -257,37 +312,6 @@
]
}
},
"/timesheets": {
"get": {
"operationId": "TimesheetController_getTimesheetByIds",
"parameters": [
{
"name": "year",
"required": true,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "period_number",
"required": true,
"in": "query",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Timesheet"
]
}
},
"/shift/create": {
"post": {
"operationId": "ShiftController_createBatch",
@ -609,6 +633,10 @@
}
},
"schemas": {
"BulkCrewApprovalDto": {
"type": "object",
"properties": {}
},
"PreferencesDto": {
"type": "object",
"properties": {}

View File

@ -1,7 +1,6 @@
import { CreateExpenseResult, UpdateExpensePayload, UpdateExpenseResult, DeleteExpenseResult, NormalizedExpense } from "src/time-and-attendance/utils/type.utils";
import { toDateFromString, toStringFromDate, weekStartSunday } from "src/time-and-attendance/utils/date-time.utils";
import { Injectable, NotFoundException, Req } from "@nestjs/common";
import { Injectable, NotFoundException } from "@nestjs/common";
import { expense_select } from "src/time-and-attendance/utils/selects.utils";
import { PrismaService } from "src/prisma/prisma.service";
import { ExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-create.dto";

View File

@ -1,10 +1,9 @@
import { Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Query, Req } from "@nestjs/common";
import { PayPeriodDto } from "../dtos/pay-period.dto";
import { Body, Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query, Req, UnauthorizedException } from "@nestjs/common";
import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto";
import { PayPeriodsQueryService } from "../services/pay-periods-query.service";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client';
// import { PayPeriodsCommandService } from "../services/pay-periods-command.service";
import { PayPeriodsCommandService } from "../services/pay-periods-command.service";
import { PayPeriodBundleDto } from "../dtos/bundle-pay-period.dto";
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
@ -14,7 +13,7 @@ export class PayPeriodsController {
constructor(
private readonly queryService: PayPeriodsQueryService,
// private readonly commandService: PayPeriodsCommandService,
private readonly commandService: PayPeriodsCommandService,
) { }
@Get('current-and-all')
@ -39,42 +38,32 @@ export class PayPeriodsController {
return this.queryService.findOneByYearPeriod(year, period_no);
}
// @Patch("crew/bulk-approval")
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: "Approve all selected timesheets in the period" })
// @ApiResponse({ status: 200, description: "Pay period approved" })
// async bulkApproval(@Body() dto: BulkCrewApprovalDto) {
// return this.commandService.bulkApproveCrew(dto);
// }
@Patch("crew/pay-period-approval")
@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
async bulkApproval(@Req() req, @Body() dto: BulkCrewApprovalDto) {
const email = req.user?.email;
if(!email) throw new UnauthorizedException(`Session infos not found`);
return this.commandService.bulkApproveCrew(email, dto);
}
@Get(':year/:periodNumber/:email')
//@RolesAllowed(RoleEnum.SUPERVISOR)
@Get('crew/:year/:periodNumber')
@RolesAllowed(RoleEnum.SUPERVISOR)
async getCrewOverview(@Req() req,
@Param('year', ParseIntPipe) year: number,
@Param('periodNumber', ParseIntPipe) period_no: number,
@Query('includeSubtree', new ParseBoolPipe({ optional: true })) include_subtree = false,
): Promise<PayPeriodOverviewDto> {
const email = req.user?.email;
if(!email) throw new UnauthorizedException(`Session infos not found`);
return this.queryService.getCrewOverview(year, period_no, email, include_subtree);
}
@Get('overview/:year/:periodNumber')
@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
async getOverviewByYear(
@Param('year', ParseIntPipe) year: number,
@Param('periodNumber', ParseIntPipe) period_no: number,
): Promise<PayPeriodOverviewDto> {
return this.queryService.getOverviewByYearPeriod(year, period_no);
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
// @Get()
// @ApiOperation({ summary: 'Find all pay period' })
// @ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true })
// async findAll(): Promise<PayPeriodDto[]> {
// return this.queryService.findAll();
// }
}

View File

@ -16,9 +16,6 @@ export class BulkCrewApprovalItemDto {
}
export class BulkCrewApprovalDto {
@IsEmail()
supervisor_email: string;
@IsBoolean()
include_subtree: boolean = false;

View File

@ -1,10 +1,20 @@
import { PayPeriodsQueryService } from "./services/pay-periods-query.service";
import { PayPeriodsController } from "./controllers/pay-periods.controller";
import { Module } from "@nestjs/common";
import { PayPeriodsCommandService } from "src/time-and-attendance/pay-period/services/pay-periods-command.service";
import { TimesheetsModule } from "src/time-and-attendance/time-tracker/timesheets/timesheets.module";
import { EmailToIdResolver } from "src/time-and-attendance/utils/resolve-email-id.utils";
import { TimesheetApprovalService } from "src/time-and-attendance/time-tracker/timesheets/services/timesheet-approval.service";
@Module({
imports:[TimesheetsModule],
controllers: [PayPeriodsController],
providers: [PayPeriodsQueryService],
providers: [
PayPeriodsQueryService,
PayPeriodsCommandService,
EmailToIdResolver,
TimesheetApprovalService,
],
})
export class PayperiodsModule {}

View File

@ -1,71 +1,72 @@
// import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
// import { PrismaService } from "src/prisma/prisma.service";
// import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
// import { PayPeriodsQueryService } from "./pay-periods-query.service";
// import { TimesheetApprovalService } from "src/modules/timesheets/services/timesheet-approval.service";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
import { PayPeriodsQueryService } from "./pay-periods-query.service";
import { TimesheetApprovalService } from "src/time-and-attendance/time-tracker/timesheets/services/timesheet-approval.service";
// @Injectable()
// export class PayPeriodsCommandService {
// constructor(
// private readonly prisma: PrismaService,
// private readonly timesheets_approval: TimesheetApprovalService,
// private readonly query: PayPeriodsQueryService,
// ) {}
@Injectable()
export class PayPeriodsCommandService {
constructor(
private readonly prisma: PrismaService,
private readonly timesheets_approval: TimesheetApprovalService,
private readonly query: PayPeriodsQueryService,
) {}
// //function to approve pay-periods according to selected crew members
// async bulkApproveCrew(dto:BulkCrewApprovalDto): Promise<{updated: number}> {
// const { supervisor_email, include_subtree, items } = dto;
// if(!items?.length) throw new BadRequestException('no items to process');
//function to approve pay-periods according to selected crew members
async bulkApproveCrew(email: string, dto:BulkCrewApprovalDto): Promise<{updated: number}> {
const { include_subtree, items } = dto;
if(!items?.length) throw new BadRequestException('no items to process');
// //fetch and validate supervisor status
// const supervisor = await this.query.getSupervisor(supervisor_email);
// if(!supervisor) throw new NotFoundException('No employee record linked to current user');
// if(!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor');
//fetch and validate supervisor status
const supervisor = await this.query.getSupervisor(email);
if(!supervisor) throw new NotFoundException('No employee record linked to current user');
if(!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor');
// //fetches emails of crew members linked to supervisor
// const crew_emails = await this.query.resolveCrewEmails(supervisor.id, include_subtree);
//fetches emails of crew members linked to supervisor
const crew_emails = await this.query.resolveCrewEmails(supervisor.id, include_subtree);
// for(const item of items) {
// if(!crew_emails.has(item.employee_email)) {
// throw new ForbiddenException(`Employee ${item.employee_email} not in supervisor crew`);
// }
// }
for(const item of items) {
if(!crew_emails.has(item.employee_email)) {
throw new ForbiddenException(`Employee ${item.employee_email} not in supervisor crew`);
}
}
// const period_cache = new Map<string, {period_start: Date, period_end: Date}>();
// const getPeriod = async (y:number, no: number) => {
// const key = `${y}-${no}`;
// if(!period_cache.has(key)) return period_cache.get(key)!;
// const period = await this.query.getPeriodWindow(y,no);
// if(!period) throw new NotFoundException(`Pay period ${y}-${no} not found`);
// period_cache.set(key, period);
// return period;
// };
const period_cache = new Map<string, {period_start: Date, period_end: Date}>();
const getPeriod = async (year:number, period_no: number) => {
const key = `${year}-${period_no}`;
if(period_cache.has(key)) return period_cache.get(key)!;
// let updated = 0;
const period = await this.query.getPeriodWindow(year,period_no);
if(!period) throw new NotFoundException(`Pay period ${year}-${period_no} not found`);
period_cache.set(key, period);
return period;
};
// await this.prisma.$transaction(async (transaction) => {
// for(const item of items) {
// const { period_start, period_end } = await getPeriod(item.pay_year, item.period_no);
let updated = 0;
// const t_sheets = await transaction.timesheets.findMany({
// where: {
// employee: { user: { email: item.employee_email } },
// OR: [
// {shift : { some: { date: { gte: period_start, lte: period_end } } } },
// {expense: { some: { date: { gte: period_start, lte: period_end } } } },
// ],
// },
// select: { id: true },
// });
await this.prisma.$transaction(async (transaction) => {
for(const item of items) {
const { period_start, period_end } = await getPeriod(item.pay_year, item.period_no);
// for(const { id } of t_sheets) {
// await this.timesheets_approval.updateApprovalWithTransaction(transaction, id, item.approve);
// updated++;
// }
const t_sheets = await transaction.timesheets.findMany({
where: {
employee: { user: { email: item.employee_email } },
OR: [
{shift : { some: { date: { gte: period_start, lte: period_end } } } },
{expense: { some: { date: { gte: period_start, lte: period_end } } } },
],
},
select: { id: true },
});
// }
// });
// return {updated};
// }
// }
for(const { id } of t_sheets) {
await this.timesheets_approval.cascadeApprovalWithtx(transaction, id, item.approve);
updated++;
}
}
});
return {updated};
}
}

View File

@ -1,10 +1,11 @@
import { Controller, Param, Query, Body, Get, Post, BadRequestException, ParseIntPipe, Delete, Patch, Req } from "@nestjs/common";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { SchedulePresetsDto } from "src/time-and-attendance/time-tracker/schedule-presets/dtos/create-schedule-presets.dto";
import { SchedulePresetsUpdateDto } from "src/time-and-attendance/time-tracker/schedule-presets/dtos/update-schedule-presets.dto";
import { SchedulePresetsApplyService } from "src/time-and-attendance/time-tracker/schedule-presets/services/schedule-presets-apply.service";
import { SchedulePresetsGetService } from "src/time-and-attendance/time-tracker/schedule-presets/services/schedule-presets-get.service";
import { SchedulePresetsUpsertService } from "src/time-and-attendance/time-tracker/schedule-presets/services/schedule-presets-upsert.service";
import { Roles as RoleEnum } from '.prisma/client';
@Controller('schedule-presets')
export class SchedulePresetsController {
@ -16,45 +17,39 @@ export class SchedulePresetsController {
//used to create a schedule preset
@Post('create')
async createPreset( @Req() req,
@Body() dto: SchedulePresetsDto,
) {
@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
async createPreset( @Req() req, @Body() dto: SchedulePresetsDto ) {
const email = req.user?.email;
return await this.upsertService.createPreset(email, dto);
}
//used to update an already existing schedule preset
@Patch('update/:preset_id')
async updatePreset(
@Param('preset_id', ParseIntPipe) preset_id: number,
@Body() dto: SchedulePresetsUpdateDto,
) {
@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
async updatePreset( @Param('preset_id', ParseIntPipe) preset_id: number,@Body() dto: SchedulePresetsUpdateDto ) {
return await this.upsertService.updatePreset(preset_id, dto);
}
//used to delete a schedule preset
@Delete('delete/:preset_id')
async deletePreset(
@Param('preset_id') preset_id: number,
) {
@RolesAllowed(RoleEnum.ADMIN)
async deletePreset( @Param('preset_id') preset_id: number ) {
return await this.upsertService.deletePreset(preset_id);
}
//used to show the list of available schedule presets
@Get('find-list')
@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
async findListById( @Req() req) {
const email = req.user?.email;
return this.getService.getSchedulePresets(email);
}
//used to apply a preset to a timesheet
@Post('apply-presets')
async applyPresets( @Req() req,
@Query('preset') preset_name: string,
@Query('start') start_date: string,
) {
@RolesAllowed(RoleEnum.EMPLOYEE, RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
async applyPresets( @Req() req, @Query('preset') preset_name: string, @Query('start') start_date: string ) {
const email = req.user?.email;
if(!preset_name?.trim()) throw new BadRequestException('Query "preset" is required');
if(!start_date?.trim()) throw new BadRequestException('Query "start" is required YYYY-MM-DD');

View File

@ -3,16 +3,16 @@ import { ShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift
import { UpdateShiftDto } from "src/time-and-attendance/time-tracker/shifts/dtos/shift-update.dto";
import { ShiftsUpsertService } from "src/time-and-attendance/time-tracker/shifts/services/shifts-upsert.service";
import { CreateShiftResult, UpdateShiftResult } from "src/time-and-attendance/utils/type.utils";
import { Roles as RoleEnum } from '.prisma/client';
import { RolesAllowed } from "src/common/decorators/roles.decorators";
@Controller('shift')
export class ShiftController {
constructor( private readonly upsert_service: ShiftsUpsertService ){}
@Post('create')
createBatch(
@Req() req,
@Body()dtos: ShiftDto[]): Promise<CreateShiftResult[]> {
@RolesAllowed(RoleEnum.EMPLOYEE, RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
createBatch( @Req() req, @Body()dtos: ShiftDto[]): Promise<CreateShiftResult[]> {
const email = req.user?.email;
const list = Array.isArray(dtos) ? dtos : [];
if(list.length === 0) throw new BadRequestException('Body is missing or invalid (create shifts)');
@ -22,14 +22,15 @@ export class ShiftController {
//change Body to receive dtos
@Patch('update')
updateBatch(
@Body() dtos: UpdateShiftDto[]): Promise<UpdateShiftResult[]>{
@RolesAllowed(RoleEnum.EMPLOYEE, RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
updateBatch( @Body() dtos: UpdateShiftDto[]): Promise<UpdateShiftResult[]>{
const list = Array.isArray(dtos) ? dtos: [];
if(list.length === 0) throw new BadRequestException('Body is missing or invalid (update shifts)');
return this.upsert_service.updateShifts(dtos);
}
@Delete(':shift_id')
@RolesAllowed(RoleEnum.EMPLOYEE, RoleEnum.ACCOUNTING, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ADMIN)
remove(@Param('shift_id') shift_id: number ) {
return this.upsert_service.deleteShift(shift_id);
}

View File

@ -11,12 +11,9 @@ export class TimesheetController {
@Get()
@RolesAllowed(RoleEnum.SUPERVISOR, RoleEnum.HR, RoleEnum.ACCOUNTING, RoleEnum.ADMIN)
async getTimesheetByIds(
@Req() req,
@Query('year', ParseIntPipe) year: number,
@Query('period_number', ParseIntPipe) period_number: number,
) {
@Req() req, @Query('year', ParseIntPipe) year:number, @Query('period_number', ParseIntPipe) period_number: number) {
const email = req.user?.email;
if(!email) throw new UnauthorizedException('Unauthorized User');
if(!email) throw new UnauthorizedException('Unauthorized User'); 
return this.timesheetOverview.getTimesheetsForEmployeeByPeriod(email, year, period_number);
}
}

View File

@ -1,14 +1,19 @@
import { Module } from '@nestjs/common';
import { TimesheetController } from 'src/time-and-attendance/time-tracker/timesheets/controllers/timesheet.controller';
import { TimesheetApprovalService } from 'src/time-and-attendance/time-tracker/timesheets/services/timesheet-approval.service';
import { TimesheetArchiveService } from 'src/time-and-attendance/time-tracker/timesheets/services/timesheet-archive.service';
import { GetTimesheetsOverviewService } from 'src/time-and-attendance/time-tracker/timesheets/services/timesheet-get-overview.service';
import { EmailToIdResolver } from 'src/time-and-attendance/utils/resolve-email-id.utils';
@Module({
controllers: [TimesheetController],
providers: [
TimesheetArchiveService,
GetTimesheetsOverviewService,
TimesheetApprovalService,
EmailToIdResolver,
],
exports: [],
})