clean(pre-prod): did some cleaning

This commit is contained in:
Matthieu Haineault 2026-01-12 09:54:38 -05:00
parent 5d6f6bfd0a
commit dda1592871
38 changed files with 614 additions and 994 deletions

View File

@ -2,11 +2,3 @@ export type Result<T, E> =
| { success: true; data: T }
| { success: false; error: E };
// const success = <T>(data: T): Result<T, never> => {
// return { success: true, data };
// }
// const failure = <E>(error: E): Result<never, E> => {
// return { success: false, error };
// }

View File

@ -1,20 +0,0 @@
// import { Roles as RoleEnum } from ".prisma/client";
// export const GLOBAL_CONTROLLER_ROLES: readonly RoleEnum[] = [
// RoleEnum.EMPLOYEE,
// RoleEnum.ACCOUNTING,
// RoleEnum.HR,
// RoleEnum.SUPERVISOR,
// RoleEnum.ADMIN,
// ];
// export const MANAGER_ROLES: readonly RoleEnum[] = [
// RoleEnum.HR,
// RoleEnum.SUPERVISOR,
// RoleEnum.ADMIN,
// ]
// export const PAY_SERVICE: readonly RoleEnum[] = [
// RoleEnum.HR,
// RoleEnum.ACCOUNTING,
// ]

View File

