feat(approval): selected approval by bulk for pay-period using crew view. renamed variables to use snake_case

This commit is contained in:
Matthieu Haineault 2025-08-20 15:33:17 -04:00
parent d44f8da99f
commit 3c8c999bfe
26 changed files with 569 additions and 472 deletions

View File

@ -2073,36 +2073,26 @@
]
}
},
"/pay-periods/approval/{year}/{periodNumber}": {
"/pay-periods/crew/bulk-approval": {
"patch": {
"operationId": "PayPeriodsController_approve",
"parameters": [
{
"name": "year",
"required": true,
"in": "path",
"schema": {
"example": 2024,
"type": "number"
}
},
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
"operationId": "PayPeriodsController_bulkApproval",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkCrewApprovalDto"
}
}
}
],
},
"responses": {
"200": {
"description": "Pay period approved"
}
},
"summary": "Approve all timesheets with activity in the period",
"summary": "Approve all selected timesheets in the period",
"tags": [
"pay-periods"
]
@ -3097,6 +3087,10 @@
"periods"
]
},
"BulkCrewApprovalDto": {
"type": "object",
"properties": {}
},
"EmployeePeriodOverviewDto": {
"type": "object",
"properties": {

View File

@ -36,7 +36,7 @@ export abstract class BaseApprovalService<T> {
}
//approval with transaction to avoid many requests
async updateApprovalWithTx(transaction: Prisma.TransactionClient, id: number, isApproved: boolean): Promise<T> {
async updateApprovalWithTransaction(transaction: Prisma.TransactionClient, id: number, isApproved: boolean): Promise<T> {
try {
return await this.delegateFor(transaction).update({
where: { id },

View File

@ -14,15 +14,15 @@ export class AfterHoursService {
private getPreBusinessMinutes(start: Date, end: Date): number {
const bizStart = new Date(start);
bizStart.setHours(AfterHoursService.BUSINESS_START, 0,0,0);
const biz_start = new Date(start);
biz_start.setHours(AfterHoursService.BUSINESS_START, 0,0,0);
if (end>= start || start >= bizStart) {
if (end>= start || start >= biz_start) {
return 0;
}
const segmentEnd = end < bizStart ? end : bizStart;
const minutes = (segmentEnd.getTime() - start.getTime()) / 60000;
const segment_end = end < biz_start ? end : biz_start;
const minutes = (segment_end.getTime() - start.getTime()) / 60000;
this.logger.debug(`getPreBusinessMintutes -> ${minutes.toFixed(1)}min`);
return minutes;
@ -30,15 +30,15 @@ export class AfterHoursService {
}
private getPostBusinessMinutes(start: Date, end: Date): number {
const bizEnd = new Date(start);
bizEnd.setHours(AfterHoursService.BUSINESS_END,0,0,0);
const biz_end = new Date(start);
biz_end.setHours(AfterHoursService.BUSINESS_END,0,0,0);
if( end <= bizEnd ) {
if( end <= biz_end ) {
return 0;
}
const segmentStart = start > bizEnd ? start : bizEnd;
const minutes = (end.getTime() - segmentStart.getTime()) / 60000;
const segment_start = start > biz_end ? start : biz_end;
const minutes = (end.getTime() - segment_start.getTime()) / 60000;
this.logger.debug(`getPostBusinessMintutes -> ${minutes.toFixed(1)}min`);
return minutes;
@ -62,17 +62,17 @@ export class AfterHoursService {
'You must create 2 instances, one on the first day and the second during the next day.');
}
const preMin = this.getPreBusinessMinutes(start, end);
const postMin = this.getPostBusinessMinutes(start, end);
const rawAftermin = preMin + postMin;
const pre_min = this.getPreBusinessMinutes(start, end);
const post_min = this.getPostBusinessMinutes(start, end);
const raw_aftermin = pre_min + post_min;
const roundedMin = this.roundToNearestQUarterMinute(rawAftermin);
const rounded_min = this.roundToNearestQUarterMinute(raw_aftermin);
const hours = roundedMin / 60;
const hours = rounded_min / 60;
const result = parseFloat(hours.toFixed(2));
this.logger.debug(`computeAfterHours -> rawAfterMin= ${rawAftermin.toFixed(1)}min, +
rounded = ${roundedMin}min, hours = ${result.toFixed(2)}`);
this.logger.debug(`computeAfterHours -> raw_aftermin = ${raw_aftermin.toFixed(1)}min, +
rounded = ${rounded_min}min, hours = ${result.toFixed(2)}`);
return result;
}
}

View File

@ -9,34 +9,34 @@ export class HolidayService {
constructor(private readonly prisma: PrismaService) {}
//switch employeeId for email
private async computeHoursPrevious4Weeks(employeeId: number, holidayDate: Date): Promise<number> {
private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise<number> {
//sets the end of the window to 1ms before the week with the holiday
const holidayWeekStart = getWeekStart(holidayDate);
const windowEnd = new Date(holidayWeekStart.getTime() - 1);
const holiday_week_start = getWeekStart(holiday_date);
const window_end = new Date(holiday_week_start.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 window_start = new Date(window_end.getTime() - 28 * 24 * 60 * 60000 + 1 )
const validCodes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700'];
const valid_codes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700'];
//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 },
bank_code: { bank_code: { in: validCodes } },
where: { timesheet: { employee_id: employee_id } ,
date: { gte: window_start, lte: window_end },
bank_code: { bank_code: { in: valid_codes } },
},
select: { date: true, start_time: true, end_time: true },
});
const totalHours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0);
const dailyHours = totalHours / 20;
const total_hours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0);
const daily_hours = total_hours / 20;
return dailyHours;
return daily_hours;
}
//switch employeeId for email
async calculateHolidayPay( employeeId: number, holidayDate: Date, modifier: number): Promise<number> {
const hours = await this.computeHoursPrevious4Weeks(employeeId, holidayDate);
const dailyRate = Math.min(hours, 8);
async calculateHolidayPay( employee_id: number, holiday_date: Date, modifier: number): Promise<number> {
const hours = await this.computeHoursPrevious4Weeks(employee_id, holiday_date);
const daily_rate = Math.min(hours, 8);
this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`);
return dailyRate * modifier;
return daily_rate * modifier;
}
}

View File

@ -8,28 +8,28 @@ export class MileageService {
constructor(private readonly prisma: PrismaService) {}
public async calculateReimbursement(amount: number, bankCodeId: number): Promise<number> {
public async calculateReimbursement(amount: number, bank_code_id: number): Promise<number> {
if(amount < 0) {
throw new BadRequestException(`The amount most be higher than 0`);
}
//fetch modifier
const bankCode = await this.prisma.bankCodes.findUnique({
where: { id: bankCodeId },
const bank_code = await this.prisma.bankCodes.findUnique({
where: { id: bank_code_id },
select: { modifier: true, type: true },
});
if(!bankCode) {
throw new BadRequestException(`bank_code ${bankCodeId} not found`);
if(!bank_code) {
throw new BadRequestException(`bank_code ${bank_code_id} not found`);
}
if(bankCode.type !== 'mileage') {
this.logger.warn(`bank_code ${bankCodeId} of type ${bankCode.type} is used for mileage`)
if(bank_code.type !== 'mileage') {
this.logger.warn(`bank_code ${bank_code_id} of type ${bank_code.type} is used for mileage`)
}
//calculate total amount to reimburs
const reimboursement = amount * bankCode.modifier;
const reimboursement = amount * bank_code.modifier;
const result = parseFloat(reimboursement.toFixed(2));
this.logger.debug(`calculateReimbursement -> amount= ${amount}, modifier= ${bankCode.modifier}, total= ${result}`);
this.logger.debug(`calculateReimbursement -> amount= ${amount}, modifier= ${bank_code.modifier}, total= ${result}`);
return result;
}

View File

@ -6,29 +6,29 @@ import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-ut
export class OvertimeService {
private logger = new Logger(OvertimeService.name);
private dailyMax = 8; // maximum for regular hours per day
private weeklyMax = 40; //maximum for regular hours per week
private daily_max = 12; // maximum for regular hours per day
private weekly_max = 80; //maximum for regular hours per week
constructor(private prisma: PrismaService) {}
//calculate Daily overtime
getDailyOvertimeHours(start: Date, end: Date): number {
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})`);
const overtime = Math.max(0, hours - this.daily_max);
this.logger.debug(`getDailyOvertimeHours : ${overtime.toFixed(2)}h (threshold ${this.daily_max})`);
return overtime;
}
//calculate Weekly overtime
//switch employeeId for email
async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise<number> {
const weekStart = getWeekStart(refDate);
const weekEnd = getWeekEnd(weekStart);
const week_start = getWeekStart(refDate);
const week_end = getWeekEnd(week_start);
//fetches all shifts containing hours
const shifts = await this.prisma.shifts.findMany({
where: { timesheet: { employee_id: employeeId, shift: {
every: {date: { gte: weekStart, lte: weekEnd } }
every: {date: { gte: week_start, lte: week_end } }
},
},
},
@ -38,16 +38,16 @@ export class OvertimeService {
//calculate total hours of those shifts minus weekly Max to find total overtime hours
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);
const overtime = Math.max(0, total - this.weekly_max);
this.logger.debug(`weekly total = ${total.toFixed(2)}h, weekly Overtime= ${overtime.toFixed(2)}h`);
return overtime;
}
//apply modifier to overtime hours
calculateOvertimePay(overtimeHours: number, modifier: number): number {
const pay = overtimeHours * modifier;
this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtimeHours}, modifier ${modifier})`);
calculateOvertimePay(overtime_hours: number, modifier: number): number {
const pay = overtime_hours * modifier;
this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`);
return pay;
}

View File

@ -9,55 +9,58 @@ export class SickLeaveService {
private readonly logger = new Logger(SickLeaveService.name);
//switch employeeId for email
async calculateSickLeavePay(employeeId: number, referenceDate: Date, daysRequested: number, modifier: number): Promise<number> {
async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number):
Promise<number> {
//sets the year to jan 1st to dec 31st
const periodStart = getYearStart(referenceDate);
const periodEnd = referenceDate;
const period_start = getYearStart(reference_date);
const period_end = reference_date;
//fetches all shifts of a selected employee
const shifts = await this.prisma.shifts.findMany({
where: {
timesheet: { employee_id: employeeId },
date: { gte: periodStart, lte: periodEnd},
timesheet: { employee_id: employee_id },
date: { gte: period_start, lte: period_end},
},
select: { date: true },
});
//count the amount of worked days
const workedDates = new Set(
const worked_dates = new Set(
shifts.map(shift => shift.date.toISOString().slice(0,10))
);
const daysWorked = workedDates.size;
this.logger.debug(`Sick leave: days worked= ${daysWorked} in ${periodStart.toDateString()} -> ${periodEnd.toDateString()}`);
const days_worked = worked_dates.size;
this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()}
-> ${period_end.toDateString()}`);
//less than 30 worked days returns 0
if (daysWorked < 30) {
if (days_worked < 30) {
return 0;
}
//default 3 days allowed after 30 worked days
let acquiredDays = 3;
let acquired_days = 3;
//identify the date of the 30th worked day
const orderedDates = Array.from(workedDates).sort();
const thresholdDate = new Date(orderedDates[29]); // index 29 is the 30th day
const ordered_dates = Array.from(worked_dates).sort();
const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day
//calculate each completed month, starting the 1st of the next month
const firstBonusDate = new Date(thresholdDate.getFullYear(), thresholdDate.getMonth() +1, 1);
let months = (periodEnd.getFullYear() - firstBonusDate.getFullYear()) * 12 +
(periodEnd.getMonth() - firstBonusDate.getMonth()) + 1;
const first_bonus_date = new Date(threshold_date.getFullYear(), threshold_date.getMonth() +1, 1);
let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
(period_end.getMonth() - first_bonus_date.getMonth()) + 1;
if(months < 0) months = 0;
acquiredDays += months;
acquired_days += months;
//cap of 10 days
if (acquiredDays > 10) acquiredDays = 10;
if (acquired_days > 10) acquired_days = 10;
this.logger.debug(`Sick leave: threshold Date = ${thresholdDate.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquiredDays}`);
this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()}
, bonusMonths = ${months}, acquired Days = ${acquired_days}`);
const payableDays = Math.min(acquiredDays, daysRequested);
const rawHours = payableDays * 8 * modifier;
const rounded = roundToQuarterHour(rawHours)
this.logger.debug(`Sick leave pay: days= ${payableDays}, modifier= ${modifier}, hours= ${rounded}`);
const payable_days = Math.min(acquired_days, days_requested);
const raw_hours = payable_days * 8 * modifier;
const rounded = roundToQuarterHour(raw_hours)
this.logger.debug(`Sick leave pay: days= ${payable_days}, modifier= ${modifier}, hours= ${rounded}`);
return rounded;
}
}

View File

@ -9,76 +9,76 @@ export class VacationService {
/**
* Calculate the ammount allowed for vacation days.
*
* @param employeeId employee ID
* @param employee_id employee ID
* @param startDate first day of vacation
* @param daysRequested number of days requested
* @param modifier Coefficient of hours(1)
* @returns amount of payable hours
*/
//switch employeeId for email
async calculateVacationPay(employeeId: number, startDate: Date, daysRequested: number, modifier: number): Promise<number> {
async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> {
//fetch hiring date
const employee = await this.prisma.employees.findUnique({
where: { id: employeeId },
where: { id: employee_id },
select: { first_work_day: true },
});
if(!employee) {
throw new NotFoundException(`Employee #${employeeId} not found`);
throw new NotFoundException(`Employee #${employee_id} not found`);
}
const hireDate = employee.first_work_day;
const hire_date = employee.first_work_day;
//sets "year" to may 1st to april 30th
//check if hiring date is in may or later, we use hiring year, otherwise we use the year before
const yearOfRequest = startDate.getMonth() >= 4
? startDate.getFullYear() : startDate.getFullYear() -1;
const periodStart = new Date(yearOfRequest, 4, 1); //may = 4
const periodEnd = new Date(yearOfRequest + 1, 4, 0); //day 0 of may == april 30th
const year_of_request = start_date.getMonth() >= 4
? start_date.getFullYear() : start_date.getFullYear() -1;
const period_start = new Date(year_of_request, 4, 1); //may = 4
const period_end = new Date(year_of_request + 1, 4, 0); //day 0 of may == april 30th
this.logger.debug(`Vacation period for request: ${periodStart.toDateString()} -> ${periodEnd.toDateString()}`);
this.logger.debug(`Vacation period for request: ${period_start.toDateString()} -> ${period_end.toDateString()}`);
//steps to reach to get more vacation weeks in years
const checkpoint = [5, 10, 15];
const anniversaries = checkpoint.map(years => {
const anniversaryDate = new Date(hireDate);
anniversaryDate.setFullYear(anniversaryDate.getFullYear() + years);
return anniversaryDate;
}).filter(d => d>= periodStart && d <= periodEnd).sort((a,b) => a.getTime() - b.getTime());
const anniversary_date = new Date(hire_date);
anniversary_date.setFullYear(anniversary_date.getFullYear() + years);
return anniversary_date;
}).filter(d => d>= period_start && d <= period_end).sort((a,b) => a.getTime() - b.getTime());
this.logger.debug(`anniversatries steps during the period: ${anniversaries.map(date => date.toDateString()).join(',') || 'aucun'}`);
const boundaries = [periodStart, ...anniversaries,periodEnd];
const boundaries = [period_start, ...anniversaries,period_end];
//calculate prorata per segment
let totalVacationDays = 0;
const msPerDay = 1000 * 60 * 60 * 24;
let total_vacation_days = 0;
const ms_per_day = 1000 * 60 * 60 * 24;
for (let i = 0; i < boundaries.length -1; i++) {
const segmentStart = boundaries[i];
const segmentEnd = boundaries[i+1];
const segment_start = boundaries[i];
const segment_end = boundaries[i+1];
//number of days in said segment
const daysInSegment = Math.round((segmentEnd.getTime() - segmentStart.getTime())/ msPerDay);
const yearsSinceHire = (segmentStart.getFullYear() - hireDate.getFullYear()) -
(segmentStart < new Date(segmentStart.getFullYear(), hireDate.getMonth()) ? 1 : 0);
let allocDays: number;
if(yearsSinceHire < 5) allocDays = 10;
else if(yearsSinceHire < 10) allocDays = 15;
else if(yearsSinceHire < 15) allocDays = 20;
else allocDays = 25;
const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day);
const years_since_hire = (segment_start.getFullYear() - hire_date.getFullYear()) -
(segment_start < new Date(segment_start.getFullYear(), hire_date.getMonth()) ? 1 : 0);
let alloc_days: number;
if(years_since_hire < 5) alloc_days = 10;
else if(years_since_hire < 10) alloc_days = 15;
else if(years_since_hire < 15) alloc_days = 20;
else alloc_days = 25;
//prorata for said segment
const prorata = (allocDays / 365) * daysInSegment;
totalVacationDays += prorata;
const prorata = (alloc_days / 365) * days_in_segment;
total_vacation_days += prorata;
}
//compares allowed vacation pools with requested days
const payableDays = Math.min(totalVacationDays, daysRequested);
const payable_days = Math.min(total_vacation_days, days_requested);
const rawHours = payableDays * 8 * modifier;
const roundedHours = Math.round(rawHours * 4) / 4;
this.logger.debug(`Vacation pay: entitledDays=${totalVacationDays.toFixed(2)}, requestedDays=${daysRequested},
payableDays=${payableDays.toFixed(2)}, hours=${roundedHours}`);
const raw_hours = payable_days * 8 * modifier;
const rounded_hours = Math.round(raw_hours * 4) / 4;
this.logger.debug(`Vacation pay: entitledDays=${total_vacation_days.toFixed(2)}, requestedDays=${days_requested},
payableDays=${payable_days.toFixed(2)}, hours=${rounded_hours}`);
return roundedHours;
return rounded_hours;
}

View File

@ -18,8 +18,8 @@ export class CustomersService {
invoice_id,
} = dto;
return this.prisma.$transaction(async (tx) => {
const user: Users = await tx.users.create({
return this.prisma.$transaction(async (transaction) => {
const user: Users = await transaction.users.create({
data: {
first_name,
last_name,
@ -28,7 +28,7 @@ export class CustomersService {
residence,
},
});
return tx.customers.create({
return transaction.customers.create({
data: {
user_id: user.id,
invoice_id,
@ -69,8 +69,8 @@ async update(
invoice_id,
} = dto;
return this.prisma.$transaction(async (tx) => {
await tx.users.update({
return this.prisma.$transaction(async (transaction) => {
await transaction.users.update({
where: { id: customer.user_id },
data: {
...(first_name !== undefined && { first_name }),
@ -81,7 +81,7 @@ async update(
},
});
return tx.customers.update({
return transaction.customers.update({
where: { id },
data: {
...(invoice_id !== undefined && { invoice_id }),

View File

@ -8,12 +8,12 @@ import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto';
function toDateOrNull(v?: string | null): Date | null {
if (!v) return null;
const d = new Date(v);
return isNaN(d.getTime()) ? null : d;
const day = new Date(v);
return isNaN(day.getTime()) ? null : day;
}
function toDateOrUndefined(v?: string | null): Date | undefined {
const d = toDateOrNull(v ?? undefined);
return d === null ? undefined : d;
const day = toDateOrNull(v ?? undefined);
return day === null ? undefined : day;
}
@Injectable()
@ -35,8 +35,8 @@ export class EmployeesService {
is_supervisor,
} = dto;
return this.prisma.$transaction(async (tx) => {
const user: Users = await tx.users.create({
return this.prisma.$transaction(async (transaction) => {
const user: Users = await transaction.users.create({
data: {
first_name,
last_name,
@ -45,7 +45,7 @@ export class EmployeesService {
residence,
},
});
return tx.employees.create({
return transaction.employees.create({
data: {
user_id: user.id,
external_payroll_id,
@ -176,18 +176,18 @@ export class EmployeesService {
first_work_day,
last_work_day,
is_supervisor,
email: newEmail,
email: new_email,
} = dto;
return this.prisma.$transaction(async (tx) => {
return this.prisma.$transaction(async (transaction) => {
if(
first_name !== undefined ||
last_name !== undefined ||
newEmail !== undefined ||
phone_number !== undefined ||
residence !== undefined
first_name !== undefined ||
last_name !== undefined ||
new_email !== undefined ||
phone_number !== undefined ||
residence !== undefined
){
await tx.users.update({
await transaction.users.update({
where: { id: emp.user_id },
data: {
...(first_name !== undefined && { first_name }),
@ -199,7 +199,7 @@ export class EmployeesService {
});
}
const updated = await tx.employees.update({
const updated = await transaction.employees.update({
where: { id: emp.id },
data: {
...(external_payroll_id !== undefined && { external_payroll_id }),
@ -219,18 +219,18 @@ export class EmployeesService {
const emp = await this.findOne(email);
return this.prisma.$transaction(async (tx) => {
await tx.employees.updateMany({
return this.prisma.$transaction(async (transaction) => {
await transaction.employees.updateMany({
where: { supervisor_id: emp.id },
data: { supervisor_id: null },
});
const deletedEmployee = await tx.employees.delete({
const deleted_employee = await transaction.employees.delete({
where: {id: emp.id },
});
await tx.users.delete({
await transaction.users.delete({
where: { id: emp.user_id },
});
return deletedEmployee;
return deleted_employee;
});
}
@ -254,7 +254,7 @@ async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees |
const {
first_name,
last_name,
email: newEmail,
email: new_email,
phone_number,
residence,
external_payroll_id,
@ -271,28 +271,28 @@ async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees |
? toDateOrNull(last_work_day ?? null)
: undefined;
await this.prisma.$transaction(async (tx) => {
await this.prisma.$transaction(async (transaction) => {
if(
first_name !== undefined ||
last_name !== undefined ||
newEmail !== undefined ||
new_email !== undefined ||
phone_number !== undefined ||
residence !== undefined
) {
await tx.users.update({
await transaction.users.update({
where: { id: active.user_id },
data: {
...(first_name !== undefined ? { first_name } : {}),
...(last_name !== undefined ? { last_name } : {}),
...(email !== undefined ? { email: newEmail }: {}),
...(phone_number !== undefined ? { phone_number } : {}),
...(residence !== undefined ? { residence } : {}),
...(first_name !== undefined ? { first_name } : {}),
...(last_name !== undefined ? { last_name } : {}),
...(email !== undefined ? { email: new_email }: {}),
...(phone_number !== undefined ? { phone_number } : {}),
...(residence !== undefined ? { residence } : {}),
},
});
}
const updated = await tx.employees.update({
const updated = await transaction.employees.update({
where: { id: active.id },
data: {
...(external_payroll_id !== undefined ? { external_payroll_id } : {}),
@ -366,7 +366,7 @@ async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees |
//transfers the employee from archive to the employees table
private async restoreEmployee(archived: EmployeesArchive & { user:Users }, dto: UpdateEmployeeDto): Promise<Employees> {
const first_work_d = toDateOrUndefined(dto.first_work_day);
// const first_work_d = toDateOrUndefined(dto.first_work_day);
return this.prisma.$transaction(async transaction => {
//restores the archived employee into the employees table
const restored = await transaction.employees.create({

View File

@ -19,7 +19,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
async updateApproval(id: number, isApproved: boolean): Promise<Expenses> {
return this.prisma.$transaction((transaction) =>
this.updateApprovalWithTx(transaction, id, isApproved),
this.updateApprovalWithTransaction(transaction, id, isApproved),
);
}

View File

@ -20,24 +20,24 @@ export class ExpensesQueryService {
//fetches type and modifier
const bankCode = await this.prisma.bankCodes.findUnique({
const bank_code = await this.prisma.bankCodes.findUnique({
where: { id: bank_code_id },
select: { type: true, modifier: true },
});
if(!bankCode) {
if(!bank_code) {
throw new NotFoundException(`bank_code #${bank_code_id} not found`)
}
//if mileage -> service, otherwise the ratio is amount:1
let finalAmount: number;
if(bankCode.type === 'mileage') {
finalAmount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
let final_amount: number;
if(bank_code.type === 'mileage') {
final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
}else {
finalAmount = parseFloat( (rawAmount * bankCode.modifier).toFixed(2));
final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
}
return this.prisma.expenses.create({
data: { timesheet_id, bank_code_id, date, amount: finalAmount, description, is_approved, supervisor_comment},
data: { timesheet_id, bank_code_id, date, amount: final_amount, description, is_approved, supervisor_comment},
include: { timesheet: { include: { employee: { include: { user: true }}}},
bank_code: true,
},
@ -94,28 +94,28 @@ export class ExpensesQueryService {
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archivedTimesheets = await this.prisma.timesheetsArchive.findMany({
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
const timesheetIds = archivedTimesheets.map(sheet => sheet.timesheet_id);
if(timesheetIds.length === 0) {
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
if(timesheet_ids.length === 0) {
return;
}
// copy/delete transaction
await this.prisma.$transaction(async transaction => {
//fetches expenses to move to archive
const expensesToArchive = await transaction.expenses.findMany({
where: { timesheet_id: { in: timesheetIds } },
const expenses_to_archive = await transaction.expenses.findMany({
where: { timesheet_id: { in: timesheet_ids } },
});
if(expensesToArchive.length === 0) {
if(expenses_to_archive.length === 0) {
return;
}
//copies sent to archive table
await transaction.expensesArchive.createMany({
data: expensesToArchive.map(exp => ({
data: expenses_to_archive.map(exp => ({
expense_id: exp.id,
timesheet_id: exp.timesheet_id,
bank_code_id: exp.bank_code_id,
@ -130,7 +130,7 @@ export class ExpensesQueryService {
//delete from expenses table
await transaction.expenses.deleteMany({
where: { id: { in: expensesToArchive.map(exp => exp.id) } },
where: { id: { in: expenses_to_archive.map(exp => exp.id) } },
})
})

View File

@ -63,8 +63,8 @@ export class LeaveRequestController {
@Patch(':id/approval')
updateApproval( @Param('id', ParseIntPipe) id: number,
@Body('is_approved', ParseBoolPipe) isApproved: boolean): Promise<LeaveRequests> {
const approvalStatus = isApproved ?
@Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise<LeaveRequests> {
const approvalStatus = is_approved ?
LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED;
return this.leaveRequetsService.update(id, { approval_status: approvalStatus });
}

View File

@ -33,8 +33,8 @@ export class LeaveRequestsService {
}
async findAll(filters: SearchLeaveRequestsDto): Promise<any[]> {
const {start_date, end_date, ...otherFilters } = filters;
const where: Record<string, any> = buildPrismaWhere(otherFilters);
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) };
@ -50,15 +50,15 @@ export class LeaveRequestsService {
},
});
const msPerDay = 1000 * 60 * 60 * 24;
const ms_per_day = 1000 * 60 * 60 * 24;
return Promise.all(
list.map(async request => {
// end_date fallback
const endDate = request.end_date_time ?? request.start_date_time;
const end_date = request.end_date_time ?? request.start_date_time;
//Requested days
const diffDays = Math.round((endDate.getTime() - request.start_date_time.getTime()) / msPerDay) +1;
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) {
@ -72,15 +72,15 @@ export class LeaveRequestsService {
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,diffDays, modifier );
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, diffDays, modifier );
cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diff_days, modifier );
break;
default:
cost = diffDays * modifier;
cost = diff_days * modifier;
}
return {...request, daysRequested: diffDays, cost };
return {...request, days_requested: diff_days, cost };
})
);
}
@ -96,11 +96,11 @@ export class LeaveRequestsService {
throw new NotFoundException(`LeaveRequest #${id} not found`);
}
//validation and fallback for end_date_time
const endDate = request.end_date_time ?? request.start_date_time;
const end_Date = request.end_date_time ?? request.start_date_time;
//calculate included days
const msPerDay = 1000 * 60 * 60 * 24;
const diffDays = Math.floor((endDate.getTime() - request.start_date_time.getTime())/ msPerDay) + 1;
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}`);
@ -108,27 +108,24 @@ export class LeaveRequestsService {
const modifier = request.bank_code.modifier;
//calculate cost based on bank_code types
let cost = diffDays * modifier;
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, diffDays, modifier );
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, diffDays, modifier );
cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diff_days, modifier );
break;
default:
cost = diffDays * modifier;
cost = diff_days * modifier;
}
return {...request, daysRequested: diffDays, cost };
return {...request, days_requested: diff_days, cost };
}
async update(
id: number,
dto: UpdateLeaveRequestsDto,
): Promise<LeaveRequests> {
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({

View File

@ -11,47 +11,47 @@ export class NotificationsService {
private buffers = new Map<string, NotificationCard[]>();
private readonly BUFFER_MAX = Number(process.env.NOTIF_BUFFER_MAX ?? 50);
private getOrCreateStream(userId: string): Subject<NotificationCard> {
let stream = this.streams.get(userId);
private getOrCreateStream(user_id: string): Subject<NotificationCard> {
let stream = this.streams.get(user_id);
if (!stream){
stream = new Subject<NotificationCard>();
this.streams.set(userId, stream);
this.streams.set(user_id, stream);
}
return stream;
}
private getOrCreateBuffer(userId: string){
let buffer = this.buffers.get(userId);
private getOrCreateBuffer(user_id: string){
let buffer = this.buffers.get(user_id);
if(!buffer) {
buffer = [];
this.buffers.set(userId, buffer);
this.buffers.set(user_id, buffer);
}
return buffer;
}
//in-app pushes and keep a small history
notify(userId: string, card: NotificationCard) {
const buffer = this.getOrCreateBuffer(userId);
notify(user_id: string, card: NotificationCard) {
const buffer = this.getOrCreateBuffer(user_id);
buffer.unshift(card);
if (buffer.length > this.BUFFER_MAX) {
buffer.length = this.BUFFER_MAX;
}
this.getOrCreateStream(userId).next(card);
this.logger.debug(`Notification in-app => user: ${userId} (${card.type})`);
this.getOrCreateStream(user_id).next(card);
this.logger.debug(`Notification in-app => user: ${user_id} (${card.type})`);
}
//SSE flux for current user
stream(userId: string) {
return this.getOrCreateStream(userId).asObservable();
stream(user_id: string) {
return this.getOrCreateStream(user_id).asObservable();
}
//return a summary of notifications kept in memory
async summary(userId: string): Promise<NotificationCard[]> {
return this.getOrCreateBuffer(userId);
async summary(user_id: string): Promise<NotificationCard[]> {
return this.getOrCreateBuffer(user_id);
}
//clear buffers from memory
clear(userId: string) {
this.buffers.set(userId, []);
clear(user_id: string) {
this.buffers.set(user_id, []);
}
onModuleDestroy() {

View File

@ -1,4 +1,4 @@
import { Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query } from "@nestjs/common";
import { Body, Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query } from "@nestjs/common";
import { ApiNotFoundResponse, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "@nestjs/swagger";
import { PayPeriodDto } from "../dtos/pay-period.dto";
import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto";
@ -7,6 +7,7 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client';
import { PayPeriodsCommandService } from "../services/pay-periods-command.service";
import { PayPeriodBundleDto } from "../dtos/bundle-pay-period.dto";
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
@ApiTags('pay-periods')
@Controller('pay-periods')
@ -57,18 +58,12 @@ export class PayPeriodsController {
return this.queryService.findOneByYearPeriod(year, period_no);
}
@Patch("approval/:year/:periodNumber")
@Patch("crew/bulk-approval")
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: "Approve all timesheets with activity in the period" })
@ApiParam({ name: "year", type: Number, example: 2024 })
@ApiParam({ name: "periodNumber", type: Number, example: 1, description: "1..26" })
@ApiOperation({ summary: "Approve all selected timesheets in the period" })
@ApiResponse({ status: 200, description: "Pay period approved" })
async approve(
@Param("year", ParseIntPipe) year: number,
@Param("periodNumber", ParseIntPipe) period_no: number,
) {
await this.commandService.approvalPayPeriod(year, period_no);
return { message: `Pay-period ${year}-${period_no} approved` };
async bulkApproval(@Body() dto: BulkCrewApprovalDto) {
return this.commandService.bulkApproveCrew(dto);
}
@Get(':year/:periodNumber/:email')

View File

@ -0,0 +1,29 @@
import { Type } from "class-transformer";
import { IsArray, IsBoolean, IsEmail, IsInt, IsOptional, ValidateNested } from "class-validator";
export class BulkCrewApprovalItemDto {
@IsInt()
pay_year: number;
@IsInt()
period_no: number;
@IsEmail()
employee_email!: string;
@IsBoolean()
approve: boolean;
}
export class BulkCrewApprovalDto {
@IsEmail()
supervisor_email: string;
@IsBoolean()
include_subtree: boolean = false;
@IsArray()
@ValidateNested({each: true})
@Type(()=> BulkCrewApprovalItemDto)
items: BulkCrewApprovalItemDto[]
}

View File

@ -1,5 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { Allow, IsOptional } from 'class-validator';
export class EmployeePeriodOverviewDto {
// @ApiProperty({

View File

@ -11,7 +11,6 @@ import { ShiftsCommandService } from "../shifts/services/shifts-command.service"
@Module({
imports: [PrismaModule, TimesheetsModule],
providers: [
PayPeriodsQueryService,
PayPeriodsQueryService,
PayPeriodsCommandService,
TimesheetsCommandService,

View File

@ -1,44 +1,105 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { TimesheetsCommandService } from "src/modules/timesheets/services/timesheets-command.service";
import { PrismaService } from "src/prisma/prisma.service";
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
import { PayPeriodsQueryService } from "./pay-periods-query.service";
@Injectable()
export class PayPeriodsCommandService {
constructor(
private readonly prisma: PrismaService,
private readonly timesheets_approval: TimesheetsCommandService,
private readonly query: PayPeriodsQueryService,
) {}
async approvalPayPeriod(pay_year: number , period_no: number): Promise<void> {
const period = await this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no: period_no},
});
if (!period) throw new NotFoundException(`PayPeriod #${pay_year}-${period_no} not found`);
//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');
//fetches timesheet of selected period if the timesheet has atleast 1 shift or 1 expense
const timesheet_ist = await this.prisma.timesheets.findMany({
where: {
OR: [
{ shift: {some: { date: { gte: period.period_start,
lte: period.period_end,
},
}},
},
{ expense: { some: { date: { gte: period.period_start,
lte: period.period_end,
},
}},
},
],
},
select: { id: true },
});
//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');
//approval of both timesheet (cascading to the approval of related shifts and expenses)
await this.prisma.$transaction(async (transaction)=> {
for(const {id} of timesheet_ist) {
await this.timesheets_approval.updateApprovalWithTx(transaction,id, true);
//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`);
}
})
}
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;
};
let updated = 0;
await this.prisma.$transaction(async (transaction) => {
for(const item of items) {
const { period_start, period_end } = await getPeriod(item.pay_year, item.period_no);
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 },
});
for(const { id } of t_sheets) {
await this.timesheets_approval.updateApprovalWithTransaction(transaction, id, item.approve);
updated++;
}
}
});
return {updated};
}
//function to approve a single pay-period of a single employee (deprecated)
// async approvalPayPeriod(pay_year: number , period_no: number): Promise<void> {
// const period = await this.prisma.payPeriods.findFirst({
// where: { pay_year, pay_period_no: period_no},
// });
// if (!period) throw new NotFoundException(`PayPeriod #${pay_year}-${period_no} not found`);
// //fetches timesheet of selected period if the timesheet has atleast 1 shift or 1 expense
// const timesheet_ist = await this.prisma.timesheets.findMany({
// where: {
// OR: [
// { shift: {some: { date: { gte: period.period_start,
// lte: period.period_end,
// },
// }},
// },
// { expense: { some: { date: { gte: period.period_start,
// lte: period.period_end,
// },
// }},
// },
// ],
// },
// select: { id: true },
// });
// //approval of both timesheet (cascading to the approval of related shifts and expenses)
// await this.prisma.$transaction(async (transaction)=> {
// for(const {id} of timesheet_ist) {
// await this.timesheets_approval.updateApprovalWithTransaction(transaction,id, true);
// }
// })
// }
}

View File

@ -11,18 +11,6 @@ import { mapPayPeriodToDto } from "../mappers/pay-periods.mapper";
export class PayPeriodsQueryService {
constructor( private readonly prisma: PrismaService) {}
async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodOverviewDto> {
const period = computePeriod(pay_year, period_no);
return this.buildOverview({
period_start: period.period_start,
period_end : period.period_end,
period_no : period.period_no,
pay_year : period.pay_year,
payday : period.payday,
label :period.label,
} as any);
}
async getOverview(pay_period_no: number): Promise<PayPeriodOverviewDto> {
const period = await this.prisma.payPeriods.findFirst({
where: { pay_period_no },
@ -40,6 +28,87 @@ export class PayPeriodsQueryService {
});
}
async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodOverviewDto> {
const period = computePeriod(pay_year, period_no);
return this.buildOverview({
period_start: period.period_start,
period_end : period.period_end,
period_no : period.period_no,
pay_year : period.pay_year,
payday : period.payday,
label :period.label,
} as any);
}
private async resolveCrew(supervisor_id: number, include_subtree: boolean):
Promise<Array<{ id: number; first_name: string; last_name: string; email: string }>> {
const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = [];
let frontier = await this.prisma.employees.findMany({
where: { supervisor_id: supervisor_id },
select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } },
});
result.push(...frontier.map(emp => ({
id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email
})));
if (!include_subtree) return result;
while (frontier.length) {
const parent_ids = frontier.map(emp => emp.id);
const next = await this.prisma.employees.findMany({
where: { supervisor_id: { in: parent_ids } },
select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } },
});
if (next.length === 0) break;
result.push(...next.map(emp => ({
id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email
})));
frontier = next;
}
return result;
}
async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise<Set<string>> {
const crew = await this.resolveCrew(supervisor_id, include_subtree);
return new Set(crew.map(crew_member => crew_member.email).filter(Boolean));
}
async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean):
Promise<PayPeriodOverviewDto> {
// 1) Search for the period
const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } });
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`);
// 2) fetch supervisor
const supervisor = await this.prisma.employees.findFirst({
where: { user: { email: email }},
select: {
id: true,
is_supervisor: true,
},
});
if (!supervisor) throw new NotFoundException('No employee record linked to current user');
if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor');
// 3)fetchs crew members
const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }]
const crew_ids = crew.map(c => c.id);
// seed names map for employee without data
const seed_names = new Map<number, string>(crew.map(crew => [crew.id, `${crew.first_name} ${crew.last_name}`.trim()]));
// 4) overview build
return this.buildOverview({
period_no : period.pay_period_no,
period_start: period.period_start,
period_end : period.period_end,
payday : period.payday,
pay_year : period.pay_year,
label : period.label,
}, { filtered_employee_ids: crew_ids, seed_names });
}
private async buildOverview(
period: { period_start: string | Date; period_end: string | Date; payday: string | Date;
period_no: number; pay_year: number; label: string; },
@ -130,10 +199,10 @@ export class PayPeriodsQueryService {
}
}
const ensure = (id: number, name: string) => {
const ensure = (id: number, name: string, email: string) => {
if (!by_employee.has(id)) {
by_employee.set(id, {
email: '',
email,
employee_name: name,
regular_hours: 0,
evening_hours: 0,
@ -150,7 +219,7 @@ export class PayPeriodsQueryService {
for (const shift of shifts) {
const employee = shift.timesheet.employee;
const name = `${employee.user.first_name} ${employee.user.last_name}`.trim();
const record = ensure(employee.id, name);
const record = ensure(employee.id, name, employee.user.email);
const hours = computeHours(shift.start_time, shift.end_time);
const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase();
@ -167,7 +236,7 @@ export class PayPeriodsQueryService {
for (const expense of expenses) {
const exp = expense.timesheet.employee;
const name = `${exp.user.first_name} ${exp.user.last_name}`.trim();
const record = ensure(exp.id, name);
const record = ensure(exp.id, name, exp.user.email);
const amount = toMoney(expense.amount);
record.expenses += amount;
@ -195,135 +264,85 @@ export class PayPeriodsQueryService {
};
}
async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean):
Promise<PayPeriodOverviewDto> {
// 1) Search for the period
const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } });
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`);
// 2) fetch supervisor
const supervisor = await this.prisma.employees.findFirst({
where: { user: { email: email }},
select: {
id: true,
is_supervisor: true,
},
});
if (!supervisor) throw new NotFoundException('No employee record linked to current user');
if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor');
// 3)fetchs crew members
const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }]
const crew_ids = crew.map(c => c.id);
// seed names map for employee without data
const seed_names = new Map<number, string>(crew.map(crew => [crew.id, `${crew.first_name} ${crew.last_name}`.trim()]));
// 4) overview build
return this.buildOverview({
period_no : period.pay_period_no,
period_start: period.period_start,
period_end : period.period_end,
payday : period.payday,
pay_year : period.pay_year,
label : period.label,
}, { filtered_employee_ids: crew_ids, seed_names });
}
private async resolveCrew(supervisor_id: number, include_subtree: boolean):
Promise<Array<{ id: number; first_name: string; last_name: string }>> {
const result: Array<{ id: number; first_name: string; last_name: string }> = [];
let frontier = await this.prisma.employees.findMany({
where: { supervisor_id: supervisor_id },
select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } },
});
result.push(...frontier.map(emp => ({ id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name })));
if (!include_subtree) return result;
while (frontier.length) {
const parent_ids = frontier.map(emp => emp.id);
const next = await this.prisma.employees.findMany({
where: { supervisor_id: { in: parent_ids } },
select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } },
async getSupervisor(email:string) {
return this.prisma.employees.findFirst({
where: { user: { email } },
select: { id: true, is_supervisor: true },
});
if (next.length === 0) break;
result.push(...next.map(emp => ({ id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email })));
frontier = next;
}
return result;
}
async findAll(): Promise<PayPeriodDto[]> {
const currentPayYear = payYearOfDate(new Date());
return listPayYear(currentPayYear).map(period =>({
pay_period_no: period.period_no,
pay_year: period.pay_year,
payday: period.payday,
period_start: period.period_start,
period_end: period.period_end,
label: period.label,
}));
const currentPayYear = payYearOfDate(new Date());
return listPayYear(currentPayYear).map(period =>({
pay_period_no: period.period_no,
pay_year: period.pay_year,
payday: period.payday,
period_start: period.period_start,
period_end: period.period_end,
label: period.label,
}));
}
async findOne(period_no: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({
where: { pay_period_no: period_no },
orderBy: { pay_year: "desc" },
});
if (!row) throw new NotFoundException(`Pay period #${period_no} not found`);
return mapPayPeriodToDto(row);
const row = await this.prisma.payPeriods.findFirst({
where: { pay_period_no: period_no },
orderBy: { pay_year: "desc" },
});
if (!row) throw new NotFoundException(`Pay period #${period_no} not found`);
return mapPayPeriodToDto(row);
}
async findCurrent(date?: string): Promise<PayPeriodDto> {
const iso_day = date ?? new Date().toISOString().slice(0,10);
return this.findByDate(iso_day);
}
async findOneByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no: period_no },
});
if(row) return mapPayPeriodToDto(row);
const row = await this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no: period_no },
});
if(row) return mapPayPeriodToDto(row);
// fallback for outside of view periods
const period = computePeriod(pay_year, period_no);
return {
pay_period_no: period.period_no,
pay_year: period.pay_year,
period_start: period.period_start,
payday: period.payday,
period_end: period.period_end,
label: period.label
}
// fallback for outside of view periods
const period = computePeriod(pay_year, period_no);
return {
pay_period_no: period.period_no,
pay_year: period.pay_year,
period_start: period.period_start,
payday: period.payday,
period_end: period.period_end,
label: period.label
}
}
//function to cherry pick a Date to find a period
async findByDate(date: string): Promise<PayPeriodDto> {
const dt = new Date(date);
const row = await this.prisma.payPeriods.findFirst({
where: { period_start: { lte: dt }, period_end: { gte: dt } },
});
if(row) return mapPayPeriodToDto(row);
const dt = new Date(date);
const row = await this.prisma.payPeriods.findFirst({
where: { period_start: { lte: dt }, period_end: { gte: dt } },
});
if(row) return mapPayPeriodToDto(row);
//fallback for outwside view periods
const pay_year = payYearOfDate(date);
const periods = listPayYear(pay_year);
const hit = periods.find(period => date >= period.period_start && date <= period.period_end);
if(!hit) throw new NotFoundException(`No period found for ${date}`);
return {
pay_period_no: hit.period_no,
pay_year : hit.pay_year,
period_start : hit.period_start,
period_end : hit.period_end,
payday : hit.payday,
label : hit.label
}
//fallback for outwside view periods
const pay_year = payYearOfDate(date);
const periods = listPayYear(pay_year);
const hit = periods.find(period => date >= period.period_start && date <= period.period_end);
if(!hit) throw new NotFoundException(`No period found for ${date}`);
return {
pay_period_no: hit.period_no,
pay_year : hit.pay_year,
period_start : hit.period_start,
period_end : hit.period_end,
payday : hit.payday,
label : hit.label
}
}
async findCurrent(date?: string): Promise<PayPeriodDto> {
const iso_day = date ?? new Date().toISOString().slice(0,10);
return this.findByDate(iso_day);
async getPeriodWindow(pay_year: number, period_no: number) {
return this.prisma.payPeriods.findFirst({
where: {pay_year, pay_period_no: period_no },
select: { period_start: true, period_end: true },
});
}
}

View File

@ -86,14 +86,14 @@ export class ShiftsController {
//CSV Headers
const header = [
'fullName',
'full_name',
'supervisor',
'totalRegularHrs',
'totalEveningHrs',
'totalOvertimeHrs',
'totalExpenses',
'totalMileage',
'isValidated'
'total_regular_hrs',
'total_evening_hrs',
'total_overtime_hrs',
'total_expenses',
'total_mileage',
'is_validated'
].join(',') + '\n';
//CSV rows
@ -101,14 +101,14 @@ export class ShiftsController {
const esc = (str: string) => `"${str.replace(/"/g, '""')}"`;
return [
esc(r.fullName),
esc(r.full_name),
esc(r.supervisor),
r.totalRegularHrs.toFixed(2),
r.totalEveningHrs.toFixed(2),
r.totalOvertimeHrs.toFixed(2),
r.totalExpenses.toFixed(2),
r.totalMileage.toFixed(2),
r.isValidated,
r.total_regular_hrs.toFixed(2),
r.total_evening_hrs.toFixed(2),
r.total_overtime_hrs.toFixed(2),
r.total_expenses.toFixed(2),
r.total_mileage.toFixed(2),
r.is_validated,
].join(',');
}).join('\n');

View File

@ -1,6 +1,6 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { Allow, IsDate, IsDateString, IsInt, IsString } from "class-validator";
import { Allow, IsDateString, IsInt, IsString } from "class-validator";
export class CreateShiftDto {
@ApiProperty({

View File

@ -15,9 +15,9 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
return transaction.shifts;
}
async updateApproval(id: number, isApproved: boolean): Promise<Shifts> {
async updateApproval(id: number, is_approved: boolean): Promise<Shifts> {
return this.prisma.$transaction((transaction) =>
this.updateApprovalWithTx(transaction, id, isApproved),
this.updateApprovalWithTransaction(transaction, id, is_approved),
);
}

View File

@ -8,17 +8,17 @@ import { SearchShiftsDto } from "../dtos/search-shift.dto";
import { NotificationsService } from "src/modules/notifications/services/notifications.service";
import { computeHours, hoursBetweenSameDay } from "src/common/utils/date-utils";
const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 8);
const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12);
export interface OverviewRow {
fullName: string;
full_name: string;
supervisor: string;
totalRegularHrs: number;
totalEveningHrs: number;
totalOvertimeHrs: number;
totalExpenses: number;
totalMileage: number;
isValidated: boolean;
total_regular_hrs: number;
total_evening_hrs: number;
total_overtime_hrs: number;
total_expenses: number;
total_mileage: number;
is_validated: boolean;
}
@Injectable()
@ -40,31 +40,32 @@ export class ShiftsQueryService {
});
//fetches all shifts of the same day to check for daily overtime
const sameDayShifts = await this.prisma.shifts.findMany({
const same_day_shifts = await this.prisma.shifts.findMany({
where: { timesheet_id, date },
select: { id: true, date: true, start_time: true, end_time: true },
});
//sums hours of the day
const totalHours = sameDayShifts.reduce((sum, s) => {
const total_hours = same_day_shifts.reduce((sum, s) => {
return sum + hoursBetweenSameDay(s.date, s.start_time, s.end_time);
}, 0 );
//Notify if total hours > 8 for a single day
if(totalHours > DAILY_LIMIT_HOURS ) {
const userId = String(shift.timesheet.employee.user.id);
const dateLabel = new Date(date).toLocaleDateString('fr-CA');
this.notifs.notify(userId, {
if(total_hours > DAILY_LIMIT_HOURS ) {
const user_id = String(shift.timesheet.employee.user.id);
const date_label = new Date(date).toLocaleDateString('fr-CA');
this.notifs.notify(user_id, {
type: 'shift.overtime.daily',
severity: 'warn',
message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${dateLabel} (total: ${totalHours.toFixed(2)}h).`,
message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${date_label}
(total: ${total_hours.toFixed(2)}h).`,
ts: new Date().toISOString(),
meta: {
timesheet_id,
date: new Date(date).toISOString(),
totalHours,
total_hours,
threshold: DAILY_LIMIT_HOURS,
lastShiftId: shift.id
last_shift_id: shift.id
},
});
}
@ -152,93 +153,93 @@ export class ShiftsQueryService {
const mapRow = new Map<string, OverviewRow>();
for(const s of shifts) {
const employeeId = s.timesheet.employee.user_id;
const user = s.timesheet.employee.user;
const sup = s.timesheet.employee.supervisor?.user;
for(const shift of shifts) {
const employeeId = shift.timesheet.employee.user_id;
const user = shift.timesheet.employee.user;
const sup = shift.timesheet.employee.supervisor?.user;
let row = mapRow.get(employeeId);
if(!row) {
row = {
fullName: `${user.first_name} ${user.last_name}`,
full_name: `${user.first_name} ${user.last_name}`,
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
totalRegularHrs: 0,
totalEveningHrs: 0,
totalOvertimeHrs: 0,
totalExpenses: 0,
totalMileage: 0,
isValidated: false,
total_regular_hrs: 0,
total_evening_hrs: 0,
total_overtime_hrs: 0,
total_expenses: 0,
total_mileage: 0,
is_validated: false,
};
}
const hours = computeHours(s.start_time, s.end_time);
const hours = computeHours(shift.start_time, shift.end_time);
switch(s.bank_code.type) {
case 'regular' : row.totalRegularHrs += hours;
switch(shift.bank_code.type) {
case 'regular' : row.total_regular_hrs += hours;
break;
case 'evening' : row.totalEveningHrs += hours;
case 'evening' : row.total_evening_hrs += hours;
break;
case 'overtime' : row.totalOvertimeHrs += hours;
case 'overtime' : row.total_overtime_hrs += hours;
break;
default: row.totalRegularHrs += hours;
default: row.total_regular_hrs += hours;
}
mapRow.set(employeeId, row);
}
for(const e of expenses) {
const employeeId = e.timesheet.employee.user_id;
const user = e.timesheet.employee.user;
const sup = e.timesheet.employee.supervisor?.user;
for(const exp of expenses) {
const employee_id = exp.timesheet.employee.user_id;
const user = exp.timesheet.employee.user;
const sup = exp.timesheet.employee.supervisor?.user;
let row = mapRow.get(employeeId);
let row = mapRow.get(employee_id);
if(!row) {
row = {
fullName: `${user.first_name} ${user.last_name}`,
full_name: `${user.first_name} ${user.last_name}`,
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
totalRegularHrs: 0,
totalEveningHrs: 0,
totalOvertimeHrs: 0,
totalExpenses: 0,
totalMileage: 0,
isValidated: false,
total_regular_hrs: 0,
total_evening_hrs: 0,
total_overtime_hrs: 0,
total_expenses: 0,
total_mileage: 0,
is_validated: false,
};
}
const amount = Number(e.amount);
row.totalExpenses += amount;
if(e.bank_code.type === 'mileage') {
row.totalMileage += amount;
const amount = Number(exp.amount);
row.total_expenses += amount;
if(exp.bank_code.type === 'mileage') {
row.total_mileage += amount;
}
mapRow.set(employeeId, row);
mapRow.set(employee_id, row);
}
//return by default the list of employee in ascending alphabetical order
return Array.from(mapRow.values()).sort((a,b) => a.fullName.localeCompare(b.fullName));
return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name));
}
//archivation functions ******************************************************
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archivedTimesheets = await this.prisma.timesheetsArchive.findMany({
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
const timesheetIds = archivedTimesheets.map(sheet => sheet.timesheet_id);
if(timesheetIds.length === 0) {
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
if(timesheet_ids.length === 0) {
return;
}
// copy/delete transaction
await this.prisma.$transaction(async transaction => {
//fetches shifts to move to archive
const shiftsToArchive = await transaction.shifts.findMany({
where: { timesheet_id: { in: timesheetIds } },
const shifts_to_archive = await transaction.shifts.findMany({
where: { timesheet_id: { in: timesheet_ids } },
});
if(shiftsToArchive.length === 0) {
if(shifts_to_archive.length === 0) {
return;
}
//copies sent to archive table
await transaction.shiftsArchive.createMany({
data: shiftsToArchive.map(shift => ({
data: shifts_to_archive.map(shift => ({
shift_id: shift.id,
timesheet_id: shift.timesheet_id,
bank_code_id: shift.bank_code_id,
@ -251,7 +252,7 @@ export class ShiftsQueryService {
//delete from shifts table
await transaction.shifts.deleteMany({
where: { id: { in: shiftsToArchive.map(shift => shift.id) } },
where: { id: { in: shifts_to_archive.map(shift => shift.id) } },
})
})

View File

@ -17,12 +17,12 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
async updateApproval(id: number, isApproved: boolean): Promise<Timesheets> {
return this.prisma.$transaction((transaction) =>
this.updateApprovalWithTx(transaction, id, isApproved),
this.updateApprovalWithTransaction(transaction, id, isApproved),
);
}
async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise<Timesheets> {
const timesheet = await this.updateApprovalWithTx(transaction, timesheetId, isApproved);
const timesheet = await this.updateApprovalWithTransaction(transaction, timesheetId, isApproved);
await transaction.shifts.updateMany({
where: { timesheet_id: timesheetId },