Merge branch 'main' of git.targo.ca:Targo/targo_backend into dev/setup/attachment/MatthieuH

This commit is contained in:
Matthieu Haineault 2025-08-25 08:16:34 -04:00
commit 514f41df19
16 changed files with 558 additions and 246 deletions

View File

@ -2,6 +2,7 @@ import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } fr
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";
@ApiTags('LeaveRequests Archives')
@ -22,7 +23,7 @@ export class LeaveRequestsArchiveController {
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Fetch leaveRequest in archives with its Id'})
@ApiResponse({ status: 200, description: 'Archived leaveRequest found'})
async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<LeaveRequestsArchive> {
async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<LeaveRequestViewDto> {
try{
return await this.leaveRequestsService.findOneArchived(id);
}catch {

View File

@ -18,54 +18,67 @@ export interface CsvRow {
export class CsvExportService {
constructor(private readonly prisma: PrismaService) {}
async collectTransaction(period_id: number, companies: ExportCompany[]): Promise<CsvRow[]> {
const companyCodes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2);
async collectTransaction( period_id: number, companies: ExportCompany[], approved: boolean = true):
Promise<CsvRow[]> {
const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2);
const period = await this.prisma.payPeriods.findFirst({
where: { pay_period_no: period_id },
});
if(!period) {
throw new NotFoundException(`Pay period ${period_id} not found`);
}
if(!period) throw new NotFoundException(`Pay period ${period_id} not found`);
const startDate = period.period_start;
const endDate = period.period_end;
const start_date = period.period_start;
const end_date = period.period_end;
const approved_filter = approved ? { is_approved: true } : {};
//fetching shifts
const shifts = await this.prisma.shifts.findMany({
where: { date: { gte: startDate, lte: endDate },
timesheet: { employee: { company_code: { in: companyCodes} } },
where: {
date: { gte: start_date, lte: end_date },
...approved_filter,
timesheet: {
employee: { company_code: { in: company_codes} } },
},
include: { bank_code: true,
timesheet: { include: {employee: { include: { user:true,
supervisor: { include: { user:true } },
}},
}},
include: {
bank_code: true,
timesheet: { include: {
employee: { include: {
user:true,
supervisor: { include: {
user:true } } } } } },
},
});
//fetching expenses
const expenses = await this.prisma.expenses.findMany({
where: { date: { gte: startDate, lte: endDate },
timesheet: { employee: { company_code: { in: companyCodes} } },
where: {
date: { gte: start_date, lte: end_date },
...approved_filter,
timesheet: { employee: { company_code: { in: company_codes} } },
},
include: { bank_code: true,
timesheet: { include: { employee: { include: { user: true,
supervisor: { include: { user:true } },
} },
} },
},
include: { bank_code: true,
timesheet: { include: {
employee: { include: {
user: true,
supervisor: { include: {
user:true } } } } } },
},
});
//fetching leave requests
const leaves = await this.prisma.leaveRequests.findMany({
where : { start_date_time: { gte: startDate, lte: endDate },
employee: { company_code: { in: companyCodes } },
where : {
start_date_time: { gte: start_date, lte: end_date },
employee: { company_code: { in: company_codes } },
},
include: { bank_code: true,
employee: { include: { user: true,
supervisor: { include: { user: true } },
}},
include: {
bank_code: true,
employee: { include: {
user: true,
supervisor: { include: {
user: true } } } },
},
});
@ -74,7 +87,7 @@ export class CsvExportService {
//Shifts Mapping
for (const shift of shifts) {
const emp = shift.timesheet.employee;
const week_number = this.computeWeekNumber(startDate, shift.date);
const week_number = this.computeWeekNumber(start_date, shift.date);
const hours = this.computeHours(shift.start_time, shift.end_time);
rows.push({
@ -85,7 +98,7 @@ export class CsvExportService {
quantity_hours: hours,
amount: undefined,
week_number,
pay_date: this.formatDate(endDate),
pay_date: this.formatDate(end_date),
holiday_date: undefined,
});
}
@ -93,7 +106,7 @@ export class CsvExportService {
//Expenses Mapping
for (const e of expenses) {
const emp = e.timesheet.employee;
const week_number = this.computeWeekNumber(startDate, e.date);
const week_number = this.computeWeekNumber(start_date, e.date);
rows.push({
company_code: emp.company_code,
@ -103,7 +116,7 @@ export class CsvExportService {
quantity_hours: undefined,
amount: Number(e.amount),
week_number,
pay_date: this.formatDate(endDate),
pay_date: this.formatDate(end_date),
holiday_date: undefined,
});
}
@ -115,7 +128,7 @@ export class CsvExportService {
const start = l.start_date_time;
const end = l.end_date_time ?? start;
const week_number = this.computeWeekNumber(startDate, start);
const week_number = this.computeWeekNumber(start_date, start);
const hours = this.computeHours(start, end);
rows.push({
@ -126,7 +139,7 @@ export class CsvExportService {
quantity_hours: hours,
amount: undefined,
week_number,
pay_date: this.formatDate(endDate),
pay_date: this.formatDate(end_date),
holiday_date: undefined,
});
}
@ -146,7 +159,7 @@ export class CsvExportService {
generateCsv(rows: CsvRow[]): Buffer {
const header = [
'company_code',
'external_payrol_id',
'external_payroll_id',
'full_name',
'bank_code',
'quantity_hours',

View File

@ -1,4 +1,4 @@
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseEnumPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { LeaveRequestsService } from "../services/leave-requests.service";
import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto";
import { LeaveRequests } from "@prisma/client";
@ -7,6 +7,7 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { LeaveApprovalStatus, Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
@ApiTags('Leave Requests')
@ApiBearerAuth('access-token')
@ -20,35 +21,35 @@ export class LeaveRequestController {
@ApiOperation({summary: 'Create leave request' })
@ApiResponse({ status: 201, description: 'Leave request created',type: CreateLeaveRequestsDto })
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
create(@Body() dto: CreateLeaveRequestsDto): Promise<LeaveRequests> {
create(@Body() dto: CreateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
return this. leaveRequetsService.create(dto);
}
@Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Find all leave request' })
@ApiResponse({ status: 201, description: 'List of Leave requests found',type: CreateLeaveRequestsDto, isArray: true })
@ApiResponse({ status: 201, description: 'List of Leave requests found',type: LeaveRequestViewDto, isArray: true })
@ApiResponse({ status: 400, description: 'List of leave request not found' })
@UsePipes(new ValidationPipe({transform: true, whitelist: true}))
findAll(@Query() filters: SearchLeaveRequestsDto): Promise<(LeaveRequests & {daysRequested:number; cost: number})[]> {
findAll(@Query() filters: SearchLeaveRequestsDto): Promise<LeaveRequestViewDto[]> {
return this.leaveRequetsService.findAll(filters);
}
//remove emp_id and use email
@Get(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Find leave request' })
@ApiResponse({ status: 201, description: 'Leave request found',type: CreateLeaveRequestsDto })
@ApiResponse({ status: 201, description: 'Leave request found',type: LeaveRequestViewDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequests> {
findOne(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.findOne(id);
}
//remove emp_id and use email
@Patch(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({summary: 'Update leave request' })
@ApiResponse({ status: 201, description: 'Leave request updated',type: CreateLeaveRequestsDto })
@ApiResponse({ status: 201, description: 'Leave request updated',type: LeaveRequestViewDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateLeaveRequestsDto): Promise<LeaveRequests> {
update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.update(id, dto);
}
@ -58,14 +59,14 @@ export class LeaveRequestController {
@ApiOperation({summary: 'Delete leave request' })
@ApiResponse({ status: 201, description: 'Leave request deleted',type: CreateLeaveRequestsDto })
@ApiResponse({ status: 400, description: 'Leave request not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequests> {
remove(@Param('id', ParseIntPipe) id: number): Promise<LeaveRequestViewDto> {
return this.leaveRequetsService.remove(id);
}
//remove emp_id and use email
@Patch(':id/approval')
updateApproval( @Param('id', ParseIntPipe) id: number,
@Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise<LeaveRequests> {
@Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise<LeaveRequestViewDto> {
const approvalStatus = is_approved ?
LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED;
return this.leaveRequetsService.update(id, { approval_status: approvalStatus });

View File

@ -1,23 +1,12 @@
import { ApiProperty } from "@nestjs/swagger";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { Type } from "class-transformer";
import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator";
import { IsDateString, IsEmail, IsEnum, IsInt, IsISO8601, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateLeaveRequestsDto {
@ApiProperty({
example: 1,
description: 'Leave request`s unique id(auto-incremented)',
})
id: number;
//remove emp_id and use email
@ApiProperty({
example: '4655867',
description: 'Employee`s id',
})
@Type(()=> Number)
@IsInt()
employee_id: number;
@IsEmail()
email: string;
@ApiProperty({
example: 7,
@ -37,18 +26,17 @@ export class CreateLeaveRequestsDto {
@ApiProperty({
example: '22/06/2463',
description: 'Leave request`s start date',
})
@Type(()=>Date)
@IsNotEmpty()
start_date_time:Date;
})
@IsISO8601()
start_date_time:string;
@ApiProperty({
example: '25/03/3019',
description: 'Leave request`s end date',
})
@Type(()=>Date)
@IsOptional()
end_date_time?: Date;
@IsISO8601()
end_date_time?: string;
@ApiProperty({
example: 'My precious',

View File

@ -0,0 +1,13 @@
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
export class LeaveRequestViewDto {
id!: number;
leave_type!: LeaveTypes;
start_date_time!: string;
end_date_time!: string | null;
comment!: string | null;
approval_status: LeaveApprovalStatus;
email!: string;
employee_full_name: string;
days_requested?: number;
}

View File

@ -1,13 +1,11 @@
import { LeaveApprovalStatus } from "@prisma/client";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { Type } from "class-transformer";
import { IsOptional, IsInt, IsEnum, IsDateString } from "class-validator";
import { IsOptional, IsInt, IsEnum, IsDateString, IsEmail } from "class-validator";
export class SearchLeaveRequestsDto {
//remove emp_id and use email
@IsOptional()
@Type(()=> Number)
@IsInt()
employee_id?: number;
@IsEmail()
email: string;
@IsOptional()
@Type(()=> Number)
@ -20,9 +18,13 @@ export class SearchLeaveRequestsDto {
@IsOptional()
@IsDateString()
start_date?: Date;
start_date?: string;
@IsOptional()
@IsDateString()
end_date?: Date;
end_date?: string;
@IsOptional()
@IsEnum(LeaveTypes)
leave_type?: LeaveTypes;
}

View File

@ -0,0 +1,17 @@
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { LeaveRequestArchiveRow } from "../utils/leave-requests-archive.select";
const toISO = (date: Date | null): string | null => (date ? date.toISOString().slice(0,10): null);
export function mapArchiveRowToView(row: LeaveRequestArchiveRow, email: string, employee_full_name:string): LeaveRequestViewDto {
return {
id: row.id,
leave_type: row.leave_type,
start_date_time: toISO(row.start_date_time)!,
end_date_time: toISO(row.end_date_time),
comment: row.comment,
approval_status: row.approval_status,
email,
employee_full_name,
}
}

View File

@ -0,0 +1,19 @@
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { LeaveRequestRow } from "../utils/leave-requests.select";
function toISODateString(date:Date | null): string | null {
return date ? date.toISOString().slice(0,10) : null;
}
export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto {
return {
id: row.id,
leave_type: row.leave_type,
start_date_time: toISODateString(row.start_date_time)!,
end_date_time: toISODateString(row.end_date_time),
comment: row.comment,
approval_status: row.approval_status,
email: row.employee.user.email,
employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`
}
}

View File

@ -8,6 +8,13 @@ import { SickLeaveService } from "src/modules/business-logics/services/sick-leav
import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto";
import { buildPrismaWhere } from "src/common/shared/build-prisma-where";
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { LeaveRequestRow, leaveRequestsSelect } from "../utils/leave-requests.select";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { LeaveRequestsArchiveController } from "src/modules/archival/controllers/leave-requests-archive.controller";
import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select";
import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper";
import { mapArchiveRowToViewWithDays, mapRowToViewWithDays } from "../utils/leave-request.transform";
@Injectable()
export class LeaveRequestsService {
@ -18,138 +25,93 @@ export class LeaveRequestsService {
private readonly sickLeaveService: SickLeaveService
) {}
//remove emp_id and use email
async create(dto: CreateLeaveRequestsDto): Promise<LeaveRequests> {
const { employee_id, bank_code_id, leave_type, start_date_time,
end_date_time, comment, approval_status } = dto;
return this.prisma.leaveRequests.create({
data: { employee_id, bank_code_id, leave_type, start_date_time,
end_date_time, comment, approval_status: approval_status ?? undefined
},
include: { employee: { include: { user: true } },
bank_code: true
},
//function to avoid using employee_id as identifier in the frontend.
private async resolveEmployeeIdByEmail(email: string): Promise<number> {
const employee = await this.prisma.employees.findFirst({
where: { user: { email} },
select: { id:true },
});
if(!employee) throw new NotFoundException(`Employee with email ${email} not found`);
return employee.id;
}
async findAll(filters: SearchLeaveRequestsDto): Promise<any[]> {
const {start_date, end_date, ...other_filters } = filters;
const where: Record<string, any> = buildPrismaWhere(other_filters);
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,
},
});
const ms_per_day = 1000 * 60 * 60 * 24;
return Promise.all(
list.map(async request => {
// end_date fallback
const end_date = request.end_date_time ?? request.start_date_time;
//Requested days
const diff_days = Math.round((end_date.getTime() - request.start_date_time.getTime()) / ms_per_day) +1;
// modifier fallback/validation
if (!request.bank_code || request.bank_code.modifier == null) {
throw new BadRequestException(`Modifier manquant pour bank_code_id=${request.bank_code_id}`);
}
const modifier = request.bank_code.modifier;
let cost: number;
switch (request.bank_code.type) {
case 'holiday' :
cost = await this.holidayService.calculateHolidayPay( request.employee_id, request.start_date_time, modifier );
break;
case 'vacation' :
cost = await this.vacationService.calculateVacationPay( request.employee_id, request.start_date_time,diff_days, modifier );
break;
case 'sick' :
cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diff_days, modifier );
break;
default:
cost = diff_days * modifier;
}
return {...request, days_requested: diff_days, cost };
})
);
}
//remove emp_id and use email
async findOne(id:number): Promise<any> {
const request = await this.prisma.leaveRequests.findUnique({
where: { id },
include: { employee: { include: { user: true } },
bank_code: true,
},
});
if(!request) {
throw new NotFoundException(`LeaveRequest #${id} not found`);
}
//validation and fallback for end_date_time
const end_Date = request.end_date_time ?? request.start_date_time;
//calculate included days
const msPerDay = 1000 * 60 * 60 * 24;
const diff_days = Math.floor((end_Date.getTime() - request.start_date_time.getTime())/ msPerDay) + 1;
if (!request.bank_code || request.bank_code.modifier == null) {
throw new BadRequestException(`Modifier missing for code ${request.bank_code_id}`);
}
const modifier = request.bank_code.modifier;
//calculate cost based on bank_code types
let cost = diff_days * modifier;
switch(request.bank_code.type) {
case 'holiday':
cost = await this.holidayService.calculateHolidayPay( request.employee_id, request.start_date_time, modifier );
break;
case 'vacation':
cost = await this.vacationService.calculateVacationPay( request.employee_id, request.start_date_time, diff_days, modifier );
break;
case 'sick':
cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diff_days, modifier );
break;
default:
cost = diff_days * modifier;
}
return {...request, days_requested: diff_days, cost };
}
//remove emp_id and use email
async update(id: number, dto: UpdateLeaveRequestsDto): Promise<LeaveRequests> {
await this.findOne(id);
const { employee_id, leave_type, start_date_time, end_date_time, comment, approval_status } = dto;
return this.prisma.leaveRequests.update({
where: { id },
//create a leave-request without the use of employee_id
async create(dto: CreateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
const employee_id = await this.resolveEmployeeIdByEmail(dto.email);
const row: LeaveRequestRow = await this.prisma.leaveRequests.create({
data: {
...(employee_id !== undefined && { employee_id }),
...(leave_type !== undefined && { leave_type } ),
...(start_date_time !== undefined && { start_date_time }),
...(end_date_time !== undefined && { end_date_time }),
...(comment !== undefined && { comment }),
...(approval_status !== undefined && { approval_status }),
employee_id,
bank_code_id: dto.bank_code_id,
leave_type: dto.leave_type,
start_date_time: new Date(dto.start_date_time),
end_date_time: dto.end_date_time ? new Date(dto.end_date_time) : null,
comment: dto.comment,
approval_status: dto.approval_status ?? undefined,
},
include: { employee: { include: { user:true } } },
select: leaveRequestsSelect,
});
return mapRowToViewWithDays(row);
}
async remove(id:number): Promise<LeaveRequests> {
await this.findOne(id);
return this.prisma.leaveRequests.delete({
where: { id },
//fetches all leave-requests using email
async findAll(filters: SearchLeaveRequestsDto): Promise<LeaveRequestViewDto[]> {
const {start_date, end_date,email, leave_type, approval_status, bank_code_id } = filters;
const where: any = {};
if (start_date) where.start_date_time = { ...(where.start_date_time ?? {}), gte: new Date(start_date) };
if (end_date) where.end_date_time = { ...(where.end_date_time ?? {}), lte: new Date(end_date) };
if (email) where.employee = { user: { email } };
if (leave_type) where.leave_type = leave_type;
if (approval_status) where.approval_status = approval_status;
if (typeof bank_code_id === 'number') where.bank_code_id = bank_code_id;
const rows= await this.prisma.leaveRequests.findMany({
where,
select: leaveRequestsSelect,
orderBy: { start_date_time: 'desc' },
});
return rows.map(mapRowToViewWithDays);
}
//fetch 1 leave-request using email
async findOne(id:number): Promise<LeaveRequestViewDto> {
const row: LeaveRequestRow | null = await this.prisma.leaveRequests.findUnique({
where: { id },
select: leaveRequestsSelect,
});
if(!row) throw new NotFoundException(`Leave Request #${id} not found`);
return mapRowToViewWithDays(row);
}
//updates 1 leave-request using email
async update(id: number, dto: UpdateLeaveRequestsDto): Promise<LeaveRequestViewDto> {
await this.findOne(id);
const data: Record<string, any> = {};
if(dto.email !== undefined) data.employee_id = await this.resolveEmployeeIdByEmail(dto.email);
if(dto.leave_type !== undefined) data.bank_code_id = dto.bank_code_id;
if(dto.start_date_time !== undefined) data.start_date_time = new Date(dto.start_date_time);
if(dto.end_date_time !== undefined) data.end_date_time = new Date(dto.end_date_time);
if(dto.comment !== undefined) data.comment = dto.comment;
if(dto.approval_status !== undefined) data.approval_status = dto.approval_status;
const row: LeaveRequestRow = await this.prisma.leaveRequests.update({
where: { id },
data,
select: leaveRequestsSelect,
});
return mapRowToViewWithDays(row);
}
//removes 1 leave-request using email
async remove(id:number): Promise<LeaveRequestViewDto> {
await this.findOne(id);
const row: LeaveRequestRow = await this.prisma.leaveRequests.delete({
where: { id },
select: leaveRequestsSelect,
});
return mapRowToViewWithDays(row);
}
//archivation functions ******************************************************
@ -184,14 +146,30 @@ export class LeaveRequestsService {
});
}
//fetches all archived employees
//fetches all archived leave-requests
async findAllArchived(): Promise<LeaveRequestsArchive[]> {
return this.prisma.leaveRequestsArchive.findMany();
}
//remove emp_id and use email
//fetches an archived employee
async findOneArchived(id: number): Promise<LeaveRequestsArchive> {
return this.prisma.leaveRequestsArchive.findUniqueOrThrow({ where: { id } });
async findOneArchived(id: number): Promise<LeaveRequestViewDto> {
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 emp = await this.prisma.employees.findUnique({
where: { id: row.employee_id },
select: { user: {select: { email:true,
first_name: true,
last_name: true,
}}},
});
const email = emp?.user.email ?? "";
const full_name = emp ? `${emp.user.first_name} ${emp.user.last_name}` : "";
return mapArchiveRowToViewWithDays(row, email, full_name);
}
}

View File

@ -0,0 +1,32 @@
import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto";
import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { LeaveRequestArchiveRow } from "./leave-requests-archive.select";
import { LeaveRequestRow } from "./leave-requests.select";
function toUTCDateOnly(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
const MS_PER_DAY = 86_400_000;
function computeDaysRequested(start_date: Date, end_date?: Date | null): number {
const start = toUTCDateOnly(start_date);
const end = toUTCDateOnly(end_date ?? start_date);
const diff = Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY) + 1;
return Math.max(1, diff);
}
/** Active (table leave_requests) : map + days_requested */
export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto {
const view = mapRowToView(row);
view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time);
return view;
}
/** Archive (table leave_requests_archive) : map + days_requested */
export function mapArchiveRowToViewWithDays(row: LeaveRequestArchiveRow, email: string, employee_full_name?: string):
LeaveRequestViewDto {
const view = mapArchiveRowToView(row, email, employee_full_name!);
view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time);
return view;
}

View File

@ -0,0 +1,16 @@
import { Prisma } from "@prisma/client";
export const leaveRequestsArchiveSelect = {
id: true,
leave_request_id: true,
archived_at: true,
employee_id: true,
leave_type: true,
start_date_time: true,
end_date_time: true,
comment: true,
approval_status: true,
} satisfies Prisma.LeaveRequestsArchiveSelect;
export type LeaveRequestArchiveRow = Prisma.LeaveRequestsArchiveGetPayload<{ select: typeof leaveRequestsArchiveSelect}>;

View File

@ -0,0 +1,22 @@
import { Prisma } from "@prisma/client";
//custom prisma select to avoid employee_id exposure
export const leaveRequestsSelect = {
id: true,
bank_code_id: true,
leave_type: true,
start_date_time: true,
end_date_time: true,
comment: true,
approval_status: true,
employee: { select: {
id: true,
user: { select: {
email: true,
first_name: true,
last_name: true,
}},
}},
} satisfies Prisma.LeaveRequestsSelect;
export type LeaveRequestRow = Prisma.LeaveRequestsGetPayload<{ select: typeof leaveRequestsSelect}>;

View File

@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import { TimesheetsQueryService } from '../services/timesheets-query.service';
import { CreateTimesheetDto } from '../dtos/create-timesheet.dto';
import { Timesheets } from '@prisma/client';
@ -8,6 +8,7 @@ import { Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { TimesheetsCommandService } from '../services/timesheets-command.service';
import { SearchTimesheetDto } from '../dtos/search-timesheet.dto';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
@ApiTags('Timesheets')
@ApiBearerAuth('access-token')
@ -15,8 +16,8 @@ import { SearchTimesheetDto } from '../dtos/search-timesheet.dto';
@Controller('timesheets')
export class TimesheetsController {
constructor(
private readonly timesheetsService: TimesheetsQueryService,
private readonly timesheetsCommandService: TimesheetsCommandService,
private readonly timesheetsQuery: TimesheetsQueryService,
private readonly timesheetsCommand: TimesheetsCommandService,
) {}
@Post()
@ -25,17 +26,18 @@ export class TimesheetsController {
@ApiResponse({ status: 201, description: 'Timesheet created', type: CreateTimesheetDto })
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
create(@Body() dto: CreateTimesheetDto): Promise<Timesheets> {
return this.timesheetsService.create(dto);
return this.timesheetsQuery.create(dto);
}
@Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find all timesheets' })
@ApiResponse({ status: 201, description: 'List of timesheet found', type: CreateTimesheetDto, isArray: true })
@ApiResponse({ status: 400, description: 'List of timesheets not found' })
@UsePipes(new ValidationPipe({transform: true, whitelist: true }))
findAll(@Query() filters: SearchTimesheetDto): Promise<any[]> {
return this.timesheetsService.findAll(filters);
async getPeriodByQuery(
@Query('year', ParseIntPipe ) year: number,
@Query('period_no', ParseIntPipe ) period_no: number,
@Query('email') email?: string
): Promise<TimesheetPeriodDto> {
if(!email || !email.trim()) throw new BadRequestException('Query param "email" is mandatory for this route.');
return this.timesheetsQuery.findAll(year, period_no, email.trim());
}
@Get(':id')
@ -44,7 +46,7 @@ export class TimesheetsController {
@ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto })
@ApiResponse({ status: 400, description: 'Timesheet not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
return this.timesheetsService.findOne(id);
return this.timesheetsQuery.findOne(id);
}
@Patch(':id')
@ -56,7 +58,7 @@ export class TimesheetsController {
@Param('id', ParseIntPipe) id:number,
@Body() dto: UpdateTimesheetDto,
): Promise<Timesheets> {
return this.timesheetsService.update(id, dto);
return this.timesheetsQuery.update(id, dto);
}
@Delete(':id')
@ -65,12 +67,12 @@ export class TimesheetsController {
@ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto })
@ApiResponse({ status: 400, description: 'Timesheet not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
return this.timesheetsService.remove(id);
return this.timesheetsQuery.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.timesheetsCommandService.updateApproval(id, isApproved);
return this.timesheetsCommand.updateApproval(id, isApproved);
}
}

View File

@ -0,0 +1,45 @@
export class ShiftDto {
start: string;
end : string;
is_approved: boolean;
}
export class ExpenseDto {
amount: number;
is_approved: boolean;
}
export type DayShiftsDto = ShiftDto[];
export class DayExpensesDto {
cash: ExpenseDto[] = [];
km : ExpenseDto[] = [];
[otherType:string]: ExpenseDto[] | any; //pour si on ajoute d'autre type de dépenses
}
export class WeekDto {
is_approved: boolean;
shifts: {
sun: DayShiftsDto;
mon: DayShiftsDto;
tue: DayShiftsDto;
wed: DayShiftsDto;
thu: DayShiftsDto;
fri: DayShiftsDto;
sat: DayShiftsDto;
}
expenses: {
sun: DayExpensesDto;
mon: DayExpensesDto;
tue: DayExpensesDto;
wed: DayExpensesDto;
thu: DayExpensesDto;
fri: DayExpensesDto;
sat: DayExpensesDto;
}
}
export class TimesheetPeriodDto {
week1: WeekDto;
week2: WeekDto;
}

View File

@ -7,8 +7,12 @@ import { OvertimeService } from 'src/modules/business-logics/services/overtime.s
import { computeHours } from 'src/common/utils/date-utils';
import { buildPrismaWhere } from 'src/common/shared/build-prisma-where';
import { SearchTimesheetDto } from '../dtos/search-timesheet.dto';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers';
const ROUND_TO = 5;
// deprecated (used with old findAll) const ROUND_TO = 5;
type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean };
type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean };
@Injectable()
export class TimesheetsQueryService {
@ -30,38 +34,63 @@ export class TimesheetsQueryService {
});
}
async findAll(filters: SearchTimesheetDto): Promise<any[]> {
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 } },
},
async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
//finds the employee
const employee = await this.prisma.employees.findFirst({
where: { user: { is: { email } } },
select: { id: true },
});
if(!employee) throw new NotFoundException(`no employee with email ${email} found`);
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, ROUND_TO);
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 };
//finds the period
const period = await this.prisma.payPeriods.findFirst({
where: { pay_year: year, pay_period_no: period_no },
select: { period_start: true, period_end: true },
});
if(!period) throw new NotFoundException(`Period ${year}-${period_no} not found`);
const from = toUTCDateOnly(period.period_start);
const to = endOfDayUTC(period.period_end);
//collects data from shifts and expenses
const [ raw_shifts, raw_expenses] = await Promise.all([
this.prisma.shifts.findMany({
where: {
timesheet: { is: { employee_id: employee.id } },
date: { gte: from, lte: to },
},
select: { date: true,start_time: true, end_time: true, is_approved: true },
orderBy: [{date: 'asc'}, { start_time: 'asc' }],
}),
);
return detailedlist;
this.prisma.expenses.findMany({
where: {
timesheet: { is: { employee_id: employee.id } },
date: { gte: from, lte: to },
},
select: { date: true, amount: true, is_approved: true, bank_code: {
select: { type: true } },
},
orderBy: { date: 'asc' },
}),
]);
//Shift data mapping
const shifts: ShiftRow[] = raw_shifts.map(shift => ({
date: shift.date,
start_time: shift.start_time,
end_time: shift.end_time,
is_approved: shift.is_approved ?? true,
}));
const expenses: ExpenseRow[] = raw_expenses.map(expense => ({
date: expense.date,
amount: typeof (expense.amount as any)?.toNumber() === 'function' ?
(expense.amount as any).toNumber() : Number(expense.amount),
type: expense.bank_code?.type ?? 'CASH',
is_approved: expense.is_approved ?? true,
}));
return buildPeriod(period.period_start, period.period_end, shifts , expenses);
}
async findOne(id: number): Promise<any> {

View File

@ -0,0 +1,134 @@
import { DayExpensesDto, DayShiftsDto, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto";
//makes the strings indexes for arrays
export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
export type DayKey = 'sun'|'mon'|'tue'|'wed'|'thu'|'fri'|'sat';
//DB line types
type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean };
type ExpenseRow = {date: Date; amount: number; type: string; is_approved?: boolean };
export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday
return DAY_KEYS[index];
}
const MS_PER_DAY = 86_400_000;
export function toUTCDateOnly(date: Date | string): Date {
const d = new Date(date);
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
export function addDays(date:Date, days: number): Date {
return new Date(date.getTime() + days * MS_PER_DAY);
}
export function endOfDayUTC(date: Date | string): Date {
const d = toUTCDateOnly(date);
return new Date(d.getTime() + MS_PER_DAY - 1);
}
export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): boolean {
const time = date.getTime();
return time >= start.getTime() && time <= end_inclusive.getTime();
}
export function dayIndex(week_start: Date, date: Date): 1|2|3|4|5|6|7 {
const diff = Math.floor((toUTCDateOnly(date).getTime() - toUTCDateOnly(week_start).getTime())/ MS_PER_DAY);
const index = Math.min(6, Math.max(0, diff)) + 1;
return index as 1|2|3|4|5|6|7;
}
export function toTimeString(date: Date): string {
const hours = String(date.getUTCHours()).padStart(2,'0');
const minutes = String(date.getUTCMinutes()).padStart(2,'0');
return `${hours}:${minutes}`;
}
export function round2(num: number) {
return Math.round(num * 100) / 100;
}
export function makeEmptyDayShifts(): DayShiftsDto { return []; }
export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; }
export function makeEmptyWeek(): WeekDto {
return {
is_approved: true,
shifts: {
sun: makeEmptyDayShifts(),
mon: makeEmptyDayShifts(),
tue: makeEmptyDayShifts(),
wed: makeEmptyDayShifts(),
thu: makeEmptyDayShifts(),
fri: makeEmptyDayShifts(),
sat: makeEmptyDayShifts(),
},
expenses: {
sun: makeEmptyDayExpenses(),
mon: makeEmptyDayExpenses(),
tue: makeEmptyDayExpenses(),
wed: makeEmptyDayExpenses(),
thu: makeEmptyDayExpenses(),
fri: makeEmptyDayExpenses(),
sat: makeEmptyDayExpenses(),
},
};
}
export function makeEmptyPeriod(): TimesheetPeriodDto {
return { week1: makeEmptyWeek(), week2: makeEmptyWeek() };
}
//needs ajusting according to DB's data for expenses types
export function normalizeExpenseBucket(db_type: string): 'km' | 'cash' {
const type = db_type.trim().toUpperCase();
if(type.includes('KM') || type.includes('MILEAGE')) return 'km';
return 'cash';
}
export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): WeekDto {
const week = makeEmptyWeek();
let all_approved = true;
//Shifts mapped and filtered by dates
const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end));
for (const shift of week_shifts) {
const key = dayKeyFromDate(shift.date, true);
week.shifts[key].push({
start: toTimeString(shift.start_time),
end : toTimeString(shift.end_time),
is_approved: shift.is_approved ?? true,
} as ShiftDto);
all_approved = all_approved && (shift.is_approved ?? true);
}
//Expenses mapped and filtered by dates
const week_expenses = expenses.filter(expense => isBetweenUTC(expense.date, week_start, week_end));
for (const expense of week_expenses) {
const key = dayKeyFromDate(expense.date, true);
const bucket = normalizeExpenseBucket(expense.type);
if (!Array.isArray(week.expenses[key][bucket])) week.expenses[key][bucket] = [];
week.expenses[key][bucket].push({
amount: round2(expense.amount),
is_approved: expense.is_approved ?? true,
});
all_approved = all_approved && (expense.is_approved ?? true);
}
week.is_approved = all_approved;
return week;
}
export function buildPeriod( period_start: Date, period_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): TimesheetPeriodDto {
const week1_start = toUTCDateOnly(period_start);
const week1_end = endOfDayUTC(addDays(week1_start, 6));
const week2_start = toUTCDateOnly(addDays(week1_start, 7));
const week2_end = endOfDayUTC(period_end);
return {
week1: buildWeek(week1_start, week1_end, shifts, expenses),
week2: buildWeek(week2_start, week2_end, shifts, expenses),
};
}