@ -147,3 +147,11 @@ export const overlaps = (a: { start: Date; end: Date, date?: Date; }, b: { start
export const is_same_week_day = (date: Date, week_day: Weekday): boolean => {
return date.getUTCDay() !== WEEKDAY_MAP[week_day];
}
export const addHourstoDateString = (start_time: string, hours: number): string => {
const start = toDateFromHHmm(start_time);
const end = new Date(start.getTime() + hours * 60 * 60 * 1000);
const hh = String(end.getUTCHours()).padStart(2, '0');
const mm = String(end.getUTCMinutes()).padStart(2, '0');
return `${hh}:${mm}:00`;
}

View File

@ -1,6 +1,6 @@
import { IsArray, IsBoolean, IsDateString, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPositive, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { PaidTimeOffDto } from 'src/time-and-attendance/domains/paid-time-off.dto';
import { PaidTimeOffDto } from 'src/time-and-attendance/paid-time-off/paid-time-off.dto';
export class EmployeeDetailedDto {
@IsString() @IsNotEmpty() first_name: string;

View File

@ -1,173 +0,0 @@
// import { Injectable } from "@nestjs/common";
// import { Employees, Users } from "@prisma/client";
// // import { UpdateEmployeeDto } from "src/identity-and-account/employees/dtos/update-employee.dto";
// import { toDateOrUndefined, toDateOrNull } from "src/identity-and-account/employees/utils/employee.utils";
// import { PrismaService } from "src/prisma/prisma.service";
// @Injectable()
// export class EmployeesArchivalService {
// constructor(private readonly prisma: PrismaService) { }
// async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees | null> {
// // 1) Tenter sur employés actifs
// const active = await this.prisma.employees.findFirst({
// where: { user: { email } },
// include: { user: true },
// });
// if (active) {
// // Archivage : si on reçoit un last_work_day défini et que l'employé nest pas déjà terminé
// // if (dto.last_work_day !== undefined && active.last_work_day == null && dto.last_work_day !== null) {
// // return this.archiveOnTermination(active, dto);
// // }
// // Sinon, update standard (split Users/Employees)
// const {
// first_name,
// last_name,
// email: new_email,
// phone_number,
// residence,
// external_payroll_id,
// company_code,
// job_title,
// first_work_day,
// last_work_day,
// supervisor_id,
// is_supervisor,
// } = dto as any;
// const first_work_d = toDateOrUndefined(first_work_day);
// const last_work_d = Object.prototype.hasOwnProperty('last_work_day')
// ? toDateOrNull(last_work_day ?? null)
// : undefined;
// await this.prisma.$transaction(async (transaction) => {
// if (
// first_name !== undefined ||
// last_name !== undefined ||
// new_email !== undefined ||
// phone_number !== undefined ||
// residence !== undefined
// ) {
// await transaction.users.update({
// where: { id: active.user_id },
// data: {
// ...(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 transaction.employees.update({
// where: { id: active.id },
// data: {
// ...(external_payroll_id !== undefined ? { external_payroll_id } : {}),
// ...(company_code !== undefined ? { company_code } : {}),
// ...(job_title !== undefined ? { job_title } : {}),
// ...(first_work_d !== undefined ? { first_work_day: first_work_d } : {}),
// ...(last_work_d !== undefined ? { last_work_day: last_work_d } : {}),
// ...(is_supervisor !== undefined ? { is_supervisor } : {}),
// ...(supervisor_id !== undefined ? { supervisor_id } : {}),
// },
// include: { user: true },
// });
// return updated;
// });
// return this.prisma.employees.findFirst({ where: { user: { email } } });
// }
// const user = await this.prisma.users.findUnique({ where: { email } });
// if (!user) return null;
// // 2) Pas trouvé en actifs → regarder en archive (pour restauration)
// // const archived = await this.prisma.employeesArchive.findFirst({
// // where: { user_id: user.id },
// // include: { user: true },
// // });
// // if (archived) {
// // // Condition de restauration : last_work_day === null ou first_work_day fourni
// // const restore = dto.last_work_day === null || dto.first_work_day != null;
// // if (restore) {
// // return this.restoreEmployee(archived, dto);
// // }
// // }
// // 3) Ni actif, ni archivé → 404 dans le controller
// return null;
// }
// //transfers the employee to archive and then delete from employees table
// // private async archiveOnTermination(active: Employees & { user: Users }, dto: UpdateEmployeeDto): Promise<EmployeesArchive> {
// // const last_work_d = toDateOrNull(dto.last_work_day!);
// // if (!last_work_d) throw new Error('invalide last_work_day for archive');
// // return this.prisma.$transaction(async transaction => {
// // //detach crew from supervisor if employee is a supervisor
// // await transaction.employees.updateMany({
// // where: { supervisor_id: active.id },
// // data: { supervisor_id: null },
// // })
// // const archived = await transaction.employeesArchive.create({
// // data: {
// // employee_id: active.id,
// // user_id: active.user_id,
// // first_name: active.user.first_name,
// // last_name: active.user.last_name,
// // company_code: active.company_code,
// // job_title: active.job_title,
// // first_work_day: active.first_work_day,
// // last_work_day: last_work_d,
// // supervisor_id: active.supervisor_id ?? null,
// // is_supervisor: active.is_supervisor,
// // external_payroll_id: active.external_payroll_id,
// // },
// // include: { user: true }
// // });
// // //delete from employees table
// // await transaction.employees.delete({ where: { id: active.id } });
// // //return archived employee
// // return archived
// // });
// // }
// // //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);
// // return this.prisma.$transaction(async transaction => {
// // //restores the archived employee into the employees table
// // const restored = await transaction.employees.create({
// // data: {
// // user_id: archived.user_id,
// // company_code: archived.company_code,
// // job_title: archived.job_title,
// // first_work_day: archived.first_work_day,
// // last_work_day: null,
// // is_supervisor: archived.is_supervisor ?? false,
// // external_payroll_id: archived.external_payroll_id,
// // },
// // });
// // //deleting archived entry by id
// // await transaction.employeesArchive.delete({ where: { id: archived.id } });
// // //return restored employee
// // return restored;
// // });
// // }
// // //fetches all archived employees
// // async findAllArchived(): Promise<EmployeesArchive[]> {
// // return this.prisma.employeesArchive.findMany();
// // }
// // //fetches an archived employee
// // async findOneArchived(id: number): Promise<EmployeesArchive> {
// // return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } });
// // }
// }

View File

@ -8,8 +8,7 @@ import { Result } from "src/common/errors/result-error.factory";
import { toCompanyCodeFromString } from "src/identity-and-account/employees/employee.utils";
import { EmployeeDetailedUpsertDto } from "src/identity-and-account/employees/employee-detailed.dto";
import { toBooleanFromString } from "src/identity-and-account/employees/services/employees-get.service";
import { PaidTimeOffDto } from "src/time-and-attendance/domains/paid-time-off.dto";
import { PaidTimeOffDto } from "src/time-and-attendance/paid-time-off/paid-time-off.dto";
@Injectable()
export class EmployeesUpdateService {
constructor(

View File

@ -1,6 +1,6 @@
import { Controller, Get } from "@nestjs/common";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { HomePageService } from "src/identity-and-account/help/home-page.service";
import { HomePageService } from "src/identity-and-account/help/help-page.service";
import { Modules as ModulesEnum } from ".prisma/client";
import { Access } from "src/common/decorators/module-access.decorators";

View File

@ -1,7 +1,7 @@
import { Module } from "@nestjs/common";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { HomePageController } from "src/identity-and-account/help/home-page.controller";
import { HomePageService } from "src/identity-and-account/help/home-page.service";
import { HomePageController } from "src/identity-and-account/help/help-page.controller";
import { HomePageService } from "src/identity-and-account/help/help-page.service";
@Module({
controllers: [HomePageController],

View File

@ -12,11 +12,11 @@ import { AccessGetService } from "src/identity-and-account/user-module-access/se
import { AccessUpdateService } from "src/identity-and-account/user-module-access/services/module-access-update.service";
import { UsersService } from "src/identity-and-account/users-management/services/users.service";
import { UsersModule } from "src/identity-and-account/users-management/users.module";
import { HomePageModule } from "src/identity-and-account/help/home-page.module";
import { HomePageModule } from "src/identity-and-account/help/help-page.module";
import { EmployeesCreateService } from "src/identity-and-account/employees/services/employees-create.service";
import { EmployeesUpdateService } from "src/identity-and-account/employees/services/employees-update.service";
import { HomePageController } from "src/identity-and-account/help/home-page.controller";
import { HomePageService } from "src/identity-and-account/help/home-page.service";
import { HomePageController } from "src/identity-and-account/help/help-page.controller";
import { HomePageService } from "src/identity-and-account/help/help-page.service";
@Module({
imports: [

View File

@ -17,10 +17,10 @@ export class BankCodesService {
bank_code: dto.bank_code,
},
});
return { success: true, data: true };
} catch (error) {
return { success: false, error: 'INVALID_BANK_CODE' + error };
}
return { success: true, data: true };
}
findAll() {
@ -44,4 +44,15 @@ export class BankCodesService {
}
}
async delete(id: number): Promise<Result<boolean, string>> {
try {
await this.prisma.bankCodes.delete({
where: { id },
});
return { success: true, data: true };
} catch (error) {
return { success: false, error: 'INVALID_BANK_CODE_ID' + error };
}
}
}

View File

@ -1,19 +0,0 @@
import { Type } from "class-transformer";
import { IsString, IsInt, IsNotEmpty, IsOptional } from "class-validator";
export class PaidTimeOffDto {
@IsInt() id: number;
@IsInt() @IsNotEmpty() employee_id: number;
@IsOptional() @Type(() => Number) vacation_hours?: number;
@IsOptional() @Type(() => Number) sick_hours?: number;
@IsOptional() @Type(() => Number) banked_hours?: number;
@IsString() @IsOptional() last_updated?: string | null;
constructor(employee_id: number) {
this.employee_id = employee_id;
this.banked_hours = 0;
this.sick_hours = 0;
this.vacation_hours = 0;
this.last_updated = new Date().toISOString();
}
}

View File

@ -30,7 +30,7 @@ export class HolidayService {
const window_start = new Date(holiday_week_start.getTime() - 4 * MS_PER_WEEK);
const window_end = new Date(holiday_week_start.getTime() - 1);
const valid_codes = ['G1', 'G43', 'G56', 'G104', 'G105', 'G305', 'G700'];
const valid_codes = ['G1', 'G43', 'G56', 'G104', 'G105', 'G305', 'G700', 'G720'];
const shifts = await this.prisma.shifts.findMany({
where: {
timesheet: { employee_id: employee_id },

View File

@ -27,7 +27,7 @@ type WeekOvertimeSummary = {
@Injectable()
export class OvertimeService {
private INCLUDED_TYPES = ['EMERGENCY', 'EVENING', 'OVERTIME', 'REGULAR', 'HOLIDAY'] as const; // included types for weekly overtime calculation
private INCLUDED_TYPES = ['EMERGENCY', 'EVENING', 'OVERTIME', 'REGULAR', 'HOLIDAY', 'BANKING'] as const; // included types for weekly overtime calculation
constructor(private prisma: PrismaService) { }

View File

@ -1,5 +1,5 @@
import { Type } from "class-transformer";
import { IsBoolean, IsDecimal, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
export class ExpenseDto {
@IsInt() id: number;

View File

@ -1,20 +1,26 @@
import { Controller, Post, Param, Body, Patch, Delete, Req, UnauthorizedException, Query } from "@nestjs/common";
import { ExpenseUpsertService } from "src/time-and-attendance/expenses/services/expense-upsert.service";
import { ExpenseDto } from "src/time-and-attendance/expenses/expense-create.dto";
import { Result } from "src/common/errors/result-error.factory";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client";
import { Access } from "src/common/decorators/module-access.decorators";
import { ExpenseUpdateService } from "src/time-and-attendance/expenses/services/expense-update.service";
import { ExpenseCreateService } from "src/time-and-attendance/expenses/services/expense-create.service";
import { ExpenseDeleteService } from "src/time-and-attendance/expenses/services/expense-delete.service";
@Controller('expense')
export class ExpenseController {
constructor(private readonly upsert_service: ExpenseUpsertService) { }
constructor(
private readonly updateService: ExpenseUpdateService,
private readonly createService: ExpenseCreateService,
private readonly deleteService: ExpenseDeleteService,
) { }
@Post('create')
@ModuleAccessAllowed(ModulesEnum.timesheets)
create(@Access('email') email: string, @Body() dto: ExpenseDto): Promise<Result<ExpenseDto, string>> {
if (!email) throw new UnauthorizedException('Unauthorized User');
return this.upsert_service.createExpense(dto, email);
return this.createService.createExpense(dto, email);
}
@Patch('update')
@ -24,13 +30,13 @@ export class ExpenseController {
@Access('email') email: string,
@Query('employee_email') employee_email?: string,
): Promise<Result<ExpenseDto, string>> {
return this.upsert_service.updateExpense(dto, email, employee_email);
return this.updateService.updateExpense(dto, email, employee_email);
}
@Delete('delete/:expense_id')
@ModuleAccessAllowed(ModulesEnum.timesheets)
remove(@Param('expense_id') expense_id: number): Promise<Result<number, string>> {
return this.upsert_service.deleteExpense(expense_id);
return this.deleteService.deleteExpense(expense_id);
}
}

View File

@ -0,0 +1,42 @@
import { Result } from "src/common/errors/result-error.factory";
import { toDateFromString } from "src/common/utils/date-utils";
import { ExpenseDto } from "src/time-and-attendance/expenses/expense-create.dto";
import { NormalizedExpense } from "src/time-and-attendance/utils/type.utils";
//makes sure that a string cannot exceed 280 chars
export const truncate280 = (input: string): string => {
return input.length > 280 ? input.slice(0, 280) : input;
}
//makes sure that the type of data of numeric values is valid
export const parseOptionalNumber = (value: unknown, field: string) => {
if (value == null) return undefined;
const parsed = Number(value);
if (Number.isNaN(parsed)) throw new Error(`Invalid value : ${value} for ${field}`);
return parsed;
};
//makes sure that comments are the right length the date is of Date type
export const normalizeAndParseExpenseDto = async (dto: ExpenseDto): Promise<Result<NormalizedExpense, string>> => {
const attachment = parseOptionalNumber(dto.attachment, "attachment");
const mileage = parseOptionalNumber(dto.mileage, "mileage");
const amount = parseOptionalNumber(dto.amount, "amount");
const comment = truncate280(dto.comment);
const supervisor_comment = dto.supervisor_comment && dto.supervisor_comment.trim()
? truncate280(dto.supervisor_comment.trim()) : undefined;
const date = toDateFromString(dto.date);
return {
success: true,
data: {
date,
comment,
supervisor_comment,
amount,
attachment,
mileage,
}
};
}

View File

@ -1,13 +1,22 @@
import { ExpenseUpsertService } from "src/time-and-attendance/expenses/services/expense-upsert.service";
import { ExpenseUpdateService } from "src/time-and-attendance/expenses/services/expense-update.service";
import { ExpenseController } from "src/time-and-attendance/expenses/expense.controller";
import { Module } from "@nestjs/common";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper";
import { EmployeeTimesheetResolver } from "src/common/mappers/timesheet.mapper";
import { ExpenseDeleteService } from "src/time-and-attendance/expenses/services/expense-delete.service";
import { ExpenseCreateService } from "src/time-and-attendance/expenses/services/expense-create.service";
@Module({
controllers: [ExpenseController],
providers: [ ExpenseUpsertService, EmailToIdResolver, BankCodesResolver, EmployeeTimesheetResolver ],
providers: [
ExpenseCreateService,
ExpenseUpdateService,
ExpenseDeleteService,
EmailToIdResolver,
BankCodesResolver,
EmployeeTimesheetResolver,
],
})
export class ExpensesModule { }

View File

@ -0,0 +1,73 @@
import { Injectable } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { toStringFromDate, weekStartSunday } from "src/common/utils/date-utils";
import { PrismaService } from "src/prisma/prisma.service";
import { ExpenseDto } from "src/time-and-attendance/expenses/expense-create.dto";
import { normalizeAndParseExpenseDto } from "src/time-and-attendance/expenses/expense.utils";
import { expense_select } from "src/time-and-attendance/utils/selects.utils";
@Injectable()
export class ExpenseCreateService {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) { }
//_________________________________________________________________
// CREATE
//_________________________________________________________________
async createExpense(dto: ExpenseDto, email: string): Promise<Result<ExpenseDto, string>> {
try {
//fetch employee_id using req.user.email
const employee_id = await this.emailResolver.findIdByEmail(email);
if (!employee_id.success) return { success: false, error: employee_id.error };
//normalize strings and dates and Parse numbers
const normed_expense = await normalizeAndParseExpenseDto(dto);
if (!normed_expense.success) return { success: false, error: normed_expense.error }
const type = await this.typeResolver.findBankCodeIDByType(dto.type);
if (!type.success) return { success: false, error: 'INVALID_EXPENSE_TYPE' }
//finds the timesheet using expense.date by finding the sunday
const start_date = weekStartSunday(normed_expense.data.date);
const timesheet = await this.prisma.timesheets.findFirst({
where: { start_date, employee_id: employee_id.data },
select: { id: true, employee_id: true },
});
if (!timesheet) return { success: false, error: `TIMESHEET_NOT_FOUND` };
//create a new expense
const expense = await this.prisma.expenses.create({
data: {
...normed_expense.data,
bank_code_id: type.data,
timesheet_id: timesheet.id,
is_approved: dto.is_approved,
},
//return the newly created expense with id
select: expense_select,
});
if (!expense) return { success: false, error: `INVALID_EXPENSE` };
//build an object to return to the frontend to display
const created: ExpenseDto = {
...expense,
type: dto.type,
date: toStringFromDate(expense.date),
amount: expense.amount?.toNumber() ?? undefined,
mileage: expense.mileage?.toNumber(),
attachment: expense.attachment ?? undefined,
supervisor_comment: expense.supervisor_comment ?? undefined,
};
return { success: true, data: created };
} catch (error) {
return { success: false, error: 'INVALID_EXPENSE' };
}
}
}

View File

@ -0,0 +1,29 @@
import { Injectable } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ExpenseDeleteService {
constructor(private readonly prisma: PrismaService) { }
//_________________________________________________________________
// DELETE
//_________________________________________________________________
async deleteExpense(expense_id: number): Promise<Result<number, string>> {
try {
await this.prisma.$transaction(async (tx) => {
const expense = await tx.expenses.findUnique({
where: { id: expense_id },
select: { id: true },
});
if (!expense) return { success: false, error: `EXPENSE_NOT_FOUND` };
await tx.expenses.delete({ where: { id: expense.id } });
return { success: true, data: expense.id };
});
return { success: true, data: expense_id };
} catch (error) {
return { success: false, error: `EXPENSE_NOT_FOUND` };
}
}
}

View File

@ -0,0 +1,75 @@
import { weekStartSunday, toStringFromDate, toDateFromString } from "src/common/utils/date-utils";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { expense_select, timesheet_select } from "src/time-and-attendance/utils/selects.utils";
import { PrismaService } from "src/prisma/prisma.service";
import { Injectable } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper";
import { ExpenseDto } from "src/time-and-attendance/expenses/expense-create.dto";
import { Prisma } from "@prisma/client";
import { normalizeAndParseExpenseDto } from "src/time-and-attendance/expenses/expense.utils";
@Injectable()
export class ExpenseUpdateService {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) { }
//_________________________________________________________________
// UPDATE
//_________________________________________________________________
async updateExpense(dto: ExpenseDto, email: string, employee_email?: string): Promise<Result<ExpenseDto, string>> {
try {
const account_email = employee_email ?? email;
//fetch employee_id using req.user.email
const employee_id = await this.emailResolver.findIdByEmail(account_email);
if (!employee_id.success) return { success: false, error: employee_id.error };
//normalize string , date format and parse numbers
const normed_expense = await normalizeAndParseExpenseDto(dto);
if (!normed_expense.success) return { success: false, error: normed_expense.error }
const type = await this.typeResolver.findBankCodeIDByType(dto.type);
if (!type.success) return { success: false, error: 'INVALID_EXPENSE_TYPE' }
//added timesheet_id modification check according to the new date
const new_timesheet_start_date = weekStartSunday(toDateFromString(dto.date));
const timesheet = await this.prisma.timesheets.findFirst({
where: { start_date: new_timesheet_start_date, employee_id: employee_id.data },
select: timesheet_select,
});
if (!timesheet) return { success: false, error: `TIMESHEET_NOT_FOUND` }
//checks for modifications
const data: Prisma.ExpensesUpdateInput = {
...normed_expense.data,
bank_code: { connect: { id: type.data } },
is_approved: dto.is_approved,
};
if (!data) return { success: false, error: `INVALID_EXPENSE` }
//push updates and get updated datas
const expense = await this.prisma.expenses.update({
where: { id: dto.id, timesheet_id: timesheet.id },
data,
select: expense_select,
});
if (!expense) return { success: false, error: 'INVALID_EXPENSE' }
//build an object to return to the frontend
const updated: ExpenseDto = {
...expense,
type: expense.bank_code.type,
date: toStringFromDate(expense.date),
amount: expense.amount?.toNumber(),
mileage: expense.mileage?.toNumber(),
attachment: expense.attachment ?? undefined,
supervisor_comment: expense.supervisor_comment ?? undefined,
};
return { success: true, data: updated };
} catch (error) {
return { success: false, error: 'EXPENSE_NOT_FOUND' };
}
}
}

View File

@ -1,188 +0,0 @@
import { weekStartSunday, toStringFromDate, toDateFromString } from "src/common/utils/date-utils";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { expense_select, timesheet_select } from "src/time-and-attendance/utils/selects.utils";
import { PrismaService } from "src/prisma/prisma.service";
import { Injectable } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
import { NormalizedExpense } from "src/time-and-attendance/utils/type.utils";
import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper";
import { ExpenseDto } from "src/time-and-attendance/expenses/expense-create.dto";
import { Prisma } from "@prisma/client";
@Injectable()
export class ExpenseUpsertService {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) { }
//_________________________________________________________________
// CREATE
//_________________________________________________________________
async createExpense(dto: ExpenseDto, email: string): Promise<Result<ExpenseDto, string>> {
try {
//fetch employee_id using req.user.email
const employee_id = await this.emailResolver.findIdByEmail(email);
if (!employee_id.success) return { success: false, error: employee_id.error };
//normalize strings and dates and Parse numbers
const normed_expense = await this.normalizeAndParseExpenseDto(dto);
if (!normed_expense.success) return { success: false, error: normed_expense.error }
//finds the timesheet using expense.date by finding the sunday
const start_date = weekStartSunday(normed_expense.data.date);
const timesheet = await this.prisma.timesheets.findFirst({
where: { start_date, employee_id: employee_id.data },
select: { id: true, employee_id: true },
});
if (!timesheet) return { success: false, error: `TIMESHEET_NOT_FOUND` };
//create a new expense
const expense = await this.prisma.expenses.create({
data: {
...normed_expense.data,
timesheet_id: timesheet.id,
is_approved: dto.is_approved,
},
//return the newly created expense with id
select: expense_select,
});
if (!expense) return { success: false, error: `INVALID_EXPENSE` };
//build an object to return to the frontend to display
const created: ExpenseDto = {
...expense,
type: dto.type,
date: toStringFromDate(expense.date),
amount: expense.amount?.toNumber() ?? undefined,
mileage: expense.mileage?.toNumber(),
attachment: expense.attachment ?? undefined,
supervisor_comment: expense.supervisor_comment ?? undefined,
};
return { success: true, data: created };
} catch (error) {
return { success: false, error: 'INVALID_EXPENSE' };
}
}
//_________________________________________________________________
// UPDATE
//_________________________________________________________________
async updateExpense(dto: ExpenseDto, email: string, employee_email?: string): Promise<Result<ExpenseDto, string>> {
try {
const account_email = employee_email ?? email;
//fetch employee_id using req.user.email
const employee_id = await this.emailResolver.findIdByEmail(account_email);
if (!employee_id.success) return { success: false, error: employee_id.error };
//normalize string , date format and parse numbers
const normed_expense = await this.normalizeAndParseExpenseDto(dto);
if (!normed_expense.success) return { success: false, error: normed_expense.error }
//added timesheet_id modification check according to the new date
const new_timesheet_start_date = weekStartSunday(toDateFromString(dto.date));
const timesheet = await this.prisma.timesheets.findFirst({
where: { start_date: new_timesheet_start_date, employee_id: employee_id.data },
select: timesheet_select,
});
if (!timesheet) return { success: false, error: `TIMESHEET_NOT_FOUND` }
//checks for modifications
const data: Prisma.ExpensesUpdateInput = {
...normed_expense.data,
is_approved: dto.is_approved,
};
if (!data) return { success: false, error: `INVALID_EXPENSE` }
//push updates and get updated datas
const expense = await this.prisma.expenses.update({
where: { id: dto.id, timesheet_id: timesheet.id },
data,
select: expense_select,
});
if (!expense) return { success: false, error: 'INVALID_EXPENSE' }
//build an object to return to the frontend
const updated: ExpenseDto = {
...expense,
type: expense.bank_code.type,
date: toStringFromDate(expense.date),
amount: expense.amount?.toNumber(),
mileage: expense.mileage?.toNumber(),
attachment: expense.attachment ?? undefined,
supervisor_comment: expense.supervisor_comment ?? undefined,
};
return { success: true, data: updated };
} catch (error) {
return { success: false, error: 'EXPENSE_NOT_FOUND' };
}
}
//_________________________________________________________________
// DELETE
//_________________________________________________________________
async deleteExpense(expense_id: number): Promise<Result<number, string>> {
try {
await this.prisma.$transaction(async (tx) => {
const expense = await tx.expenses.findUnique({
where: { id: expense_id },
select: { id: true },
});
if (!expense) return { success: false, error: `EXPENSE_NOT_FOUND` };
await tx.expenses.delete({ where: { id: expense.id } });
return { success: true, data: expense.id };
});
return { success: true, data: expense_id };
} catch (error) {
return { success: false, error: `EXPENSE_NOT_FOUND` };
}
}
//_________________________________________________________________
// LOCAL HELPERS
//_________________________________________________________________
//makes sure that comments are the right length the date is of Date type
private normalizeAndParseExpenseDto = async (dto: ExpenseDto): Promise<Result<NormalizedExpense, string>> => {
const attachment = this.parseOptionalNumber(dto.attachment, "attachment");
const mileage = this.parseOptionalNumber(dto.mileage, "mileage");
const amount = this.parseOptionalNumber(dto.amount, "amount");
const comment = this.truncate280(dto.comment);
const supervisor_comment = dto.supervisor_comment && dto.supervisor_comment.trim()
? this.truncate280(dto.supervisor_comment.trim()) : undefined;
const date = toDateFromString(dto.date);
const type = await this.typeResolver.findBankCodeIDByType(dto.type);
if (!type.success) return { success: false, error: 'INVALID_EXPENSE_TYPE' }
return {
success: true,
data: {
date,
comment,
supervisor_comment,
amount,
attachment,
mileage,
bank_code_id: type.data,
}
};
}
//makes sure that a string cannot exceed 280 chars
private truncate280 = (input: string): string => {
return input.length > 280 ? input.slice(0, 280) : input;
}
//makes sure that the type of data of numeric values is valid
private parseOptionalNumber = (value: unknown, field: string) => {
if (value == null) return undefined;
const parsed = Number(value);
if (Number.isNaN(parsed)) throw new Error(`Invalid value : ${value} for ${field}`);
return parsed;
};
}

View File

@ -1,62 +0,0 @@
import { ExpensesArchive } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class ExpensesArchivalService {
constructor(private readonly prisma: PrismaService){}
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
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 expenses_to_archive = await transaction.expenses.findMany({
where: { timesheet_id: { in: timesheet_ids } },
});
if(expenses_to_archive.length === 0) {
return;
}
//copies sent to archive table
await transaction.expensesArchive.createMany({
data: expenses_to_archive.map(exp => ({
expense_id: exp.id,
timesheet_id: exp.timesheet_id,
bank_code_id: exp.bank_code_id,
date: exp.date,
amount: exp.amount,
attachment: exp.attachment,
comment: exp.comment,
is_approved: exp.is_approved,
supervisor_comment: exp.supervisor_comment,
})),
});
//delete from expenses table
await transaction.expenses.deleteMany({
where: { id: { in: expenses_to_archive.map(exp => exp.id) } },
})
})
}
//fetches all archived timesheets
async findAllArchived(): Promise<ExpensesArchive[]> {
return this.prisma.expensesArchive.findMany();
}
//fetches an archived timesheet
async findOneArchived(id: number): Promise<ExpensesArchive> {
return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } });
}
}

View File

@ -1,3 +1,6 @@
import { Type } from "class-transformer";
import { IsString, IsInt, IsNotEmpty, IsOptional } from "class-validator";
export const paid_time_off_types: string[] = ['SICK', 'VACATION', 'BANKING', 'WITHDRAW_BANKED'];
@ -8,3 +11,20 @@ export const paid_time_off_mapping: Record<string, { field: string; invert_logic
WITHDRAW_BANKED: { field: 'banked_hours', invert_logic: true, operation: 'increment' },
BANKING: { field: 'banked_hours', invert_logic: false, operation: 'decrement' },
};
export class PaidTimeOffDto {
@IsInt() id: number;
@IsInt() @IsNotEmpty() employee_id: number;
@IsOptional() @Type(() => Number) vacation_hours?: number;
@IsOptional() @Type(() => Number) sick_hours?: number;
@IsOptional() @Type(() => Number) banked_hours?: number;
@IsString() @IsOptional() last_updated?: string | null;
constructor(employee_id: number) {
this.employee_id = employee_id;
this.banked_hours = 0;
this.sick_hours = 0;
this.vacation_hours = 0;
this.last_updated = new Date().toISOString();
}
}

View File

@ -55,14 +55,6 @@ export class SchedulePresetsCreateService {
}
await this.prisma.$transaction(async (tx) => {
//check if employee chose this preset has a default preset and ensure all others are false
// if (dto.is_default) {
// await tx.schedulePresets.updateMany({
// where: { is_default: true },
// data: { is_default: false },
// });
// }
await tx.schedulePresets.create({
data: {
name: dto.name,

View File

@ -1,71 +0,0 @@
import { PrismaService } from "src/prisma/prisma.service";
import { ShiftsArchive } from "@prisma/client";
import { Injectable } from "@nestjs/common";
/**
* _____________________________________________________________________________________
*
*
* This service is not used. Will be use to atrchive a list of shifts using a cron job.
*
*
* _____________________________________________________________________________________
*/
@Injectable()
export class ShiftsArchivalService {
constructor(private readonly prisma: PrismaService){}
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
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 shifts_to_archive = await transaction.shifts.findMany({
where: { timesheet_id: { in: timesheet_ids } },
});
if(shifts_to_archive.length === 0) {
return;
}
//copies sent to archive table
await transaction.shiftsArchive.createMany({
data: shifts_to_archive.map(shift => ({
shift_id: shift.id,
timesheet_id: shift.timesheet_id,
bank_code_id: shift.bank_code_id,
comment: shift.comment ?? undefined,
date: shift.date,
start_time: shift.start_time,
end_time: shift.end_time,
})),
});
//delete from shifts table
await transaction.shifts.deleteMany({
where: { id: { in: shifts_to_archive.map(shift => shift.id) } },
})
})
}
//fetches all archived timesheets
async findAllArchived(): Promise<ShiftsArchive[]> {
return this.prisma.shiftsArchive.findMany();
}
//fetches an archived timesheet
async findOneArchived(id: number): Promise<ShiftsArchive> {
return this.prisma.shiftsArchive.findUniqueOrThrow({ where: { id } });
}
}

View File

@ -179,12 +179,4 @@ export class ShiftsCreateService {
return { success: true, data: { date, start_time, end_time, bank_code_id: bank_code_id.data } };
}
private addHourstoDateString = (start_time: string, hours: number): string => {
const start = toDateFromHHmm(start_time);
const end = new Date(start.getTime() + hours * 60 * 60 * 1000);
const hh = String(end.getUTCHours()).padStart(2, '0');
const mm = String(end.getUTCMinutes()).padStart(2, '0');
return `${hh}:${mm}:00`;
}
}

View File

@ -1,58 +0,0 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
/**
* _____________________________________________________________________________________
*
*
* This service is not used. Could be use to show a list of shifts.
*
* For the moment, the module Timesheets is used to display shifts, filtered by employee
*
* _____________________________________________________________________________________
*/
@Injectable()
export class ShiftsGetService {
constructor(
private readonly prisma: PrismaService,
){}
//fetch a shift using shift_id and return all that shift's info
// async getShiftByShiftId(shift_ids: number[]): Promise<GetShiftDto[]> {
// if(!Array.isArray(shift_ids) || shift_ids.length === 0) return [];
// const rows = await this.prisma.shifts.findMany({
// where: { id: { in: shift_ids } },
// select: shift_select,
// });
// if(rows.length !== shift_ids.length) {
// const found_ids = new Set(rows.map(row => row.id));
// const missing_ids = shift_ids.filter(id => !found_ids.has(id));
// throw new NotFoundException(`Shift(s) not found: ${ missing_ids.join(", ")}`);
// }
// const row_by_id = new Map(rows.map(row => [row.id, row]));
// return shift_ids.map((id) => {
// const shift = row_by_id.get(id)!;
// return {
// timesheet_id: shift.timesheet_id,
// type: shift.bank_code.type,
// date: toStringFromDate(shift.date),
// start_time: toStringFromHHmm(shift.start_time),
// end_time: toStringFromHHmm(shift.end_time),
// is_remote: shift.is_remote,
// is_approved: shift.is_approved,
// comment: shift.comment ?? undefined,
// } satisfies GetShiftDto;
// });
// }
}

View File

@ -8,7 +8,6 @@ import { ShiftsUpdateService } from 'src/time-and-attendance/shifts/services/shi
import { VacationService } from 'src/time-and-attendance/domains/services/vacation.service';
import { BankedHoursService } from 'src/time-and-attendance/domains/services/banking-hours.service';
import { PaidTimeOffModule } from 'src/time-and-attendance/paid-time-off/paid-time-off.module';
import { ShiftsGetService } from 'src/time-and-attendance/shifts/services/shifts-get.service';
import { PaidTimeOFfBankHoursService } from 'src/time-and-attendance/paid-time-off/paid-time-off.service';
@Module({
@ -26,7 +25,6 @@ import { PaidTimeOFfBankHoursService } from 'src/time-and-attendance/paid-time-o
ShiftsCreateService,
ShiftsUpdateService,
ShiftsDeleteService,
ShiftsGetService,
],
})
export class ShiftsModule { }

View File

@ -1,14 +1,19 @@
import { Module } from "@nestjs/common";
import { BusinessLogicsModule } from "src/time-and-attendance/domains/business-logics.module";
import { VacationService } from "src/time-and-attendance/domains/services/vacation.service";
import { BankedHoursService } from "src/time-and-attendance/domains/services/banking-hours.service";
import { PaidTimeOffModule } from "src/time-and-attendance/paid-time-off/paid-time-off.module";
import { PaidTimeOFfBankHoursService } from "src/time-and-attendance/paid-time-off/paid-time-off.service";
import { ExpenseController } from "src/time-and-attendance/expenses/expense.controller";
import { ExpenseUpsertService } from "src/time-and-attendance/expenses/services/expense-upsert.service";
import { ExpenseCreateService } from "src/time-and-attendance/expenses/services/expense-create.service";
import { ExpenseUpdateService } from "src/time-and-attendance/expenses/services/expense-update.service";
import { ExpenseDeleteService } from "src/time-and-attendance/expenses/services/expense-delete.service";
import { ExpensesModule } from "src/time-and-attendance/expenses/expenses.module";
import { TimesheetController } from "src/time-and-attendance/timesheets/timesheet.controller";
import { TimesheetApprovalService } from "src/time-and-attendance/timesheets/services/timesheet-approval.service";
import { GetTimesheetsOverviewService } from "src/time-and-attendance/timesheets/services/timesheet-get-overview.service";
import { GetTimesheetsOverviewService } from "src/time-and-attendance/timesheets/services/timesheet-employee-overview.service";
import { TimesheetsModule } from "src/time-and-attendance/timesheets/timesheets.module";
import { BankCodesResolver } from "src/common/mappers/bank-type-id.mapper";
@ -23,11 +28,11 @@ import { PayPeriodsCommandService } from "src/time-and-attendance/pay-period/ser
import { CsvExportModule } from "src/time-and-attendance/exports/csv-exports.module";
import { CsvExportService } from "src/time-and-attendance/exports/services/csv-exports.service";
import { CsvGeneratorService } from "src/time-and-attendance/exports/services/csv-builder.service";
import { CsvExportController } from "src/time-and-attendance/exports/csv-exports.controller";
import { ShiftController } from "src/time-and-attendance/shifts/shift.controller";
import { ShiftsCreateService } from "src/time-and-attendance/shifts/services/shifts-create.service";
import { ShiftsGetService } from "src/time-and-attendance/shifts/services/shifts-get.service";
import { ShiftsUpdateService } from "src/time-and-attendance/shifts/services/shifts-update.service";
import { ShiftsDeleteService } from "src/time-and-attendance/shifts/services/shifts-delete.service";
@ -38,11 +43,7 @@ import { SchedulePresetDeleteService } from "src/time-and-attendance/schedule-pr
import { SchedulePresetUpdateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-update.service";
import { SchedulePresetsCreateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-create.service";
import { SchedulePresetsApplyService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-apply.service";
import { CsvGeneratorService } from "src/time-and-attendance/exports/services/csv-builder.service";
import { VacationService } from "src/time-and-attendance/domains/services/vacation.service";
import { BankedHoursService } from "src/time-and-attendance/domains/services/banking-hours.service";
import { PaidTimeOffModule } from "src/time-and-attendance/paid-time-off/paid-time-off.module";
import { PaidTimeOFfBankHoursService } from "src/time-and-attendance/paid-time-off/paid-time-off.service";
@Module({
imports: [
@ -66,11 +67,12 @@ import { PaidTimeOFfBankHoursService } from "src/time-and-attendance/paid-time-o
],
providers: [
GetTimesheetsOverviewService,
ShiftsGetService,
ShiftsCreateService,
ShiftsUpdateService,
ShiftsDeleteService,
ExpenseUpsertService,
ExpenseCreateService,
ExpenseUpdateService,
ExpenseDeleteService,
SchedulePresetsGetService,
SchedulePresetDeleteService,
SchedulePresetsApplyService,

View File

@ -1,49 +0,0 @@
// import { TimesheetsArchive } from "@prisma/client";
// import { PrismaService } from "src/prisma/prisma.service";
// export class TimesheetArchiveService {
// constructor(private readonly prisma: PrismaService){}
// async archiveOld(): Promise<void> {
// //calcul du cutoff pour archivation
// const cutoff = new Date();
// cutoff.setMonth(cutoff.getMonth() - 6)
// await this.prisma.$transaction(async transaction => {
// //fetches all timesheets to cutoff
// const oldSheets = await transaction.timesheets.findMany({
// where: { shift: { some: { date: { lt: cutoff } } },
// },
// select: {
// id: true,
// employee_id: true,
// is_approved: true,
// },
// });
// if( oldSheets.length === 0) return;
// //preping data for archivation
// const archiveDate = oldSheets.map(sheet => ({
// timesheet_id: sheet.id,
// employee_id: sheet.employee_id,
// is_approved: sheet.is_approved,
// }));
// //copying data from timesheets table to archive table
// await transaction.timesheetsArchive.createMany({ data: archiveDate });
// //removing data from timesheets table
// await transaction.timesheets.deleteMany({ where: { id: { in: oldSheets.map(s => s.id) } } });
// });
// }
// //fetches all archived timesheets
// async findAllArchived(): Promise<TimesheetsArchive[]> {
// return this.prisma.timesheetsArchive.findMany();
// }
// //fetches an archived timesheet
// async findOneArchived(id: number): Promise<TimesheetsArchive> {
// return this.prisma.timesheetsArchive.findUniqueOrThrow({ where: { id } });
// }
// }

View File

@ -0,0 +1,125 @@
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/timesheets/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";
import { mapOneTimesheet } from "src/time-and-attendance/timesheets/timesheet.mapper";
@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, employee_email?: string): Promise<Result<Timesheets, string>> {
try {
const account_email = employee_email ?? email;
//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_NOT_FOUND` };
//fetch the employee_id using the email
const employee_id = await this.emailResolver.findIdByEmail(account_email);
if (!employee_id.success) return { success: false, error: 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) await 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: { schedule_preset: true, user: true },
});
if (!employee) return { success: false, error: `EMPLOYEE_NOT_FOUND` }
//builds employee details
const has_preset_schedule = employee.schedule_preset !== null;
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) => mapOneTimesheet(timesheet)));
if (!timesheets) return { success: false, error: 'INVALID_TIMESHEET' }
return { success: true, data: { has_preset_schedule, employee_fullname, timesheets } };
} catch (error) {
return { success: false, error: 'TIMESHEET_NOT_FOUND' }
}
}
//-----------------------------------------------------------------------------------
// 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 }, orderBy: { start_time: 'asc' } },
expense: { include: { bank_code: true, attachment_record: true }, orderBy: [{ date: 'asc' }, { bank_code_id: 'desc' }] },
},
orderBy: { start_date: 'asc' },
});
}
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!;
}
}

