253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
import { sevenDaysFrom, toStringFromDate, toHHmmFromDate, toDateFromString } from "src/time-and-attendance/utils/date-time.utils";
|
|
import { NUMBER_OF_TIMESHEETS_TO_RETURN } from "src/time-and-attendance/utils/constants.utils";
|
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
|
import { TotalExpenses, TotalHours } from "src/time-and-attendance/utils/type.utils";
|
|
import { PrismaService } from "src/prisma/prisma.service";
|
|
import { EmailToIdResolver } from "src/time-and-attendance/shared/utils/resolve-email-id.utils";
|
|
|
|
@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) {
|
|
//find period using year and period_no
|
|
const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no } });
|
|
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${pay_period_no} not found`);
|
|
|
|
//fetch the employee_id using the email
|
|
const employee_id = await this.emailResolver.findIdByEmail(email);
|
|
//loads the timesheets related to the fetched pay-period
|
|
const timesheet_range = { employee_id, start_date: { gte: period.period_start, lte: period.period_end } };
|
|
let rows = await this.loadTimesheets(timesheet_range);
|
|
|
|
//Normalized dates from pay-period
|
|
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 exists = rows.some(
|
|
(row) => toDateFromString(row.start_date).getTime() === week_start.getTime()
|
|
);
|
|
if (!exists) await this.ensureTimesheet(employee_id, week_start);
|
|
}
|
|
rows = await this.loadTimesheets(timesheet_range);
|
|
|
|
|
|
//find user infos using the employee_id
|
|
const employee = await this.prisma.employees.findUnique({
|
|
where: { id: employee_id },
|
|
include: { user: true },
|
|
});
|
|
if (!employee) throw new NotFoundException(`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 = rows.map((timesheet) => this.mapOneTimesheet(timesheet));
|
|
return { employee_fullname, timesheets };
|
|
}
|
|
|
|
|
|
//-----------------------------------------------------------------------------------
|
|
// MAPPERS & HELPERS
|
|
//-----------------------------------------------------------------------------------
|
|
|
|
//fetch timesheet's infos
|
|
private async loadTimesheets(where: any) {
|
|
return this.prisma.timesheets.findMany({
|
|
where,
|
|
include: {
|
|
employee: { include: { user: true } },
|
|
shift: { include: { bank_code: true } },
|
|
expense: { include: { bank_code: true, attachment_record: true } },
|
|
},
|
|
orderBy: { start_date: 'asc' },
|
|
});
|
|
}
|
|
|
|
private mapOneTimesheet(timesheet: any) {
|
|
//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<string, any[]>();
|
|
for (const shift of timesheet.shift) {
|
|
const date = toStringFromDate(shift.date);
|
|
const arr = shifts_by_date.get(date) ?? [];
|
|
arr.push(shift);
|
|
shifts_by_date.set(date, arr);
|
|
}
|
|
//map of expenses by days
|
|
const expenses_by_date = new Map<string, any[]>();
|
|
for (const expense of timesheet.expense) {
|
|
const date = toStringFromDate(expense.date);
|
|
const arr = expenses_by_date.get(date) ?? [];
|
|
arr.push(expense);
|
|
expenses_by_date.set(date, 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,
|
|
shift_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 ? Number(expense.amount) : undefined,
|
|
mileage: expense.mileage ? 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,
|
|
}));
|
|
|
|
//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';
|
|
}
|