diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 46a1366..4f0d2e2 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -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": { diff --git a/src/common/shared/base-approval.service.ts b/src/common/shared/base-approval.service.ts index a624fe7..e3660e1 100644 --- a/src/common/shared/base-approval.service.ts +++ b/src/common/shared/base-approval.service.ts @@ -36,7 +36,7 @@ export abstract class BaseApprovalService { } //approval with transaction to avoid many requests - async updateApprovalWithTx(transaction: Prisma.TransactionClient, id: number, isApproved: boolean): Promise { + async updateApprovalWithTransaction(transaction: Prisma.TransactionClient, id: number, isApproved: boolean): Promise { try { return await this.delegateFor(transaction).update({ where: { id }, diff --git a/src/modules/business-logics/services/after-hours.service.ts b/src/modules/business-logics/services/after-hours.service.ts index bc0e271..2330b33 100644 --- a/src/modules/business-logics/services/after-hours.service.ts +++ b/src/modules/business-logics/services/after-hours.service.ts @@ -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; } } diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index 0e06f9a..daf6e96 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -9,34 +9,34 @@ export class HolidayService { constructor(private readonly prisma: PrismaService) {} //switch employeeId for email - private async computeHoursPrevious4Weeks(employeeId: number, holidayDate: Date): Promise { + private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise { //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 { - const hours = await this.computeHoursPrevious4Weeks(employeeId, holidayDate); - const dailyRate = Math.min(hours, 8); + async calculateHolidayPay( employee_id: number, holiday_date: Date, modifier: number): Promise { + 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; } } \ No newline at end of file diff --git a/src/modules/business-logics/services/mileage.service.ts b/src/modules/business-logics/services/mileage.service.ts index bf0590c..8919532 100644 --- a/src/modules/business-logics/services/mileage.service.ts +++ b/src/modules/business-logics/services/mileage.service.ts @@ -8,28 +8,28 @@ export class MileageService { constructor(private readonly prisma: PrismaService) {} - public async calculateReimbursement(amount: number, bankCodeId: number): Promise { + public async calculateReimbursement(amount: number, bank_code_id: number): Promise { 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; } diff --git a/src/modules/business-logics/services/overtime.service.ts b/src/modules/business-logics/services/overtime.service.ts index 1bacabc..79619b5 100644 --- a/src/modules/business-logics/services/overtime.service.ts +++ b/src/modules/business-logics/services/overtime.service.ts @@ -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 { - 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; } diff --git a/src/modules/business-logics/services/sick-leave.service.ts b/src/modules/business-logics/services/sick-leave.service.ts index 3bf2fef..6ebb3d9 100644 --- a/src/modules/business-logics/services/sick-leave.service.ts +++ b/src/modules/business-logics/services/sick-leave.service.ts @@ -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 { + async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number): + Promise { //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; } } \ No newline at end of file diff --git a/src/modules/business-logics/services/vacation.service.ts b/src/modules/business-logics/services/vacation.service.ts index 1eb5498..f3b3447 100644 --- a/src/modules/business-logics/services/vacation.service.ts +++ b/src/modules/business-logics/services/vacation.service.ts @@ -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 { + async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise { //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; } diff --git a/src/modules/customers/services/customers.service.ts b/src/modules/customers/services/customers.service.ts index 4f11bce..df803e4 100644 --- a/src/modules/customers/services/customers.service.ts +++ b/src/modules/customers/services/customers.service.ts @@ -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 }), diff --git a/src/modules/employees/services/employees.service.ts b/src/modules/employees/services/employees.service.ts index 5da5573..fd7dab7 100644 --- a/src/modules/employees/services/employees.service.ts +++ b/src/modules/employees/services/employees.service.ts @@ -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 { + 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 { - 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({ diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 5c254ed..2fc8777 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -19,7 +19,7 @@ export class ExpensesCommandService extends BaseApprovalService { async updateApproval(id: number, isApproved: boolean): Promise { return this.prisma.$transaction((transaction) => - this.updateApprovalWithTx(transaction, id, isApproved), + this.updateApprovalWithTransaction(transaction, id, isApproved), ); } diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index 707f737..b0d6191 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -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 { //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) } }, }) }) diff --git a/src/modules/leave-requests/controllers/leave-requests.controller.ts b/src/modules/leave-requests/controllers/leave-requests.controller.ts index adefa81..2f794f0 100644 --- a/src/modules/leave-requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave-requests/controllers/leave-requests.controller.ts @@ -63,8 +63,8 @@ export class LeaveRequestController { @Patch(':id/approval') updateApproval( @Param('id', ParseIntPipe) id: number, - @Body('is_approved', ParseBoolPipe) isApproved: boolean): Promise { - const approvalStatus = isApproved ? + @Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise { + const approvalStatus = is_approved ? LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED; return this.leaveRequetsService.update(id, { approval_status: approvalStatus }); } diff --git a/src/modules/leave-requests/services/leave-requests.service.ts b/src/modules/leave-requests/services/leave-requests.service.ts index c3131fb..4ce97b1 100644 --- a/src/modules/leave-requests/services/leave-requests.service.ts +++ b/src/modules/leave-requests/services/leave-requests.service.ts @@ -33,8 +33,8 @@ export class LeaveRequestsService { } async findAll(filters: SearchLeaveRequestsDto): Promise { - const {start_date, end_date, ...otherFilters } = filters; - const where: Record = buildPrismaWhere(otherFilters); + const {start_date, end_date, ...other_filters } = filters; + const where: Record = 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 { + async update(id: number, dto: UpdateLeaveRequestsDto): Promise { await this.findOne(id); const { employee_id, leave_type, start_date_time, end_date_time, comment, approval_status } = dto; return this.prisma.leaveRequests.update({ diff --git a/src/modules/notifications/services/notifications.service.ts b/src/modules/notifications/services/notifications.service.ts index c9b40c0..8677766 100644 --- a/src/modules/notifications/services/notifications.service.ts +++ b/src/modules/notifications/services/notifications.service.ts @@ -11,47 +11,47 @@ export class NotificationsService { private buffers = new Map(); private readonly BUFFER_MAX = Number(process.env.NOTIF_BUFFER_MAX ?? 50); - private getOrCreateStream(userId: string): Subject { - let stream = this.streams.get(userId); + private getOrCreateStream(user_id: string): Subject { + let stream = this.streams.get(user_id); if (!stream){ stream = new Subject(); - 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 { - return this.getOrCreateBuffer(userId); + async summary(user_id: string): Promise { + 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() { diff --git a/src/modules/pay-periods/controllers/pay-periods.controller.ts b/src/modules/pay-periods/controllers/pay-periods.controller.ts index b90b3a9..b1e3011 100644 --- a/src/modules/pay-periods/controllers/pay-periods.controller.ts +++ b/src/modules/pay-periods/controllers/pay-periods.controller.ts @@ -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') diff --git a/src/modules/pay-periods/dtos/bulk-crew-approval.dto.ts b/src/modules/pay-periods/dtos/bulk-crew-approval.dto.ts new file mode 100644 index 0000000..3762ddb --- /dev/null +++ b/src/modules/pay-periods/dtos/bulk-crew-approval.dto.ts @@ -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[] +} \ No newline at end of file diff --git a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts index 711e34c..8213be9 100644 --- a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts +++ b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Allow, IsOptional } from 'class-validator'; export class EmployeePeriodOverviewDto { // @ApiProperty({ diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index fc400d3..6772529 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -11,7 +11,6 @@ import { ShiftsCommandService } from "../shifts/services/shifts-command.service" @Module({ imports: [PrismaModule, TimesheetsModule], providers: [ - PayPeriodsQueryService, PayPeriodsQueryService, PayPeriodsCommandService, TimesheetsCommandService, diff --git a/src/modules/pay-periods/services/pay-periods-command.service.ts b/src/modules/pay-periods/services/pay-periods-command.service.ts index d8609ed..8cffe2c 100644 --- a/src/modules/pay-periods/services/pay-periods-command.service.ts +++ b/src/modules/pay-periods/services/pay-periods-command.service.ts @@ -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 { - 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(); + 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 { + // 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); + // } + // }) + // } } \ No newline at end of file diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index 7e5c050..219a9be 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -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 { - 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 { 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 { + 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> { + 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> { + 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 { + // 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(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 { - // 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(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> { - 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 { - 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 { - 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 { + const iso_day = date ?? new Date().toISOString().slice(0,10); + return this.findByDate(iso_day); } async findOneByYearPeriod(pay_year: number, period_no: number): Promise { - 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 { - 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 { - 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 }, + }); } } diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index 1612e12..b3213f9 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -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'); diff --git a/src/modules/shifts/dtos/create-shift.dto.ts b/src/modules/shifts/dtos/create-shift.dto.ts index b64dded..c3451d2 100644 --- a/src/modules/shifts/dtos/create-shift.dto.ts +++ b/src/modules/shifts/dtos/create-shift.dto.ts @@ -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({ diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 8d75e0e..c3de439 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -15,9 +15,9 @@ export class ShiftsCommandService extends BaseApprovalService { return transaction.shifts; } - async updateApproval(id: number, isApproved: boolean): Promise { + async updateApproval(id: number, is_approved: boolean): Promise { return this.prisma.$transaction((transaction) => - this.updateApprovalWithTx(transaction, id, isApproved), + this.updateApprovalWithTransaction(transaction, id, is_approved), ); } diff --git a/src/modules/shifts/services/shifts-query.service.ts b/src/modules/shifts/services/shifts-query.service.ts index ef51021..f1bb5f7 100644 --- a/src/modules/shifts/services/shifts-query.service.ts +++ b/src/modules/shifts/services/shifts-query.service.ts @@ -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(); - 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 { //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) } }, }) }) diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 9720491..abce079 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -17,12 +17,12 @@ export class TimesheetsCommandService extends BaseApprovalService{ async updateApproval(id: number, isApproved: boolean): Promise { 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 { - const timesheet = await this.updateApprovalWithTx(transaction, timesheetId, isApproved); + const timesheet = await this.updateApprovalWithTransaction(transaction, timesheetId, isApproved); await transaction.shifts.updateMany({ where: { timesheet_id: timesheetId },