View File

@ -1,290 +0,0 @@
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/timesheets/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, employee_email?: string): Promise<Result<Timesheets, string>> {
try {
const account_email = employee_email ?? email;
//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_NOT_FOUND` };
//fetch the employee_id using the email
const employee_id = await this.emailResolver.findIdByEmail(account_email);
if (!employee_id.success) return { success: false, error: 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) await 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: { schedule_preset: true, user: true },
});
if (!employee) return { success: false, error: `EMPLOYEE_NOT_FOUND` }
//builds employee details
const has_preset_schedule = employee.schedule_preset !== null;
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: 'INVALID_TIMESHEET' }
return { success: true, data: { has_preset_schedule, employee_fullname, timesheets } };
} catch (error) {
return { success: false, error: 'TIMESHEET_NOT_FOUND' }
}
}
//-----------------------------------------------------------------------------------
// 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 }, orderBy: { start_time: 'asc' } },
expense: { include: { bank_code: true, attachment_record: true }, orderBy: [{ date: 'asc' }, { bank_code_id: 'desc' }] },
},
orderBy: { start_date: 'asc' },
});
}
private async mapOneTimesheet(timesheet: Prisma.TimesheetsGetPayload<{
include: {
employee: { include: { user } },
shift: { include: { bank_code }, orderBy: { start_time: 'asc' } },
expense: { include: { bank_code, attachment_record } },
}
}>): Promise<Timesheet> {
//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, Prisma.ShiftsGetPayload<{ include: { bank_code }, orderBy: { start_time: 'asc' } }>[]>();
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<string, Prisma.ExpensesGetPayload<{ include: { bank_code: {}, attachment_record } }>[]>();
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,
id: expense.id ?? null,
timesheet_id: expense.timesheet_id,
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[subgroup] += hours;
weekly_hours[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.mileage += mileage;
weekly_expenses.mileage += mileage;
} else if (subgroup === 'per_diem') {
const amount = num(expense.amount);
daily_expenses.per_diem += amount;
weekly_expenses.per_diem += amount;
} else if (subgroup === 'on_call') {
const amount = num(expense.amount);
daily_expenses.on_call += amount;
weekly_expenses.on_call += amount;
} else {
const amount = num(expense.amount);
daily_expenses.expenses += amount;
weekly_expenses.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';
}

View File

@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query, Req, UnauthorizedException } from "@nestjs/common";
import { GetTimesheetsOverviewService } from "src/time-and-attendance/timesheets/services/timesheet-get-overview.service";
import { GetTimesheetsOverviewService } from "src/time-and-attendance/timesheets/services/timesheet-employee-overview.service";
import { TimesheetApprovalService } from "src/time-and-attendance/timesheets/services/timesheet-approval.service";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client";

