refactor(timesheets): deep refactor of the timesheet module and small corrections of the shift module.

This commit is contained in:
Matthieu Haineault 2025-10-21 15:59:33 -04:00
parent b7ad300a6e
commit 11f6cf2049
13 changed files with 254 additions and 89 deletions

View File

@ -1,5 +1,5 @@
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query } from "@nestjs/common";
import { CreateResult, DeleteResult, ShiftsUpsertService, ShiftWithOvertimeDto, UpdateResult } from "../services/shifts-upsert.service";
import { CreateResult, ShiftsUpsertService, UpdateResult } from "../services/shifts-upsert.service";
import { updateShiftDto } from "../dtos/update-shift.dto";
import { ShiftDto } from "../dtos/shift.dto";
import { ShiftsGetService } from "../services/shifts-get.service";
@ -12,10 +12,10 @@ export class ShiftController {
private readonly get_service: ShiftsGetService
){}
@Get("shifts")
@Get()
async getShiftsByIds(
@Query("shift_ids") shift_ids: string) {
const parsed = shift_ids.split(", ").map(value => Number(value)).filter(Number.isFinite);
const parsed = shift_ids.split(/,\s*/).map(value => Number(value)).filter(Number.isFinite);
return this.get_service.getShiftByShiftId(parsed);
}
@ -42,15 +42,8 @@ export class ShiftController {
}
@Delete(':shift_id')
removeBatch(
@Body() body: { ids: number[] }): Promise<DeleteResult[]> {
const ids = Array.isArray(body?.ids)
? body.ids.filter((value) => Number.isFinite(value))
: [];
if( ids.length === 0) {
throw new BadRequestException('Body is missing or invalid (delete shifts)');
}
return this.upsert_service.deleteShifts(ids);
remove(@Param('shift_id') shift_id: number ) {
return this.upsert_service.deleteShift(shift_id);
}
}

View File

