Merge branch 'main' of git.targo.ca:Targo/targo_backend

This commit is contained in:
Nicolas Drolet 2025-10-24 14:23:35 -04:00
commit 4b39240606
9 changed files with 199 additions and 45 deletions

View File

@ -1,10 +1,21 @@
import { Controller } from "@nestjs/common"; import { Body, Controller, Param, ParseIntPipe, Post } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ExpenseDto } from "../dtos/expense.dto";
import { CreateResult, ExpenseUpsertService } from "../services/expense-upsert.service";
@Controller('expense') @Controller('expense')
export class ExpenseController { export class ExpenseController {
constructor(private readonly prisma: PrismaService){} constructor(
private readonly prisma: PrismaService,
private readonly upsert_service: ExpenseUpsertService,
){}
// @Post(':timesheet_id')
// create(
// @Param('timesheet_id', ParseIntPipe) timesheet_id: number,
// @Body() dto: ExpenseDto): Promise<CreateResult>{
// return this.upsert_service.createExpense(timesheet_id, dto);
// }
} }

View File

@ -0,0 +1,14 @@
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
export class ExpenseDto {
@IsInt() bank_code_id!: number;
@IsInt() timesheet_id!: number;
@IsString() @IsOptional() attachment?: string;
@IsString() date!: string;
@IsInt() @IsOptional() amount?: number;
@IsInt() @IsOptional() mileage?: number;
@IsString() @MaxLength(280) comment!: string;
@IsBoolean() is_approved!: boolean;
@IsString() @MaxLength(280) @IsOptional() supervisor_comment?: string
}

View File

@ -0,0 +1,11 @@
export class GetExpenseDto {
timesheet_id: number;
bank_code_id: number;
attachment?: string;
date: string;
comment: string;
mileage?: number;
amount?: number;
supervisor_comment?: string;
is_approved: boolean;
}

View File

@ -0,0 +1,6 @@
import { OmitType, PartialType } from "@nestjs/swagger";
import { ExpenseDto } from "./expense.dto";
export class updateExpenseDto extends PartialType (
OmitType(ExpenseDto, ['is_approved', 'timesheet_id'] as const)
){}

View File

@ -0,0 +1,3 @@
export const toDateFromString = (ymd: string): Date => {
return new Date(`${ymd}T00:00:00:000Z`);
}

View File

@ -0,0 +1,46 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { GetExpenseDto } from "../dtos/get-expense.dto";
import { updateExpenseDto } from "../dtos/update-expense.dto";
import { ExpenseDto } from "../dtos/expense.dto";
import { toDateFromString } from "../helpers/expenses-date-time-helpers";
type Normalized = { date: Date; comment: string; supervisor_comment: string; };
export type CreateResult = { ok: true; data: GetExpenseDto } | { ok: false; error: any };
export type UpdatePayload = { id: number; dto: updateExpenseDto };
export type UpdateResult = { ok: true; id: number; data: GetExpenseDto } | { ok: false; id: number; error: any };
export type DeleteResult = { ok: true; id: number; } | { ok: false; id: number; error: any };
type NormedOk = { dto: GetExpenseDto; normed: Normalized };
type NormedErr = { error: any };
@Injectable()
export class ExpenseUpsertService {
constructor(private readonly prisma: PrismaService){}
//_________________________________________________________________
// CREATE
//_________________________________________________________________
//normalized frontend data to match DB
async createExpense(timesheet_id: number, dto: ExpenseDto){
const normed_expense = this.normalizeExpenseDto(dto)
}
//_________________________________________________________________
// LOCAL HELPERS
//_________________________________________________________________
private normalizeExpenseDto(dto: ExpenseDto): Normalized {
const date = toDateFromString(dto.date);
const comment = this.truncate280(dto.comment);
const supervisor_comment = this.truncate280(dto.supervisor_comment? dto.supervisor_comment : '');
return { date, comment, supervisor_comment };
}
//makes sure that a string cannot exceed 280 chars
private truncate280 = (input: string): string => {
return input.length > 280 ? input.slice(0, 280) : input;
}
}

