import { NUMBER_OF_TIMESHEETS_TO_RETURN } from "src/common/utils/constants.utils"; import { Injectable } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; import { EmailToIdResolver } from "src/common/mappers/email-id.mapper"; import { Timesheet, Timesheets } from "src/time-and-attendance/time-tracker/timesheets/dtos/timesheet.dto"; import { Result } from "src/common/errors/result-error.factory"; import { Prisma } from "@prisma/client"; import { toDateFromString, sevenDaysFrom, toStringFromDate, toHHmmFromDate } from "src/common/utils/date-utils"; export type TotalHours = { regular: number; evening: number; emergency: number; overtime: number; vacation: number; holiday: number; sick: number; }; export type TotalExpenses = { expenses: number; per_diem: number; on_call: number; mileage: number; }; @Injectable() export class GetTimesheetsOverviewService { constructor( private readonly prisma: PrismaService, private readonly emailResolver: EmailToIdResolver, ) { } //----------------------------------------------------------------------------------- // GET TIMESHEETS FOR A SELECTED EMPLOYEE //----------------------------------------------------------------------------------- async getTimesheetsForEmployeeByPeriod(email: string, pay_year: number, pay_period_no: number): Promise> { try { //find period using year and period_no const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no } }); if (!period) return { success: false, error: `Pay period ${pay_year}-${pay_period_no} not found` }; //fetch the employee_id using the email const employee_id = await this.emailResolver.findIdByEmail(email); if (!employee_id.success) return { success: false, error: `employee with email: ${email} not found` + employee_id.error } //loads the timesheets related to the fetched pay-period let rows = await this.loadTimesheets(employee_id.data, period.period_start, period.period_end); //Normalized dates from pay-period strings const normalized_start = toDateFromString(period.period_start); const normalized_end = toDateFromString(period.period_end); //creates empty timesheet to make sure to return desired amount of timesheet for (let i = 0; i < NUMBER_OF_TIMESHEETS_TO_RETURN; i++) { const week_start = new Date(normalized_start); week_start.setUTCDate(week_start.getUTCDate() + i * 7); if (week_start.getTime() > normalized_end.getTime()) break; const has_existing_timesheets = rows.some( (row) => toDateFromString(row.start_date).getTime() === week_start.getTime() ); if (!has_existing_timesheets) this.ensureTimesheet(employee_id.data, week_start); } rows = await this.loadTimesheets(employee_id.data, period.period_start, period.period_end); //find user infos using the employee_id const employee = await this.prisma.employees.findUnique({ where: { id: employee_id.data }, include: { user: true }, }); if (!employee) return { success: false, error: `Employee #${employee_id} not found` } //builds employee full name const user = employee.user; const employee_fullname = `${user.first_name} ${user.last_name}`.trim(); //maps all timesheet's infos const timesheets = await Promise.all(rows.map((timesheet) => this.mapOneTimesheet(timesheet))); if (!timesheets) return { success: false, error: 'an error occured during the mapping of a timesheet' } return { success: true, data: { employee_fullname, timesheets } }; } catch (error) { return { success: false, error: 'timesheet failed to load: ' + pay_year + pay_period_no } } } //----------------------------------------------------------------------------------- // MAPPERS & HELPERS //----------------------------------------------------------------------------------- //fetch timesheet's infos private async loadTimesheets(employee_id: number, period_start: Date, period_end: Date) { return this.prisma.timesheets.findMany({ where: { employee_id, start_date: { gte: period_start, lte: period_end } }, include: { employee: { include: { user: true } }, shift: { include: { bank_code: true } }, expense: { include: { bank_code: true, attachment_record: true } }, }, orderBy: { start_date: 'asc' }, }); } private async mapOneTimesheet(timesheet: Prisma.TimesheetsGetPayload<{ include: { employee: { include: { user } }, shift: { include: { bank_code } }, expense: { include: { bank_code, attachment_record } }, } }>): Promise { //converts string to UTC date format const start = toDateFromString(timesheet.start_date); const day_dates = sevenDaysFrom(start); //map of shifts by days const shifts_by_date = new Map[]>(); for (const shift of timesheet.shift) { const date_string = toStringFromDate(shift.date); const arr = shifts_by_date.get(date_string) ?? []; arr.push(shift); shifts_by_date.set(date_string, arr); } //map of expenses by days const expenses_by_date = new Map[]>(); for (const expense of timesheet.expense) { const date_string = toStringFromDate(expense.date); const arr = expenses_by_date.get(date_string) ?? []; arr.push(expense); expenses_by_date.set(date_string, arr); } //weekly totals const weekly_hours: TotalHours[] = [emptyHours()]; const weekly_expenses: TotalExpenses[] = [emptyExpenses()]; //map of days const days = day_dates.map((date) => { const date_iso = toStringFromDate(date); const shifts_source = shifts_by_date.get(date_iso) ?? []; const expenses_source = expenses_by_date.get(date_iso) ?? []; //inner map of shifts const shifts = shifts_source.map((shift) => ({ timesheet_id: shift.timesheet_id, date: toStringFromDate(shift.date), start_time: toHHmmFromDate(shift.start_time), end_time: toHHmmFromDate(shift.end_time), type: shift.bank_code?.type ?? '', is_remote: shift.is_remote ?? false, is_approved: shift.is_approved ?? false, id: shift.id ?? null, comment: shift.comment ?? null, })); //inner map of expenses const expenses = expenses_source.map((expense) => ({ date: toStringFromDate(expense.date), amount: expense.amount != null ? Number(expense.amount) : undefined, mileage: expense.mileage != null ? Number(expense.mileage) : undefined, expense_id: expense.id ?? null, attachment: expense.attachment_record ? String(expense.attachment_record.id) : undefined, is_approved: expense.is_approved ?? false, comment: expense.comment ?? '', supervisor_comment: expense.supervisor_comment, type: expense.bank_code.type, })); //daily totals const daily_hours = [emptyHours()]; const daily_expenses = [emptyExpenses()]; //totals by shift types for (const shift of shifts_source) { const hours = diffOfHours(shift.start_time, shift.end_time); const subgroup = hoursSubGroupFromBankCode(shift.bank_code); daily_hours[0][subgroup] += hours; weekly_hours[0][subgroup] += hours; } //totals by expense types for (const expense of expenses_source) { const subgroup = expenseSubgroupFromBankCode(expense.bank_code); if (subgroup === 'mileage') { const mileage = num(expense.mileage); daily_expenses[0].mileage += mileage; weekly_expenses[0].mileage += mileage; } else if (subgroup === 'per_diem') { const amount = num(expense.amount); daily_expenses[0].per_diem += amount; weekly_expenses[0].per_diem += amount; } else if (subgroup === 'on_call') { const amount = num(expense.amount); daily_expenses[0].on_call += amount; weekly_expenses[0].on_call += amount; } else { const amount = num(expense.amount); daily_expenses[0].expenses += amount; weekly_expenses[0].expenses += amount; } } return { date: date_iso, shifts, expenses, daily_hours, daily_expenses, }; }); return { timesheet_id: timesheet.id, is_approved: timesheet.is_approved ?? false, days, weekly_hours, weekly_expenses, }; } private ensureTimesheet = async (employee_id: number, start_date: Date | string) => { const start = toDateFromString(start_date); let row = await this.prisma.timesheets.findFirst({ where: { employee_id, start_date: start }, include: { employee: { include: { user: true } }, shift: { include: { bank_code: true } }, expense: { include: { bank_code: true, attachment_record: true } }, }, }); if (row) return row; await this.prisma.timesheets.create({ data: { employee_id, start_date: start, is_approved: false }, }); row = await this.prisma.timesheets.findFirst({ where: { employee_id, start_date: start }, include: { employee: { include: { user: true } }, shift: { include: { bank_code: true } }, expense: { include: { bank_code: true, attachment_record: true, } }, }, }); return row!; } } //filled array with default values const emptyHours = (): TotalHours => { return { regular: 0, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0 } }; const emptyExpenses = (): TotalExpenses => { return { expenses: 0, per_diem: 0, on_call: 0, mileage: 0 } }; //calculate the differences of hours const diffOfHours = (a: Date, b: Date): number => { const ms = new Date(b).getTime() - new Date(a).getTime(); return Math.max(0, Math.round((ms / 36e5) * 1000) / 1000); } //validate numeric values const num = (value: any): number => { return value ? Number(value) : 0 }; // shift's subgroup types const hoursSubGroupFromBankCode = (bank_code: any): keyof TotalHours => { const type = bank_code.type; if (type.includes('EVENING')) return 'evening'; if (type.includes('EMERGENCY')) return 'emergency'; if (type.includes('OVERTIME')) return 'overtime'; if (type.includes('VACATION')) return 'vacation'; if (type.includes('HOLIDAY')) return 'holiday'; if (type.includes('SICK')) return 'sick'; return 'regular' } // expense's subgroup types const expenseSubgroupFromBankCode = (bank_code: any): keyof TotalExpenses => { const type = bank_code.type; if (type.includes('MILEAGE')) return 'mileage'; if (type.includes('PER_DIEM')) return 'per_diem'; if (type.includes('ON_CALL')) return 'on_call'; return 'expenses'; }