@ -39,11 +39,6 @@ export class ShiftsUpsertService {
//checks for overlaping shifts
//create new shifts
//calculate overtime
async createShift(timesheet_id: number, dto: ShiftDto): Promise<ShiftWithOvertimeDto> {
const [result] = await this.createShifts(timesheet_id, [dto]);
if (!result.ok) throw result.error;
return result.data;
}
async createShifts(timesheet_id: number, dtos: ShiftDto[]): Promise<CreateResult[]> {
if (!Array.isArray(dtos) || dtos.length === 0) return [];
@ -179,13 +174,6 @@ export class ShiftsUpsertService {
// update shifts in DB
// recalculate overtime after update
// return an updated version to display
async updateShift(shift_id: number, dto: updateShiftDto): Promise<ShiftWithOvertimeDto> {
const [results] = await this.updateShifts([{ id: shift_id, dto }]);
if (!results.ok) throw results.error;
return results.data;
}
async updateShifts(updates: UpdatePayload[]): Promise<UpdateResult[]> {
if (!Array.isArray(updates) || updates.length === 0) return [];
@ -345,43 +333,24 @@ export class ShiftsUpsertService {
//_________________________________________________________________
// DELETE
//_________________________________________________________________
//finds shifts using shit_ids
//finds shift using shit_ids
//recalc overtime shifts after delete
//blocs deletion if approved
async deleteShift(shift_id: number) {
const [result] = await this.deleteShifts([shift_id]);
if (!result.ok) throw result.error;
return { success: true, overtime: result.overtime };
}
async deleteShifts(shift_ids: number[]): Promise<DeleteResult[]> {
if (!Array.isArray(shift_ids) || shift_ids.length === 0) return [];
return this.prisma.$transaction(async (tx) => {
const rows = await tx.shifts.findMany({
where: { id: { in: shift_ids } },
select: { id: true, date: true, timesheet_id: true, is_approved: true },
async deleteShift(shift_id: number) {
return await this.prisma.$transaction(async (tx) =>{
const shift = await tx.shifts.findUnique({
where: { id: shift_id },
select: { id: true, date: true, timesheet_id: true },
});
const byId = new Map(rows.map(row => [row.id, row]));
if(!shift) throw new NotFoundException(`Shift with id #${shift_id} not found`);
for (const id of shift_ids) {
const row = byId.get(id);
if (!row) {
return shift_ids.map(existing_shift_id =>
existing_shift_id === id
? ({ ok: false, id: existing_shift_id, error: new NotFoundException(`Shift with id #${existing_shift_id} not found`) } as DeleteResult)
: ({ ok: false, id: existing_shift_id, error: new BadRequestException('Batch aborted due to missing shift') })
);
}
}
await tx.shifts.delete({ where: { id: shift_id } });
const results: DeleteResult[] = [];
for (const id of shift_ids) {
const row = byId.get(id)!;
await tx.shifts.delete({ where: { id } });
const summary = await this.overtime.getWeekOvertimeSummary(row.timesheet_id, row.date, tx);
results.push({ ok: true, id, overtime: summary });
}
return results;
const summary = await this.overtime.getWeekOvertimeSummary( shift.timesheet_id, shift.date, tx);
return {
success: true,
overtime: summary
};
});
}

View File

@ -1,8 +1,16 @@
import { Controller} from "@nestjs/common";
import { GetTimesheetsOverviewService } from "../services/timesheet-get-overview.service";
import { Controller, Get, Query} from "@nestjs/common";
@Controller('timesheet')
@Controller('timesheets')
export class TimesheetController {
constructor(){}
constructor(private readonly timesheetOverview: GetTimesheetsOverviewService){}
@Get()
async getTimesheetByIds(
@Query('timesheet_ids') timesheet_ids: string ) {
const parsed = timesheet_ids.split(/,\s*/).map(value => Number(value)).filter(Number.isFinite);
return this.timesheetOverview.getTimesheetsByIds(parsed);
}

View File

@ -1,8 +1,3 @@
export class Timesheets {
employee_fullname: string;
timesheets: Timesheet[];
@ -35,7 +30,7 @@ export class TotalHours {
}
export class TotalExpenses {
expenses: number;
perd_diem: number;
per_diem: number;
on_call: number;
mileage: number;
}

View File

@ -0,0 +1,26 @@
export const toDateFromString = ( date: Date | string):Date => {
const d = new Date(date);
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
export const sevenDaysFrom = (date: Date | string): Date[] => {
return Array.from({length: 7 }, (_,i) => {
const d = new Date(date);
d.setUTCDate(d.getUTCDate() + i );
return d;
});
}
export const toStringFromDate = (date: Date | string): string => {
const d = toDateFromString(date);
const year = d.getUTCFullYear();
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${d}`;
}
export const toHHmmFromDate = (input: Date | string): string => {
const date = new Date(input);
const hh = String(date.getUTCHours()).padStart(2, '0');
const mm = String(date.getUTCMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}

View File

@ -1,10 +1,192 @@
import { Injectable } from "@nestjs/common";
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { sevenDaysFrom, toDateFromString, toHHmmFromDate, toStringFromDate } from "../helpers/timesheets-date-time-helpers";
type TotalHours = {
regular: number;
evening: number;
emergency: number;
overtime: number;
vacation: number;
holiday: number;
sick: number;
};
type TotalExpenses = {
expenses: number;
per_diem: number;
on_call: number;
mileage: number;
};
@Injectable()
export class GetTimesheetsOverviewService {
constructor(private readonly prisma: PrismaService){}
constructor(private readonly prisma: PrismaService) { }
async getTimesheetsByIds(timesheet_ids: number[]) {
if (!Array.isArray(timesheet_ids) || timesheet_ids.length === 0) throw new NotFoundException(`Timesheet_ids are missing`);
//fetch all needed data using timesheet ids
const rows = await this.prisma.timesheets.findMany({
where: { id: { in: timesheet_ids } },
include: {
employee: { include: { user: true } },
shift: { include: { bank_code: true } },
expense: { include: { bank_code: true, attachment_record: true } },
},
orderBy: { start_date: 'asc' },
});
if (rows.length === 0) throw new NotFoundException('Timesheet(s) not found');
//build full name
const user = rows[0].employee.user;
const employee_fullname = `${user.first_name} ${user.last_name}`.trim();
const timesheets = rows.map((timesheet) => this.mapOneTimesheet(timesheet));
return { employee_fullname, timesheets };
}
}
//-----------------------------------------------------------------------------------
// MAPPERS & HELPERS
//-----------------------------------------------------------------------------------
private mapOneTimesheet(timesheet: any) {
//converts string to UTC date format
const start = toDateFromString(timesheet.start_date);
const day_dates = sevenDaysFrom(start);
//map of shifts by days
const shifts_by_date = new Map<string, any[]>();
for (const shift of timesheet.shift) {
const date = toStringFromDate(shift.date);
const arr = shifts_by_date.get(date) ?? [];
arr.push(shift);
shifts_by_date.set(date, arr);
}
//map of expenses by days
const expenses_by_date = new Map<string, any[]>();
for (const expense of timesheet.expense) {
const date = toStringFromDate(expense.date);
const arr = expenses_by_date.get(date) ?? [];
arr.push(expense);
expenses_by_date.set(date, arr);
}
//weekly totals
const weekly_hours: TotalHours[] = [emptyHours()];
const weekly_expenses: TotalExpenses[] = [emptyExpenses()];
//map of days
const days = day_dates.map((date) => {
const date_iso = toStringFromDate(date);
const shifts_source = shifts_by_date.get(date_iso) ?? [];
const expenses_source = expenses_by_date.get(date_iso) ?? [];
//inner map of shifts
const shifts = shifts_source.map((shift) => ({
date: toStringFromDate(shift.date),
start_time: toHHmmFromDate(shift.start_time),
end_time: toHHmmFromDate(shift.end_time),
type: shift.bank_code?.type ?? '',
is_remote: shift.is_remote ?? false,
is_approved: shift.is_approved ?? false,
shift_id: shift.id ?? null,
comment: shift.comment ?? null,
}));
//inner map of expenses
const expenses = expenses_source.map((expense) => ({
date: toStringFromDate(expense.date),
amount: expense.amount ? Number(expense.amount) : undefined,
mileage: expense.mileage ? Number(expense.mileage) : undefined,
expense_id: expense.id ?? null,
attachment: expense.attachment_record ? String(expense.attachment_record.id) : undefined,
is_approved: expense.is_approved ?? false,
comment: expense.comment ?? '',
supervisor_comment: expense.supervisor_comment,
}));
//daily totals
const daily_hours = [emptyHours()];
const daily_expenses = [emptyExpenses()];
//totals by shift types
for (const shift of shifts_source) {
const hours = diffOfHours(shift.start_time, shift.end_time);
const subgroup = hoursSubGroupFromBankCode(shift.bank_code);
daily_hours[0][subgroup] += hours;
weekly_hours[0][subgroup] += hours;
}
//totals by expense types
for (const expense of expenses_source) {
const subgroup = expenseSubgroupFromBankCode(expense.bank_code);
if (subgroup === 'mileage') {
const mileage = num(expense.mileage);
daily_expenses[0].mileage += mileage;
weekly_expenses[0].mileage += mileage;
} else if (subgroup === 'per_diem') {
const amount = num(expense.amount);
daily_expenses[0].per_diem += amount;
weekly_expenses[0].per_diem += amount;
} else if (subgroup === 'on_call') {
const amount = num(expense.amount);
daily_expenses[0].on_call += amount;
weekly_expenses[0].on_call += amount;
} else {
const amount = num(expense.amount);
daily_expenses[0].expenses += amount;
weekly_expenses[0].expenses += amount;
}
}
return {
date: date_iso,
shifts,
expenses,
daily_hours,
daily_expenses,
};
});
return {
timesheet_id: timesheet.id,
is_approved: timesheet.is_approved ?? false,
days,
weekly_hours,
weekly_expenses,
};
}
}
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 };
}
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);
}
const num = (value: any): number => {
return value ? Number(value) : 0;
}
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'
}
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,32 +1,24 @@
import { TimesheetsController } from './~misc_deprecated-files/timesheets.controller';
import { TimesheetsQueryService } from './~misc_deprecated-files/timesheets-query.service';
import { TimesheetArchiveService } from './services/timesheet-archive.service';
import { TimesheetsCommandService } from './~misc_deprecated-files/timesheets-command.service';
import { ExpensesCommandService } from '../expenses/services/expenses-command.service';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { SharedModule } from '../shared/shared.module';
import { Module } from '@nestjs/common';
import { ShiftsGetService } from '../shifts/services/shifts-get.service';
import { TimesheetSelectorsService } from './~misc_deprecated-files/utils-helpers-others/timesheet.selectors';
import { GetTimesheetsOverviewService } from './services/timesheet-get-overview.service';
import { TimesheetArchiveService } from './services/timesheet-archive.service';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { TimesheetController } from './controllers/timesheet.controller';
import { SharedModule } from '../shared/shared.module';
import { ShiftsModule } from '../shifts/shifts.module';
import { Module } from '@nestjs/common';
@Module({
imports: [
BusinessLogicsModule,
SharedModule,
ShiftsGetService,
ShiftsModule,
],
controllers: [TimesheetsController],
controllers: [TimesheetController],
providers: [
TimesheetsQueryService,
TimesheetsCommandService,
ExpensesCommandService,
TimesheetArchiveService,
TimesheetSelectorsService,
TimesheetArchiveService,
GetTimesheetsOverviewService,
],
exports: [
TimesheetsQueryService,
TimesheetArchiveService,
TimesheetsCommandService
],
})
export class TimesheetsModule {}

View File

@ -5,8 +5,8 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { TimesheetsCommandService } from './timesheets-command.service';
import { TimesheetMap } from './utils-helpers-others/timesheet.types';
import { TimesheetPeriodDto } from './timesheet-period.dto';
import { TimesheetMap } from './timesheet.types';
@ApiTags('Timesheets')