Merge branch 'main' of git.targo.ca:Targo/targo_backend into auth-session-fullstack

This commit is contained in:
Nicolas Drolet 2025-10-07 10:24:25 -04:00
commit e575b9bd54
15 changed files with 452 additions and 472 deletions

View File

@ -125,46 +125,6 @@
] ]
} }
}, },
"/employees": {
"post": {
"operationId": "EmployeesController_create",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateEmployeeDto"
}
}
}
},
"responses": {
"201": {
"description": "Employee created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateEmployeeDto"
}
}
}
},
"400": {
"description": "Incomplete task or invalid data"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Create employee",
"tags": [
"Employees"
]
}
},
"/employees/employee-list": { "/employees/employee-list": {
"get": { "get": {
"operationId": "EmployeesController_findListEmployees", "operationId": "EmployeesController_findListEmployees",
@ -1127,6 +1087,81 @@
} }
}, },
"schemas": { "schemas": {
"EmployeeListItemDto": {
"type": "object",
"properties": {}
},
"UpdateEmployeeDto": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1,
"description": "Unique ID of an employee(primary-key, auto-incremented)"
},
"user_id": {
"type": "string",
"example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d",
"description": "UUID of the user linked to that employee"
},
"first_name": {
"type": "string",
"example": "Frodo",
"description": "Employee`s first name"
},
"last_name": {
"type": "string",
"example": "Baggins",
"description": "Employee`s last name"
},
"email": {
"type": "string",
"example": "i_cant_do_this_sam@targointernet.com",
"description": "Employee`s email"
},
"phone_number": {
"type": "string",
"example": "82538437464",
"description": "Employee`s phone number"
},
"residence": {
"type": "string",
"example": "1 Bagshot Row, Hobbiton, The Shire, Middle-earth",
"description": "Employee`s residence"
},
"external_payroll_id": {
"type": "number",
"example": 7464,
"description": "external ID for the pay system"
},
"company_code": {
"type": "number",
"example": 335567447,
"description": "Employee`s company code"
},
"job_title": {
"type": "string",
"example": "technicient",
"description": "employee`s job title"
},
"first_work_day": {
"format": "date-time",
"type": "string",
"example": "23/09/3018",
"description": "New hire date or undefined"
},
"last_work_day": {
"format": "date-time",
"type": "string",
"example": "25/03/3019",
"description": "Termination date (null to restore)"
},
"supervisor_id": {
"type": "number",
"description": "Supervisor ID"
}
}
},
"CreateEmployeeDto": { "CreateEmployeeDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -1204,81 +1239,6 @@
"first_work_day" "first_work_day"
] ]
}, },
"EmployeeListItemDto": {
"type": "object",
"properties": {}
},
"UpdateEmployeeDto": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1,
"description": "Unique ID of an employee(primary-key, auto-incremented)"
},
"user_id": {
"type": "string",
"example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d",
"description": "UUID of the user linked to that employee"
},
"first_name": {
"type": "string",
"example": "Frodo",
"description": "Employee`s first name"
},
"last_name": {
"type": "string",
"example": "Baggins",
"description": "Employee`s last name"
},
"email": {
"type": "string",
"example": "i_cant_do_this_sam@targointernet.com",
"description": "Employee`s email"
},
"phone_number": {
"type": "string",
"example": "82538437464",
"description": "Employee`s phone number"
},
"residence": {
"type": "string",
"example": "1 Bagshot Row, Hobbiton, The Shire, Middle-earth",
"description": "Employee`s residence"
},
"external_payroll_id": {
"type": "number",
"example": 7464,
"description": "external ID for the pay system"
},
"company_code": {
"type": "number",
"example": 335567447,
"description": "Employee`s company code"
},
"job_title": {
"type": "string",
"example": "technicient",
"description": "employee`s job title"
},
"first_work_day": {
"format": "date-time",
"type": "string",
"example": "23/09/3018",
"description": "New hire date or undefined"
},
"last_work_day": {
"format": "date-time",
"type": "string",
"example": "25/03/3019",
"description": "Termination date (null to restore)"
},
"supervisor_id": {
"type": "number",
"description": "Supervisor ID"
}
}
},
"CreateWeekShiftsDto": { "CreateWeekShiftsDto": {
"type": "object", "type": "object",
"properties": {} "properties": {}

View File

@ -2,20 +2,20 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { EmployeesArchive, Roles as RoleEnum } from '@prisma/client'; import { EmployeesArchive, Roles as RoleEnum } from '@prisma/client';
import { EmployeesService } from "src/modules/employees/services/employees.service"; import { EmployeesArchivalService } from "src/modules/employees/services/employees-archival.service";
@ApiTags('Employee Archives') @ApiTags('Employee Archives')
// @UseGuards() // @UseGuards()
@Controller('archives/employees') @Controller('archives/employees')
export class EmployeesArchiveController { export class EmployeesArchiveController {
constructor(private readonly employeesService: EmployeesService) {} constructor(private readonly employeesArchiveService: EmployeesArchivalService) {}
@Get() @Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'List of archived employees'}) @ApiOperation({ summary: 'List of archived employees'})
@ApiResponse({ status: 200, description: 'List of archived employees', isArray: true }) @ApiResponse({ status: 200, description: 'List of archived employees', isArray: true })
async findAllArchived(): Promise<EmployeesArchive[]> { async findAllArchived(): Promise<EmployeesArchive[]> {
return this.employeesService.findAllArchived(); return this.employeesArchiveService.findAllArchived();
} }
@Get() @Get()
@ -24,7 +24,7 @@ export class EmployeesArchiveController {
@ApiResponse({ status: 200, description: 'Archived employee found'}) @ApiResponse({ status: 200, description: 'Archived employee found'})
async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<EmployeesArchive> { async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise<EmployeesArchive> {
try{ try{
return await this.employeesService.findOneArchived(id); return await this.employeesArchiveService.findOneArchived(id);
}catch { }catch {
throw new NotFoundException(`Archived employee #${id} not found`); throw new NotFoundException(`Archived employee #${id} not found`);
} }

View File

@ -2,13 +2,13 @@ import { UseGuards, Controller, Get, Param, ParseIntPipe, NotFoundException } fr
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
import { ExpensesArchive,Roles as RoleEnum } from "@prisma/client"; import { ExpensesArchive,Roles as RoleEnum } from "@prisma/client";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service";
@ApiTags('Expense Archives') @ApiTags('Expense Archives')
// @UseGuards() // @UseGuards()
@Controller('archives/expenses') @Controller('archives/expenses')
export class ExpensesArchiveController { export class ExpensesArchiveController {
constructor(private readonly expensesService: ExpensesQueryService) {} constructor(private readonly expensesService: ExpensesArchivalService) {}
@Get() @Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)

View File

@ -2,13 +2,13 @@ import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } fr
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { ShiftsArchive, Roles as RoleEnum } from "@prisma/client"; import { ShiftsArchive, Roles as RoleEnum } from "@prisma/client";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service";
@ApiTags('Shift Archives') @ApiTags('Shift Archives')
// @UseGuards() // @UseGuards()
@Controller('archives/shifts') @Controller('archives/shifts')
export class ShiftsArchiveController { export class ShiftsArchiveController {
constructor(private readonly shiftsService:ShiftsQueryService) {} constructor(private readonly shiftsService: ShiftsArchivalService) {}
@Get() @Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)

View File

@ -2,13 +2,13 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { TimesheetsArchive, Roles as RoleEnum } from '@prisma/client'; import { TimesheetsArchive, Roles as RoleEnum } from '@prisma/client';
import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service";
@ApiTags('Timesheet Archives') @ApiTags('Timesheet Archives')
// @UseGuards() // @UseGuards()
@Controller('archives/timesheets') @Controller('archives/timesheets')
export class TimesheetsArchiveController { export class TimesheetsArchiveController {
constructor(private readonly timesheetsService: TimesheetsQueryService) {} constructor(private readonly timesheetsService: TimesheetArchiveService) {}
@Get() @Get()
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)

View File

@ -1,17 +1,17 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule"; import { Cron } from "@nestjs/schedule";
import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service";
import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service";
import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service";
@Injectable() @Injectable()
export class ArchivalService { export class ArchivalService {
private readonly logger = new Logger(ArchivalService.name); private readonly logger = new Logger(ArchivalService.name);
constructor( constructor(
private readonly timesheetsService: TimesheetsQueryService, private readonly timesheetsService: TimesheetArchiveService,
private readonly expensesService: ExpensesQueryService, private readonly expensesService: ExpensesArchivalService,
private readonly shiftsService: ShiftsQueryService, private readonly shiftsService: ShiftsArchivalService,
) {} ) {}
@Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00 @Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00

View File

@ -6,7 +6,7 @@ import {
Matches, Matches,
MaxLength, MaxLength,
Min, Min,
ValidateIf, ValidateIf,
ValidateNested ValidateNested
} from "class-validator"; } from "class-validator";

View File

@ -21,6 +21,7 @@ export class ExpensesDto {
bank_type: string; bank_type: string;
date: string; date: string;
amount: number; amount: number;
mileage: number;
km: number; km: number;
comment: string; comment: string;
supervisor_comment: string; supervisor_comment: string;

View File

@ -1,20 +1,22 @@
export class ShiftDto { export class ShiftDto {
date: string; date: string;
type: string; type: string;
start_time: string; start_time: string;
end_time : string; end_time : string;
comment: string; comment: string;
is_approved: boolean; is_approved: boolean;
is_remote: boolean; is_remote: boolean;
} }
export class ExpenseDto { export class ExpenseDto {
amount: number; type: string;
comment: string; amount: number;
supervisor_comment: string; mileage: number;
comment: string;
total_mileage: number; total_mileage: number;
total_expense: number; total_expense: number;
is_approved: boolean; is_approved: boolean;
supervisor_comment: string;
} }
export type DayShiftsDto = ShiftDto[]; export type DayShiftsDto = ShiftDto[];
@ -31,9 +33,10 @@ export class DetailedShifts {
} }
export class DayExpensesDto { export class DayExpensesDto {
cash: ExpenseDto[] = []; expense: ExpenseDto[] = [];
km : ExpenseDto[] = []; mileage: ExpenseDto[] = [];
[otherType:string]: ExpenseDto[] | any; per_diem: ExpenseDto[] = [];
on_call: ExpenseDto[] = [];
} }
export class WeekDto { export class WeekDto {
@ -62,3 +65,5 @@ export class TimesheetPeriodDto {
weeks: WeekDto[]; weeks: WeekDto[];
employee_full_name: string; employee_full_name: string;
} }

View File

@ -0,0 +1,56 @@
import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "../dtos/timesheet-period.dto";
import { ExpensesAmount } from "../types/timesheet.types";
import { addDays, shortDate } from "../utils/timesheet.helpers";
// Factories
export function makeEmptyDayExpenses(): DayExpensesDto {
return {
expense: [],
mileage: [],
per_diem: [],
on_call: [],
};
}
export function makeEmptyWeek(week_start: Date): WeekDto {
const make_empty_shifts = (offset: number): DetailedShifts => ({
shifts: [],
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
comment: '',
short_date: shortDate(addDays(week_start, offset)),
break_durations: 0,
});
return {
is_approved: true,
shifts: {
sun: make_empty_shifts(0),
mon: make_empty_shifts(1),
tue: make_empty_shifts(2),
wed: make_empty_shifts(3),
thu: make_empty_shifts(4),
fri: make_empty_shifts(5),
sat: make_empty_shifts(6),
},
expenses: {
sun: makeEmptyDayExpenses(),
mon: makeEmptyDayExpenses(),
tue: makeEmptyDayExpenses(),
wed: makeEmptyDayExpenses(),
thu: makeEmptyDayExpenses(),
fri: makeEmptyDayExpenses(),
sat: makeEmptyDayExpenses(),
},
};
}
export function makeEmptyPeriod(): TimesheetPeriodDto {
return { weeks: [makeEmptyWeek(new Date()), makeEmptyWeek(new Date())], employee_full_name: '' };
}
export const makeAmounts = (): ExpensesAmount => ({
expense: 0,
mileage: 0,
});

View File

@ -47,8 +47,9 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
return timesheet; return timesheet;
} }
//_____________________________________________________________________________________________
//
//_____________________________________________________________________________________________
async createWeekShiftsAndReturnOverview( async createWeekShiftsAndReturnOverview(
email:string, email:string,

View File

@ -1,11 +1,12 @@
import { buildPeriod, endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers'; import { endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers';
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; import { OvertimeService } from 'src/modules/business-logics/services/overtime.service';
import { TimesheetDto } from '../dtos/overview-timesheet.dto'; import { TimesheetDto } from '../dtos/overview-timesheet.dto';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers'; import { ShiftRow, ExpenseRow } from '../types/timesheet.types';
import { buildPeriod } from '../utils/timesheet.utils';
@Injectable() @Injectable()
@ -18,7 +19,9 @@ export class TimesheetsQueryService {
async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> { async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
//finds the employee //finds the employee
const employee = await this.prisma.employees.findFirst({ const employee = await this.prisma.employees.findFirst({
where: { user: { is: { email } } }, where: {
user: { is: { email } }
},
select: { select: {
id: true, id: true,
user_id: true, user_id: true,
@ -38,8 +41,14 @@ export class TimesheetsQueryService {
//finds the period //finds the period
const period = await this.prisma.payPeriods.findFirst({ const period = await this.prisma.payPeriods.findFirst({
where: { pay_year: year, pay_period_no: period_no }, where: {
select: { period_start: true, period_end: true }, pay_year: year,
pay_period_no: period_no
},
select: {
period_start: true,
period_end: true
},
}); });
if(!period) throw new NotFoundException(`Period ${year}-${period_no} not found`); if(!period) throw new NotFoundException(`Period ${year}-${period_no} not found`);
@ -52,12 +61,12 @@ export class TimesheetsQueryService {
date: { gte: from, lte: to }, date: { gte: from, lte: to },
}, },
select: { select: {
date: true, date: true,
start_time: true, start_time: true,
end_time: true, end_time: true,
comment: true, comment: true,
is_approved: true, is_approved: true,
is_remote: true, is_remote: true,
bank_code: { select: { type: true } }, bank_code: { select: { type: true } },
}, },
orderBy:[ { date:'asc'}, { start_time: 'asc'} ], orderBy:[ { date:'asc'}, { start_time: 'asc'} ],
@ -69,15 +78,16 @@ export class TimesheetsQueryService {
date: { gte: from, lte: to }, date: { gte: from, lte: to },
}, },
select: { select: {
date: true, date: true,
amount: true, amount: true,
comment: true, mileage: true,
supervisor_comment: true, comment: true,
is_approved: true, is_approved: true,
supervisor_comment: true,
bank_code: { select: { type: true } }, bank_code: { select: { type: true } },
}, },
orderBy: { date: 'asc' }, orderBy: { date: 'asc' },
}); });
const toNum = (value: any) => const toNum = (value: any) =>
value && typeof value.toNumber === 'function' ? value.toNumber() : value && typeof value.toNumber === 'function' ? value.toNumber() :
@ -86,22 +96,23 @@ export class TimesheetsQueryService {
// data mapping // data mapping
const shifts: ShiftRow[] = raw_shifts.map(shift => ({ const shifts: ShiftRow[] = raw_shifts.map(shift => ({
date: shift.date, date: shift.date,
start_time: shift.start_time, start_time: shift.start_time,
end_time: shift.end_time, end_time: shift.end_time,
comment: shift.comment ?? '', comment: shift.comment ?? '',
is_approved: shift.is_approved ?? true, is_approved: shift.is_approved ?? true,
is_remote: shift.is_remote ?? true, is_remote: shift.is_remote ?? true,
type: String(shift.bank_code?.type ?? '').toUpperCase(), type: String(shift.bank_code?.type ?? '').toUpperCase(),
})); }));
const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ const expenses: ExpenseRow[] = raw_expenses.map(expense => ({
date: expense.date, type: String(expense.bank_code?.type ?? '').toUpperCase(),
amount: toNum(expense.amount), date: expense.date,
comment: expense.comment ?? '', amount: toNum(expense.amount),
supervisor_comment: expense.supervisor_comment ?? '', mileage: toNum(expense.mileage),
comment: expense.comment ?? '',
is_approved: expense.is_approved ?? true, is_approved: expense.is_approved ?? true,
type: String(expense.bank_code?.type ?? '').toUpperCase(), supervisor_comment: expense.supervisor_comment ?? '',
})); }));
return buildPeriod(period.period_start, period.period_end, shifts , expenses, employee_full_name); return buildPeriod(period.period_start, period.period_end, shifts , expenses, employee_full_name);
@ -111,27 +122,27 @@ export class TimesheetsQueryService {
//fetch user related to email //fetch user related to email
const user = await this.prisma.users.findUnique({ const user = await this.prisma.users.findUnique({
where: { email }, where: { email },
select: { id: true }, select: { id: true },
}); });
if(!user) throw new NotFoundException(`user with email ${email} not found`); if(!user) throw new NotFoundException(`user with email ${email} not found`);
//fetch employee_id matching the email //fetch employee_id matching the email
const employee = await this.prisma.employees.findFirst({ const employee = await this.prisma.employees.findFirst({
where: { user_id: user.id }, where: { user_id: user.id },
select: { id: true }, select: { id: true },
}); });
if(!employee) throw new NotFoundException(`Employee with email: ${email} not found`); if(!employee) throw new NotFoundException(`Employee with email: ${email} not found`);
//sets current week Sunday -> Saturday //sets current week Sunday -> Saturday
const base = new Date(); const base = new Date();
const offset = new Date(base); const offset = new Date(base);
offset.setDate(offset.getDate() + (week_offset * 7)); offset.setDate(offset.getDate() + (week_offset * 7));
const start_date_week = getWeekStart(offset, 0); const start_date_week = getWeekStart(offset, 0);
const end_date_week = getWeekEnd(start_date_week); const end_date_week = getWeekEnd(start_date_week);
const start_day = formatDateISO(start_date_week); const start_day = formatDateISO(start_date_week);
const end_day = formatDateISO(end_date_week); const end_day = formatDateISO(end_date_week);
//build the label MM/DD/YYYY.MM/DD.YYYY //build the label MM/DD/YYYY.MM/DD.YYYY
const mm_dd = (date: Date) => `${String(date.getFullYear())}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2,'0')}`; const mm_dd = (date: Date) => `${String(date.getFullYear())}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2,'0')}`;
@ -173,7 +184,7 @@ export class TimesheetsQueryService {
//maps all shifts of selected timesheet //maps all shifts of selected timesheet
const shifts = timesheet.shift.map((shift_row) => ({ const shifts = timesheet.shift.map((shift_row) => ({
bank_type: shift_row.bank_code?.type ?? '', bank_type: shift_row.bank_code?.type ?? '',
date: formatDateISO(shift_row.date), date: formatDateISO(shift_row.date),
start_time: toHHmm(shift_row.start_time), start_time: toHHmm(shift_row.start_time),
@ -188,9 +199,9 @@ export class TimesheetsQueryService {
bank_type: exp.bank_code?.type ?? '', bank_type: exp.bank_code?.type ?? '',
date: formatDateISO(exp.date), date: formatDateISO(exp.date),
amount: Number(exp.amount) || 0, amount: Number(exp.amount) || 0,
mileage: exp.mileage != null ? Number(exp.mileage) : 0,
comment: exp.comment ?? '', comment: exp.comment ?? '',
is_approved: exp.is_approved ?? false, is_approved: exp.is_approved ?? false,
km: 0,
supervisor_comment: exp.supervisor_comment ?? '', supervisor_comment: exp.supervisor_comment ?? '',
})); }));

View File

@ -0,0 +1,73 @@
export type ShiftRow = {
date: Date;
start_time: Date;
end_time: Date;
comment: string;
is_approved?: boolean;
is_remote: boolean;
type: string
};
export type ExpenseRow = {
date: Date;
amount: number;
mileage?: number | null;
comment: string;
type: string;
is_approved?: boolean;
supervisor_comment: string;
};
//Date & Format
export const MS_PER_DAY = 86_400_000;
export const MS_PER_HOUR = 3_600_000;
// Types
export const SHIFT_TYPES = {
REGULAR: 'REGULAR',
EVENING: 'EVENING',
OVERTIME: 'OVERTIME',
EMERGENCY: 'EMERGENCY',
HOLIDAY: 'HOLIDAY',
VACATION: 'VACATION',
SICK: 'SICK',
} as const;
export const EXPENSE_TYPES = {
MILEAGE: 'MILEAGE',
EXPENSE: 'EXPENSES',
PER_DIEM: 'PER_DIEM',
ON_CALL: 'ON_CALL',
} as const;
//makes the strings indexes for arrays
export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
export type DayKey = typeof DAY_KEYS[number];
export const EXPENSE_BUCKETS = ['expense', 'mileage'] as const;
export type ExpenseBucketKey = typeof EXPENSE_BUCKETS[number];
//shifts's hour by type
export type ShiftsHours = {
regular: number;
evening: number;
overtime: number;
emergency: number;
sick: number;
vacation: number;
holiday: number;
};
export const make_hours = (): ShiftsHours => ({
regular: 0,
evening: 0,
overtime: 0,
emergency: 0,
sick: 0,
vacation: 0,
holiday: 0,
});
export type ExpensesAmount = {
expense: number;
mileage: number;
};

View File

@ -1,71 +1,6 @@
import { toDateString } from "src/modules/pay-periods/utils/pay-year.util"; import { MS_PER_DAY, DayKey, DAY_KEYS } from "../types/timesheet.types";
import { DayExpensesDto, DetailedShifts, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto";
//makes the strings indexes for arrays
export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
export type DayKey = typeof DAY_KEYS[number];
export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday
return DAY_KEYS[index];
}
export const toHHmm = (date: Date) => date.toISOString().slice(11, 16);
//create shifts within timesheet's week - employee overview functions
export function parseISODate(iso: string): Date {
const [ y, m, d ] = iso.split('-').map(Number);
return new Date(y, (m ?? 1) - 1, d ?? 1);
}
export function parseHHmm(t: string): Date {
const [ hh, mm ] = t.split(':').map(Number);
return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0);
}
//Date & Format
const MS_PER_DAY = 86_400_000;
const MS_PER_HOUR = 3_600_000;
// Types
const SHIFT_TYPES = {
REGULAR: 'REGULAR',
EVENING: 'EVENING',
OVERTIME: 'OVERTIME',
EMERGENCY: 'EMERGENCY',
HOLIDAY: 'HOLIDAY',
VACATION: 'VACATION',
SICK: 'SICK',
} as const;
const EXPENSE_TYPES = {
MILEAGE: 'MILEAGE',
EXPENSE: 'EXPENSES',
PER_DIEM: 'PER_DIEM',
COMMISSION: 'COMMISSION',
PRIME_DISPO: 'PRIME_DISPO',
} as const;
//DB line types
export type ShiftRow = {
date: Date;
start_time: Date;
end_time: Date;
comment: string;
is_approved?: boolean;
is_remote: boolean;
type: string
};
export type ExpenseRow = {
date: Date;
amount: number;
comment: string;
supervisor_comment: string;
is_approved?: boolean;
type: string;
};
//helper functions
export function toUTCDateOnly(date: Date | string): Date { export function toUTCDateOnly(date: Date | string): Date {
const d = new Date(date); const d = new Date(date);
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
@ -95,240 +30,27 @@ export function round2(num: number) {
return Math.round(num * 100) / 100; return Math.round(num * 100) / 100;
} }
export function shortDate(date:Date): string {
function shortDate(date:Date): string {
const mm = String(date.getUTCMonth()+1).padStart(2,'0'); const mm = String(date.getUTCMonth()+1).padStart(2,'0');
const dd = String(date.getUTCDate()).padStart(2,'0'); const dd = String(date.getUTCDate()).padStart(2,'0');
return `${mm}/${dd}`; return `${mm}/${dd}`;
} }
// Factories export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; } const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday
return DAY_KEYS[index];
export function makeEmptyWeek(week_start: Date): WeekDto {
const make_empty_shifts = (offset: number): DetailedShifts => ({
shifts: [],
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
comment: '',
short_date: shortDate(addDays(week_start, offset)),
break_durations: 0,
});
return {
is_approved: true,
shifts: {
sun: make_empty_shifts(0),
mon: make_empty_shifts(1),
tue: make_empty_shifts(2),
wed: make_empty_shifts(3),
thu: make_empty_shifts(4),
fri: make_empty_shifts(5),
sat: make_empty_shifts(6),
},
expenses: {
sun: makeEmptyDayExpenses(),
mon: makeEmptyDayExpenses(),
tue: makeEmptyDayExpenses(),
wed: makeEmptyDayExpenses(),
thu: makeEmptyDayExpenses(),
fri: makeEmptyDayExpenses(),
sat: makeEmptyDayExpenses(),
},
};
} }
export function makeEmptyPeriod(): TimesheetPeriodDto { export const toHHmm = (date: Date) => date.toISOString().slice(11, 16);
return { weeks: [ makeEmptyWeek(new Date()), makeEmptyWeek(new Date())], employee_full_name: " " };
//create shifts within timesheet's week - employee overview functions
export function parseISODate(iso: string): Date {
const [ y, m, d ] = iso.split('-').map(Number);
return new Date(y, (m ?? 1) - 1, d ?? 1);
} }
export function buildWeek( export function parseHHmm(t: string): Date {
week_start: Date, const [ hh, mm ] = t.split(':').map(Number);
week_end: Date, return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0);
shifts: ShiftRow[],
expenses: ExpenseRow[],
): WeekDto {
const week = makeEmptyWeek(week_start);
let all_approved = true;
//breaks
const day_times: Record<DayKey, Array<{ start: Date; end: Date }>> = DAY_KEYS.reduce((acc, key) => {
acc[key] = []; return acc;
}, {} as Record<DayKey, Array<{ start: Date; end: Date}>>);
//shifts's hour by type
type ShiftsHours = {
regular: number;
evening: number;
overtime: number;
emergency: number;
sick: number;
vacation: number;
holiday: number;
};
const make_hours = (): ShiftsHours => ({
regular: 0,
evening: 0,
overtime: 0,
emergency: 0,
sick: 0,
vacation: 0,
holiday: 0
});
const day_hours: Record<DayKey, ShiftsHours> = DAY_KEYS.reduce((acc, key) => {
acc[key] = make_hours(); return acc;
}, {} as Record<DayKey, ShiftsHours>);
//expenses's amount by type
type ExpensesAmount = {
mileage: number;
expense: number;
per_diem: number;
commission: number;
prime_dispo: number
};
const make_amounts = (): ExpensesAmount => ({
mileage: 0,
expense: 0,
per_diem: 0,
commission: 0,
prime_dispo: 0
});
const day_amounts: Record<DayKey, ExpensesAmount> = DAY_KEYS.reduce((acc, key) => {
acc[key] = make_amounts(); return acc;
}, {} as Record<DayKey, ExpensesAmount>);
const dayExpenseRows: Record<DayKey, { km: ExpenseRow[]; cash: ExpenseRow[] }> = DAY_KEYS.reduce((acc, key) => {
acc[key] = {km: [], cash: [] }; return acc;
}, {} as Record<DayKey, { km: ExpenseRow[], cash: ExpenseRow[] }>);
//regroup hours per type of shifts
const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end));
for (const shift of week_shifts) {
const key = dayKeyFromDate(shift.date, true);
week.shifts[key].shifts.push({
date: toDateString(shift.date),
type: shift.type,
start_time: toTimeString(shift.start_time),
end_time: toTimeString(shift.end_time),
comment: shift.comment,
is_approved: shift.is_approved ?? true,
is_remote: shift.is_remote,
} as ShiftDto);
day_times[key].push({ start: shift.start_time, end: shift.end_time});
const duration = Math.max(0, (shift.end_time.getTime() - shift.start_time.getTime())/ MS_PER_HOUR);
const type = (shift.type || '').toUpperCase();
if ( type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration;
else if( type === SHIFT_TYPES.EVENING) day_hours[key].evening += duration;
else if( type === SHIFT_TYPES.EMERGENCY) day_hours[key].emergency += duration;
else if( type === SHIFT_TYPES.OVERTIME) day_hours[key].overtime += duration;
else if( type === SHIFT_TYPES.SICK) day_hours[key].sick += duration;
else if( type === SHIFT_TYPES.VACATION) day_hours[key].vacation += duration;
else if( type === SHIFT_TYPES.HOLIDAY) day_hours[key].holiday += duration;
all_approved = all_approved && (shift.is_approved ?? true );
}
//regroupe amounts to type of expenses
const week_expenses = expenses.filter(expense => isBetweenUTC(expense.date, week_start, week_end));
for (const expense of week_expenses) {
const key = dayKeyFromDate(expense.date, true);
const type = (expense.type || '').toUpperCase();
if (type === EXPENSE_TYPES.MILEAGE) {
day_amounts[key].mileage += expense.amount;
dayExpenseRows[key].km.push(expense);
} else if (type === EXPENSE_TYPES.EXPENSE) {
day_amounts[key].expense += expense.amount;
dayExpenseRows[key].cash.push(expense)
} else if (type === EXPENSE_TYPES.PER_DIEM) {
day_amounts[key].per_diem += expense.amount;
dayExpenseRows[key].cash.push(expense)
} else if (type === EXPENSE_TYPES.COMMISSION) {
day_amounts[key].commission += expense.amount;
dayExpenseRows[key].cash.push(expense)
} else if (type === EXPENSE_TYPES.PRIME_DISPO) {
day_amounts[key].prime_dispo += expense.amount;
dayExpenseRows[key].cash.push(expense)
}
all_approved = all_approved && (expense.is_approved ?? true );
}
for (const key of DAY_KEYS) {
//return exposed dto data
week.shifts[key].regular_hours = round2(day_hours[key].regular);
week.shifts[key].evening_hours = round2(day_hours[key].evening);
week.shifts[key].overtime_hours = round2(day_hours[key].overtime);
week.shifts[key].emergency_hours = round2(day_hours[key].emergency);
//calculate gaps between shifts
const times = day_times[key].sort((a,b) => a.start.getTime() - b.start.getTime());
let gaps = 0;
for (let i = 1; i < times.length; i++) {
const gap = (times[i].start.getTime() - times[i - 1].end.getTime()) / MS_PER_HOUR;
if(gap > 0) gaps += gap;
}
week.shifts[key].break_durations = round2(gaps);
//daily totals
const totals = day_amounts[key];
const total_mileage = totals.mileage;
const total_expense = totals.expense + totals.per_diem + totals.commission + totals.prime_dispo;
//pushing mileage rows
for(const row of dayExpenseRows[key].km) {
week.expenses[key].km.push({
amount: round2(row.amount),
comment: row.comment,
supervisor_comment: row.supervisor_comment,
total_mileage: round2(total_mileage),
total_expense: round2(total_expense),
is_approved: row.is_approved ?? true,
});
}
//pushing expense rows
for(const row of dayExpenseRows[key].cash) {
week.expenses[key].cash.push({
amount: round2(row.amount),
comment: row.comment,
supervisor_comment: row.supervisor_comment,
total_mileage: round2(total_mileage),
total_expense: round2(total_expense),
is_approved: row.is_approved ?? true,
});
}
}
week.is_approved = all_approved;
return week;
} }
export function buildPeriod(
period_start: Date,
period_end: Date,
shifts: ShiftRow[],
expenses: ExpenseRow[],
employee_full_name: string,
): TimesheetPeriodDto {
const week1_start = toUTCDateOnly(period_start);
const week1_end = endOfDayUTC(addDays(week1_start, 6));
const week2_start = toUTCDateOnly(addDays(week1_start, 7));
const week2_end = endOfDayUTC(period_end);
return {
weeks: [
buildWeek(week1_start, week1_end, shifts, expenses),
buildWeek(week2_start, week2_end, shifts, expenses),
],
employee_full_name,
};
}

View File

@ -0,0 +1,151 @@
import { DayKey, DAY_KEYS, EXPENSE_BUCKETS, EXPENSE_TYPES, ExpenseBucketKey, ExpenseRow, MS_PER_HOUR, SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount } from "../types/timesheet.types";
import { isBetweenUTC, dayKeyFromDate, toTimeString, round2, toUTCDateOnly, endOfDayUTC, addDays } from "./timesheet.helpers";
import { WeekDto, ShiftDto, ExpenseDto, TimesheetPeriodDto } from "../dtos/timesheet-period.dto";
import { makeAmounts, makeEmptyWeek } from "../mappers/timesheet.mappers";
import { toDateString } from "src/modules/pay-periods/utils/pay-year.util";
export function buildWeek(
week_start: Date,
week_end: Date,
shifts: ShiftRow[],
expenses: ExpenseRow[],
): WeekDto {
const week = makeEmptyWeek(week_start);
let all_approved = true;
const day_times: Record<DayKey, Array<{ start: Date; end: Date }>> = DAY_KEYS.reduce((acc, key) => {
acc[key] = []; return acc;
}, {} as Record<DayKey, Array<{ start: Date; end: Date}>>);
const day_hours: Record<DayKey, ShiftsHours> = DAY_KEYS.reduce((acc, key) => {
acc[key] = make_hours(); return acc;
}, {} as Record<DayKey, ShiftsHours>);
const day_amounts: Record<DayKey, ExpensesAmount> = DAY_KEYS.reduce((acc, key) => {
acc[key] = makeAmounts(); return acc;
}, {} as Record<DayKey, ExpensesAmount>);
const day_expense_rows: Record<DayKey, Record<ExpenseBucketKey, ExpenseRow[]>> = DAY_KEYS.reduce((acc, key) => {
acc[key] = {
expense: [],
mileage: [],
};
return acc;
}, {} as Record<DayKey, Record<ExpenseBucketKey, ExpenseRow[]>>);
//regroup hours per type of shifts
const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end));
for (const shift of week_shifts) {
const key = dayKeyFromDate(shift.date, true);
week.shifts[key].shifts.push({
date: toDateString(shift.date),
type: shift.type,
start_time: toTimeString(shift.start_time),
end_time: toTimeString(shift.end_time),
comment: shift.comment,
is_approved: shift.is_approved ?? true,
is_remote: shift.is_remote,
} as ShiftDto);
day_times[key].push({ start: shift.start_time, end: shift.end_time});
const duration = Math.max(0, (shift.end_time.getTime() - shift.start_time.getTime())/ MS_PER_HOUR);
const type = (shift.type || '').toUpperCase();
if ( type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration;
else if( type === SHIFT_TYPES.EVENING) day_hours[key].evening += duration;
else if( type === SHIFT_TYPES.EMERGENCY) day_hours[key].emergency += duration;
else if( type === SHIFT_TYPES.OVERTIME) day_hours[key].overtime += duration;
else if( type === SHIFT_TYPES.SICK) day_hours[key].sick += duration;
else if( type === SHIFT_TYPES.VACATION) day_hours[key].vacation += duration;
else if( type === SHIFT_TYPES.HOLIDAY) day_hours[key].holiday += duration;
all_approved = all_approved && (shift.is_approved ?? true );
}
//regroupe amounts to type of expenses
const week_expenses = expenses.filter(expense => isBetweenUTC(expense.date, week_start, week_end));
for (const expense of week_expenses) {
const key = dayKeyFromDate(expense.date, true);
const type = (expense.type || '').toUpperCase();
let bucket: ExpenseBucketKey;
if(type === EXPENSE_TYPES.MILEAGE) {
bucket = 'mileage';
day_amounts[key].mileage += expense.mileage ?? 0;
} else {
bucket = 'expense';
day_amounts[key].expense += expense.amount;
}
day_expense_rows[key][bucket].push(expense);
all_approved = all_approved && (expense.is_approved ?? true );
}
for (const key of DAY_KEYS) {
//return exposed dto data
week.shifts[key].regular_hours = round2(day_hours[key].regular);
week.shifts[key].evening_hours = round2(day_hours[key].evening);
week.shifts[key].overtime_hours = round2(day_hours[key].overtime);
week.shifts[key].emergency_hours = round2(day_hours[key].emergency);
//calculate gaps between shifts
const times = day_times[key].sort((a,b) => a.start.getTime() - b.start.getTime());
let gaps = 0;
for (let i = 1; i < times.length; i++) {
const gap = (times[i].start.getTime() - times[i - 1].end.getTime()) / MS_PER_HOUR;
if(gap > 0) gaps += gap;
}
week.shifts[key].break_durations = round2(gaps);
//daily totals
const totals = day_amounts[key];
const total_mileage = round2(totals.mileage);
const total_expense = round2(totals.expense);
const target_buckets = week.expenses[key] as Record<ExpenseBucketKey, ExpenseDto[]>;
const source_buckets = day_expense_rows[key];
for (const bucket of EXPENSE_BUCKETS) {
for (const row of source_buckets[bucket]) {
target_buckets[bucket].push({
type: (row.type || '').toUpperCase(),
amount: round2(row.amount),
mileage: round2(row.mileage ?? 0),
comment: row.comment,
is_approved: row.is_approved ?? true,
supervisor_comment: row.supervisor_comment,
total_mileage,
total_expense,
});
}
}
}
week.is_approved = all_approved;
return week;
}
export function buildPeriod(
period_start: Date,
period_end: Date,
shifts: ShiftRow[],
expenses: ExpenseRow[],
employeeFullName = ''
): TimesheetPeriodDto {
const week1_start = toUTCDateOnly(period_start);
const week1_end = endOfDayUTC(addDays(week1_start, 6));
const week2_start = toUTCDateOnly(addDays(week1_start, 7));
const week2_end = endOfDayUTC(period_end);
const weeks: WeekDto[] = [
buildWeek(week1_start, week1_end, shifts, expenses),
buildWeek(week2_start, week2_end, shifts, expenses),
];
return {
weeks,
employee_full_name: employeeFullName,
};
}