View File

@ -333,7 +333,7 @@ export class ShiftsUpsertService {
//_________________________________________________________________ //_________________________________________________________________
// DELETE // DELETE
//_________________________________________________________________ //_________________________________________________________________
//finds shift using shit_ids //finds shifts using shit_ids
//recalc overtime shifts after delete //recalc overtime shifts after delete
//blocs deletion if approved //blocs deletion if approved
async deleteShift(shift_id: number) { async deleteShift(shift_id: number) {

View File

@ -1,3 +1,11 @@
export function weekStartSunday(date_local: Date): Date {
const start = new Date(Date.UTC(date_local.getFullYear(), date_local.getMonth(), date_local.getDate()));
const dow = start.getDay();
start.setDate(start.getDate() - dow);
start.setHours(0, 0, 0, 0);
return start;
}
export const toDateFromString = ( date: Date | string):Date => { export const toDateFromString = ( 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()));
@ -10,6 +18,7 @@ export const sevenDaysFrom = (date: Date | string): Date[] => {
return d; return d;
}); });
} }
export const toStringFromDate = (date: Date | string): string => { export const toStringFromDate = (date: Date | string): string => {
const d = toDateFromString(date); const d = toDateFromString(date);
const year = d.getUTCFullYear(); const year = d.getUTCFullYear();
@ -24,3 +33,4 @@ export const toHHmmFromDate = (input: Date | string): string => {
const mm = String(date.getUTCMinutes()).padStart(2, '0'); const mm = String(date.getUTCMinutes()).padStart(2, '0');
return `${hh}:${mm}`; return `${hh}:${mm}`;
} }

View File

@ -19,32 +19,52 @@ type TotalExpenses = {
mileage: number; mileage: number;
}; };
const NUMBER_OF_TIMESHEETS_TO_RETURN = 2;
@Injectable() @Injectable()
export class GetTimesheetsOverviewService { export class GetTimesheetsOverviewService {
constructor(private readonly prisma: PrismaService) { } constructor(private readonly prisma: PrismaService) { }
//-----------------------------------------------------------------------------------
// GET TIMESHEETS FOR A SELECTED EMPLOYEE
//-----------------------------------------------------------------------------------
async getTimesheetsForEmployeeByPeriod(employee_id: number, pay_year: number, pay_period_no: number) { async getTimesheetsForEmployeeByPeriod(employee_id: number, pay_year: number, pay_period_no: number) {
//find period using year and period_no //find period using year and period_no
const period = await this.prisma.payPeriods.findFirst({ const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no } });
where: { pay_year, pay_period_no },
});
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${pay_period_no} not found`); if (!period) throw new NotFoundException(`Pay period ${pay_year}-${pay_period_no} not found`);
//loads the timesheets related to the fetched pay-period //loads the timesheets related to the fetched pay-period
const rows = await this.loadTimesheets({ const timesheet_range = { employee_id, start_date: { gte: period.period_start, lte: period.period_end } };
employee_id, let rows = await this.loadTimesheets(timesheet_range);
start_date: { gte: period.period_start, lte: period.period_end },
}); //Normalized dates from pay-period
const normalized_start = toDateFromString(period.period_start);
const normalized_end = toDateFromString(period.period_end);
//creates empty timesheet to make sure to return desired amount of timesheet
for (let i = 0; i < NUMBER_OF_TIMESHEETS_TO_RETURN; i++) {
const week_start = new Date(normalized_start);
week_start.setUTCDate(week_start.getUTCDate() + i * 7);
if (week_start.getTime() > normalized_end.getTime()) break;
const exists = rows.some(
(row) => toDateFromString(row.start_date).getTime() === week_start.getTime()
);
if (!exists) await this.ensureTimesheet(employee_id, week_start);
}
rows = await this.loadTimesheets(timesheet_range);
//find user infos using the employee_id //find user infos using the employee_id
const employee = await this.prisma.employees.findUnique({ const employee = await this.prisma.employees.findUnique({
where: { id: employee_id }, where: { id: employee_id },
include: { user: true }, include: { user: true },
}); });
if (!employee) throw new NotFoundException(`Employee #${employee_id} not found`); if (!employee) throw new NotFoundException(`Employee #${employee_id} not found`);
//builds employee full name //builds employee full name
const user = employee.user; const user = employee.user;
const employee_fullname = `${user.first_name} ${user.last_name}`.trim(); const employee_fullname = `${user.first_name} ${user.last_name}`.trim();
//maps all timesheet's infos //maps all timesheet's infos
@ -54,16 +74,17 @@ export class GetTimesheetsOverviewService {
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
// MAPPERS & HELPERS // MAPPERS & HELPERS
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
//fetch timesheet's infos //fetch timesheet's infos
private async loadTimesheets(where: any) { private async loadTimesheets(where: any) {
return this.prisma.timesheets.findMany({ return this.prisma.timesheets.findMany({
where, where,
include: { include: {
employee: { include: { user: true } }, employee: { include: { user: true } },
shift: { include: { bank_code: true } }, shift: { include: { bank_code: true } },
expense: { include: { bank_code: true, attachment_record: true } }, expense: { include: { bank_code: true, attachment_record: true } },
}, },
orderBy: { start_date: 'asc' }, orderBy: { start_date: 'asc' },
}); });
@ -71,14 +92,14 @@ export class GetTimesheetsOverviewService {
private mapOneTimesheet(timesheet: any) { private mapOneTimesheet(timesheet: any) {
//converts string to UTC date format //converts string to UTC date format
const start = toDateFromString(timesheet.start_date); const start = toDateFromString(timesheet.start_date);
const day_dates = sevenDaysFrom(start); const day_dates = sevenDaysFrom(start);
//map of shifts by days //map of shifts by days
const shifts_by_date = new Map<string, any[]>(); const shifts_by_date = new Map<string, any[]>();
for (const shift of timesheet.shift) { for (const shift of timesheet.shift) {
const date = toStringFromDate(shift.date); const date = toStringFromDate(shift.date);
const arr = shifts_by_date.get(date) ?? []; const arr = shifts_by_date.get(date) ?? [];
arr.push(shift); arr.push(shift);
shifts_by_date.set(date, arr); shifts_by_date.set(date, arr);
} }
@ -86,40 +107,40 @@ export class GetTimesheetsOverviewService {
const expenses_by_date = new Map<string, any[]>(); const expenses_by_date = new Map<string, any[]>();
for (const expense of timesheet.expense) { for (const expense of timesheet.expense) {
const date = toStringFromDate(expense.date); const date = toStringFromDate(expense.date);
const arr = expenses_by_date.get(date) ?? []; const arr = expenses_by_date.get(date) ?? [];
arr.push(expense); arr.push(expense);
expenses_by_date.set(date, arr); expenses_by_date.set(date, arr);
} }
//weekly totals //weekly totals
const weekly_hours: TotalHours[] = [emptyHours()]; const weekly_hours: TotalHours[] = [emptyHours()];
const weekly_expenses: TotalExpenses[] = [emptyExpenses()]; const weekly_expenses: TotalExpenses[] = [emptyExpenses()];
//map of days //map of days
const days = day_dates.map((date) => { const days = day_dates.map((date) => {
const date_iso = toStringFromDate(date); const date_iso = toStringFromDate(date);
const shifts_source = shifts_by_date.get(date_iso) ?? []; const shifts_source = shifts_by_date.get(date_iso) ?? [];
const expenses_source = expenses_by_date.get(date_iso) ?? []; const expenses_source = expenses_by_date.get(date_iso) ?? [];
//inner map of shifts //inner map of shifts
const shifts = shifts_source.map((shift) => ({ const shifts = shifts_source.map((shift) => ({
date: toStringFromDate(shift.date), date: toStringFromDate(shift.date),
start_time: toHHmmFromDate(shift.start_time), start_time: toHHmmFromDate(shift.start_time),
end_time: toHHmmFromDate(shift.end_time), end_time: toHHmmFromDate(shift.end_time),
type: shift.bank_code?.type ?? '', type: shift.bank_code?.type ?? '',
is_remote: shift.is_remote ?? false, is_remote: shift.is_remote ?? false,
is_approved: shift.is_approved ?? false, is_approved: shift.is_approved ?? false,
shift_id: shift.id ?? null, shift_id: shift.id ?? null,
comment: shift.comment ?? null, comment: shift.comment ?? null,
})); }));
//inner map of expenses //inner map of expenses
const expenses = expenses_source.map((expense) => ({ const expenses = expenses_source.map((expense) => ({
date: toStringFromDate(expense.date), date: toStringFromDate(expense.date),
amount: expense.amount ? Number(expense.amount) : undefined, amount: expense.amount ? Number(expense.amount) : undefined,
mileage: expense.mileage ? Number(expense.mileage) : undefined, mileage: expense.mileage ? Number(expense.mileage) : undefined,
expense_id: expense.id ?? null, expense_id: expense.id ?? null,
attachment: expense.attachment_record ? String(expense.attachment_record.id) : undefined, attachment: expense.attachment_record ? String(expense.attachment_record.id) : undefined,
is_approved: expense.is_approved ?? false, is_approved: expense.is_approved ?? false,
comment: expense.comment ?? '', comment: expense.comment ?? '',
supervisor_comment: expense.supervisor_comment, supervisor_comment: expense.supervisor_comment,
})); }));
@ -131,7 +152,7 @@ export class GetTimesheetsOverviewService {
for (const shift of shifts_source) { for (const shift of shifts_source) {
const hours = diffOfHours(shift.start_time, shift.end_time); const hours = diffOfHours(shift.start_time, shift.end_time);
const subgroup = hoursSubGroupFromBankCode(shift.bank_code); const subgroup = hoursSubGroupFromBankCode(shift.bank_code);
daily_hours[0][subgroup] += hours; daily_hours[0][subgroup] += hours;
weekly_hours[0][subgroup] += hours; weekly_hours[0][subgroup] += hours;
} }
@ -166,12 +187,44 @@ export class GetTimesheetsOverviewService {
}); });
return { return {
timesheet_id: timesheet.id, timesheet_id: timesheet.id,
is_approved: timesheet.is_approved ?? false, is_approved: timesheet.is_approved ?? false,
days, days,
weekly_hours, weekly_hours,
weekly_expenses, 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 //filled array with default values
@ -190,20 +243,20 @@ const num = (value: any): number => { return value ? Number(value) : 0 };
// shift's subgroup types // shift's subgroup types
const hoursSubGroupFromBankCode = (bank_code: any): keyof TotalHours => { const hoursSubGroupFromBankCode = (bank_code: any): keyof TotalHours => {
const type = bank_code.type; const type = bank_code.type;
if (type.includes('EVENING')) return 'evening'; if (type.includes('EVENING')) return 'evening';
if (type.includes('EMERGENCY')) return 'emergency'; if (type.includes('EMERGENCY')) return 'emergency';
if (type.includes('OVERTIME')) return 'overtime'; if (type.includes('OVERTIME')) return 'overtime';
if (type.includes('VACATION')) return 'vacation'; if (type.includes('VACATION')) return 'vacation';
if (type.includes('HOLIDAY')) return 'holiday'; if (type.includes('HOLIDAY')) return 'holiday';
if (type.includes('SICK')) return 'sick'; if (type.includes('SICK')) return 'sick';
return 'regular' return 'regular'
} }
// expense's subgroup types // expense's subgroup types
const expenseSubgroupFromBankCode = (bank_code: any): keyof TotalExpenses => { const expenseSubgroupFromBankCode = (bank_code: any): keyof TotalExpenses => {
const type = bank_code.type; const type = bank_code.type;
if (type.includes('MILEAGE')) return 'mileage'; if (type.includes('MILEAGE')) return 'mileage';
if (type.includes('PER_DIEM')) return 'per_diem'; if (type.includes('PER_DIEM')) return 'per_diem';
if (type.includes('ON_CALL')) return 'on_call'; if (type.includes('ON_CALL')) return 'on_call';
return 'expenses'; return 'expenses';
} }