View File

@ -38,6 +38,8 @@ export class TotalHours {
@Type(() => Number) vacation: number;
@Type(() => Number) holiday: number;
@Type(() => Number) sick: number;
@Type(() => Number) banking: number;
@Type(() => Number) withdraw_banked: number;
}
export class TotalExpenses {
@Type(() => Number) expenses: number;
@ -69,3 +71,4 @@ export class Expense {
@IsOptional() @IsInt() id?: number | null;
@IsString() @IsOptional() supervisor_comment?: string | null;
}

View File

@ -0,0 +1,174 @@
import { Prisma } from "@prisma/client";
import { toDateFromString, sevenDaysFrom, toStringFromDate, toHHmmFromDate } from "src/common/utils/date-utils";
import { Timesheet } from "src/time-and-attendance/timesheets/timesheet.dto";
export const mapOneTimesheet = async (timesheet: Prisma.TimesheetsGetPayload<{
include: {
employee: { include: { user } },
shift: { include: { bank_code }, orderBy: { start_time: 'asc' } },
expense: { include: { bank_code, attachment_record } },
}
}>): Promise<Timesheet> => {
//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, Prisma.ShiftsGetPayload<{ include: { bank_code }, orderBy: { start_time: 'asc' } }>[]>();
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<string, Prisma.ExpensesGetPayload<{ include: { bank_code: {}, attachment_record } }>[]>();
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,
id: expense.id ?? null,
timesheet_id: expense.timesheet_id,
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[subgroup] += hours;
weekly_hours[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.mileage += mileage;
weekly_expenses.mileage += mileage;
} else if (subgroup === 'per_diem') {
const amount = num(expense.amount);
daily_expenses.per_diem += amount;
weekly_expenses.per_diem += amount;
} else if (subgroup === 'on_call') {
const amount = num(expense.amount);
daily_expenses.on_call += amount;
weekly_expenses.on_call += amount;
} else {
const amount = num(expense.amount);
daily_expenses.expenses += amount;
weekly_expenses.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,
};
}
//filled array with default values
const emptyHours = (): TotalHours => { return { regular: 0, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0, banking: 0, withdraw_banked: 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';
if (type.includes('BANKING')) return 'banking';
if (type.includes('WITHDRAW_BANKED')) return 'withdraw_banked'
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';
}
export type TotalHours = {
regular: number;
evening: number;
emergency: number;
overtime: number;
vacation: number;
holiday: number;
sick: number;
banking: number;
withdraw_banked: number;
};
export type TotalExpenses = {
expenses: number;
per_diem: number;
on_call: number;
mileage: number;
};

View File

@ -2,7 +2,7 @@
import { Module } from '@nestjs/common';
import { TimesheetController } from 'src/time-and-attendance/timesheets/timesheet.controller';
import { TimesheetApprovalService } from 'src/time-and-attendance/timesheets/services/timesheet-approval.service';
import { GetTimesheetsOverviewService } from 'src/time-and-attendance/timesheets/services/timesheet-get-overview.service';
import { GetTimesheetsOverviewService } from 'src/time-and-attendance/timesheets/services/timesheet-employee-overview.service';
import { EmailToIdResolver } from 'src/common/mappers/email-id.mapper';
@Module({

View File

@ -14,7 +14,7 @@ export type NormalizedExpense = {
amount?: number | Prisma.Decimal | null;
mileage?: number | Prisma.Decimal | null;
attachment?: number;
bank_code_id: number;
// bank_code_id: number;
};
export type NormalizedLeaveRequest = {