From 791a95374468a83328bf8aa2e0cb45c54b5d784a Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 7 Oct 2025 09:49:42 -0400 Subject: [PATCH 1/3] fix(timesheets): modified returns to show total expense, total mileage and an array of all expenses per date --- .../expenses/dtos/upsert-expense.dto.ts | 2 +- .../timesheets/dtos/overview-timesheet.dto.ts | 1 + .../timesheets/dtos/timesheet-period.dto.ts | 35 +- .../timesheets/mappers/timesheet.mappers.ts | 56 ++++ .../services/timesheets-command.service.ts | 5 +- .../services/timesheets-query.service.ts | 82 +++-- .../timesheets/types/timesheet.types.ts | 73 +++++ .../timesheets/utils/timesheet.helpers.ts | 302 +----------------- .../timesheets/utils/timesheet.utils.ts | 151 +++++++++ 9 files changed, 367 insertions(+), 340 deletions(-) create mode 100644 src/modules/timesheets/mappers/timesheet.mappers.ts create mode 100644 src/modules/timesheets/types/timesheet.types.ts create mode 100644 src/modules/timesheets/utils/timesheet.utils.ts diff --git a/src/modules/expenses/dtos/upsert-expense.dto.ts b/src/modules/expenses/dtos/upsert-expense.dto.ts index 6ec007e..5bea2c3 100644 --- a/src/modules/expenses/dtos/upsert-expense.dto.ts +++ b/src/modules/expenses/dtos/upsert-expense.dto.ts @@ -6,7 +6,7 @@ import { Matches, MaxLength, Min, - ValidateIf, + ValidateIf, ValidateNested } from "class-validator"; diff --git a/src/modules/timesheets/dtos/overview-timesheet.dto.ts b/src/modules/timesheets/dtos/overview-timesheet.dto.ts index 95aad54..ff86de8 100644 --- a/src/modules/timesheets/dtos/overview-timesheet.dto.ts +++ b/src/modules/timesheets/dtos/overview-timesheet.dto.ts @@ -21,6 +21,7 @@ export class ExpensesDto { bank_type: string; date: string; amount: number; + mileage: number; km: number; comment: string; supervisor_comment: string; diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index cfd0194..6b9275c 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -1,20 +1,22 @@ export class ShiftDto { - date: string; - type: string; - start_time: string; - end_time : string; - comment: string; + date: string; + type: string; + start_time: string; + end_time : string; + comment: string; is_approved: boolean; - is_remote: boolean; + is_remote: boolean; } export class ExpenseDto { - amount: number; - comment: string; - supervisor_comment: string; + type: string; + amount: number; + mileage: number; + comment: string; total_mileage: number; total_expense: number; - is_approved: boolean; + is_approved: boolean; + supervisor_comment: string; } export type DayShiftsDto = ShiftDto[]; @@ -31,9 +33,10 @@ export class DetailedShifts { } export class DayExpensesDto { - cash: ExpenseDto[] = []; - km : ExpenseDto[] = []; - [otherType:string]: ExpenseDto[] | any; + expense: ExpenseDto[] = []; + mileage: ExpenseDto[] = []; + per_diem: ExpenseDto[] = []; + on_call: ExpenseDto[] = []; } export class WeekDto { @@ -59,6 +62,8 @@ export class WeekDto { } export class TimesheetPeriodDto { - week1: WeekDto; - week2: WeekDto; + weeks: WeekDto[]; + employee_full_name: string; } + + diff --git a/src/modules/timesheets/mappers/timesheet.mappers.ts b/src/modules/timesheets/mappers/timesheet.mappers.ts new file mode 100644 index 0000000..f3f3234 --- /dev/null +++ b/src/modules/timesheets/mappers/timesheet.mappers.ts @@ -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, +}); \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index 3f88d40..5564f0d 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -47,8 +47,9 @@ export class TimesheetsCommandService extends BaseApprovalService{ return timesheet; } - - +//_____________________________________________________________________________________________ +// +//_____________________________________________________________________________________________ async createWeekShiftsAndReturnOverview( email:string, diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index f000277..511331f 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -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 { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; import { PrismaService } from 'src/prisma/prisma.service'; import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; import { TimesheetDto } from '../dtos/overview-timesheet.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() @@ -18,15 +19,25 @@ export class TimesheetsQueryService { async findAll(year: number, period_no: number, email: string): Promise { //finds the employee const employee = await this.prisma.employees.findFirst({ - where: { user: { is: { email } } }, - select: { id: true }, + where: { + user: { is: { email } } + }, + select: { + id: true + }, }); if(!employee) throw new NotFoundException(`no employee with email ${email} found`); //finds the period const period = await this.prisma.payPeriods.findFirst({ - where: { pay_year: year, pay_period_no: period_no }, - select: { period_start: true, period_end: true }, + where: { + 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`); @@ -39,12 +50,12 @@ export class TimesheetsQueryService { date: { gte: from, lte: to }, }, select: { - date: true, - start_time: true, - end_time: true, - comment: true, + date: true, + start_time: true, + end_time: true, + comment: true, is_approved: true, - is_remote: true, + is_remote: true, bank_code: { select: { type: true } }, }, orderBy:[ { date:'asc'}, { start_time: 'asc'} ], @@ -56,15 +67,16 @@ export class TimesheetsQueryService { date: { gte: from, lte: to }, }, select: { - date: true, - amount: true, - comment: true, - supervisor_comment: true, + date: true, + amount: true, + mileage: true, + comment: true, is_approved: true, + supervisor_comment: true, bank_code: { select: { type: true } }, }, orderBy: { date: 'asc' }, - }); + }); const toNum = (value: any) => value && typeof value.toNumber === 'function' ? value.toNumber() : @@ -73,22 +85,23 @@ export class TimesheetsQueryService { // data mapping const shifts: ShiftRow[] = raw_shifts.map(shift => ({ - date: shift.date, - start_time: shift.start_time, - end_time: shift.end_time, - comment: shift.comment ?? '', + date: shift.date, + start_time: shift.start_time, + end_time: shift.end_time, + comment: shift.comment ?? '', is_approved: shift.is_approved ?? true, - is_remote: shift.is_remote ?? true, - type: String(shift.bank_code?.type ?? '').toUpperCase(), + is_remote: shift.is_remote ?? true, + type: String(shift.bank_code?.type ?? '').toUpperCase(), })); const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ - date: expense.date, - amount: toNum(expense.amount), - comment: expense.comment ?? '', - supervisor_comment: expense.supervisor_comment ?? '', + type: String(expense.bank_code?.type ?? '').toUpperCase(), + date: expense.date, + amount: toNum(expense.amount), + mileage: toNum(expense.mileage), + comment: expense.comment ?? '', 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); @@ -98,27 +111,27 @@ export class TimesheetsQueryService { //fetch user related to email const user = await this.prisma.users.findUnique({ - where: { email }, + where: { email }, select: { id: true }, }); if(!user) throw new NotFoundException(`user with email ${email} not found`); //fetch employee_id matching the email const employee = await this.prisma.employees.findFirst({ - where: { user_id: user.id }, + where: { user_id: user.id }, select: { id: true }, }); if(!employee) throw new NotFoundException(`Employee with email: ${email} not found`); //sets current week Sunday -> Saturday - const base = new Date(); + const base = new Date(); const offset = new Date(base); offset.setDate(offset.getDate() + (week_offset * 7)); const start_date_week = getWeekStart(offset, 0); const end_date_week = getWeekEnd(start_date_week); - const start_day = formatDateISO(start_date_week); - const end_day = formatDateISO(end_date_week); + const start_day = formatDateISO(start_date_week); + const end_day = formatDateISO(end_date_week); //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')}`; @@ -160,7 +173,7 @@ export class TimesheetsQueryService { //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 ?? '', date: formatDateISO(shift_row.date), start_time: toHHmm(shift_row.start_time), @@ -175,9 +188,10 @@ export class TimesheetsQueryService { bank_type: exp.bank_code?.type ?? '', date: formatDateISO(exp.date), amount: Number(exp.amount) || 0, + mileage: exp.mileage != null ? Number(exp.mileage) : 0, + km: exp.mileage != null ? Number(exp.mileage) : 0, comment: exp.comment ?? '', is_approved: exp.is_approved ?? false, - km: 0, supervisor_comment: exp.supervisor_comment ?? '', })); diff --git a/src/modules/timesheets/types/timesheet.types.ts b/src/modules/timesheets/types/timesheet.types.ts new file mode 100644 index 0000000..e253909 --- /dev/null +++ b/src/modules/timesheets/types/timesheet.types.ts @@ -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; +}; \ No newline at end of file diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 6a3ead1..3ba862d 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -1,71 +1,6 @@ -import { toDateString } from "src/modules/pay-periods/utils/pay-year.util"; -import { DayExpensesDto, DetailedShifts, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto"; +import { MS_PER_DAY, DayKey, DAY_KEYS } from "../types/timesheet.types"; -//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 { const d = new Date(date); return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); @@ -95,236 +30,27 @@ export function round2(num: number) { return Math.round(num * 100) / 100; } - -function shortDate(date:Date): string { +export function shortDate(date:Date): string { const mm = String(date.getUTCMonth()+1).padStart(2,'0'); const dd = String(date.getUTCDate()).padStart(2,'0'); return `${mm}/${dd}`; } -// Factories -export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; } - -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 dayKeyFromDate(date: Date, useUTC = true): DayKey { + const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday + return DAY_KEYS[index]; } -export function makeEmptyPeriod(): TimesheetPeriodDto { - return { week1: makeEmptyWeek(new Date()), week2: makeEmptyWeek(new Date()) }; +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 buildWeek( - week_start: Date, - week_end: Date, - shifts: ShiftRow[], - expenses: ExpenseRow[], - ): WeekDto { - const week = makeEmptyWeek(week_start); - let all_approved = true; - - //breaks - const day_times: Record> = DAY_KEYS.reduce((acc, key) => { - acc[key] = []; return acc; - }, {} as Record>); - - //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 = DAY_KEYS.reduce((acc, key) => { - acc[key] = make_hours(); return acc; - }, {} as Record); - - //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 = DAY_KEYS.reduce((acc, key) => { - acc[key] = make_amounts(); return acc; - }, {} as Record); - - const dayExpenseRows: Record = DAY_KEYS.reduce((acc, key) => { - acc[key] = {km: [], cash: [] }; return acc; - }, {} as Record); - - //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 parseHHmm(t: string): Date { + const [ hh, mm ] = t.split(':').map(Number); + return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0); } -export function buildPeriod( - period_start: Date, - period_end: Date, - shifts: ShiftRow[], - expenses: ExpenseRow[] -): 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 { - week1: buildWeek(week1_start, week1_end, shifts, expenses), - week2: buildWeek(week2_start, week2_end, shifts, expenses), - }; -} - - diff --git a/src/modules/timesheets/utils/timesheet.utils.ts b/src/modules/timesheets/utils/timesheet.utils.ts new file mode 100644 index 0000000..6e22366 --- /dev/null +++ b/src/modules/timesheets/utils/timesheet.utils.ts @@ -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> = DAY_KEYS.reduce((acc, key) => { + acc[key] = []; return acc; + }, {} as Record>); + + const day_hours: Record = DAY_KEYS.reduce((acc, key) => { + acc[key] = make_hours(); return acc; + }, {} as Record); + + const day_amounts: Record = DAY_KEYS.reduce((acc, key) => { + acc[key] = makeAmounts(); return acc; + }, {} as Record); + + const day_expense_rows: Record> = DAY_KEYS.reduce((acc, key) => { + acc[key] = { + expense: [], + mileage: [], + }; + return acc; + }, {} as Record>); + + //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; + 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, + }; +} \ No newline at end of file From 9821b81afdf4c21fe12f5d53b2789f7a176dc5be Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 7 Oct 2025 09:55:44 -0400 Subject: [PATCH 2/3] fizx(archive): fix import services --- .../controllers/employees-archive.controller.ts | 8 ++++---- .../controllers/expenses-archive.controller.ts | 4 ++-- .../controllers/shifts-archive.controller.ts | 4 ++-- .../controllers/timesheets-archive.controller.ts | 4 ++-- src/modules/archival/services/archival.service.ts | 12 ++++++------ 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/modules/archival/controllers/employees-archive.controller.ts b/src/modules/archival/controllers/employees-archive.controller.ts index fa9e911..375a351 100644 --- a/src/modules/archival/controllers/employees-archive.controller.ts +++ b/src/modules/archival/controllers/employees-archive.controller.ts @@ -2,20 +2,20 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; 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') // @UseGuards() @Controller('archives/employees') export class EmployeesArchiveController { - constructor(private readonly employeesService: EmployeesService) {} + constructor(private readonly employeesArchiveService: EmployeesArchivalService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) @ApiOperation({ summary: 'List of archived employees'}) @ApiResponse({ status: 200, description: 'List of archived employees', isArray: true }) async findAllArchived(): Promise { - return this.employeesService.findAllArchived(); + return this.employeesArchiveService.findAllArchived(); } @Get() @@ -24,7 +24,7 @@ export class EmployeesArchiveController { @ApiResponse({ status: 200, description: 'Archived employee found'}) async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise { try{ - return await this.employeesService.findOneArchived(id); + return await this.employeesArchiveService.findOneArchived(id); }catch { throw new NotFoundException(`Archived employee #${id} not found`); } diff --git a/src/modules/archival/controllers/expenses-archive.controller.ts b/src/modules/archival/controllers/expenses-archive.controller.ts index 7c270fe..e6bd935 100644 --- a/src/modules/archival/controllers/expenses-archive.controller.ts +++ b/src/modules/archival/controllers/expenses-archive.controller.ts @@ -2,13 +2,13 @@ import { UseGuards, Controller, Get, Param, ParseIntPipe, NotFoundException } fr import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; import { ExpensesArchive,Roles as RoleEnum } from "@prisma/client"; 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') // @UseGuards() @Controller('archives/expenses') export class ExpensesArchiveController { - constructor(private readonly expensesService: ExpensesQueryService) {} + constructor(private readonly expensesService: ExpensesArchivalService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) diff --git a/src/modules/archival/controllers/shifts-archive.controller.ts b/src/modules/archival/controllers/shifts-archive.controller.ts index fb7204b..e8f92f2 100644 --- a/src/modules/archival/controllers/shifts-archive.controller.ts +++ b/src/modules/archival/controllers/shifts-archive.controller.ts @@ -2,13 +2,13 @@ import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } fr import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ShiftsArchive, Roles as RoleEnum } from "@prisma/client"; 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') // @UseGuards() @Controller('archives/shifts') export class ShiftsArchiveController { - constructor(private readonly shiftsService:ShiftsQueryService) {} + constructor(private readonly shiftsService: ShiftsArchivalService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) diff --git a/src/modules/archival/controllers/timesheets-archive.controller.ts b/src/modules/archival/controllers/timesheets-archive.controller.ts index 0c9d607..7505b66 100644 --- a/src/modules/archival/controllers/timesheets-archive.controller.ts +++ b/src/modules/archival/controllers/timesheets-archive.controller.ts @@ -2,13 +2,13 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; 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') // @UseGuards() @Controller('archives/timesheets') export class TimesheetsArchiveController { - constructor(private readonly timesheetsService: TimesheetsQueryService) {} + constructor(private readonly timesheetsService: TimesheetArchiveService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) diff --git a/src/modules/archival/services/archival.service.ts b/src/modules/archival/services/archival.service.ts index 7dbf567..66be2d0 100644 --- a/src/modules/archival/services/archival.service.ts +++ b/src/modules/archival/services/archival.service.ts @@ -1,17 +1,17 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; -import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; -import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; -import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; +import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service"; +import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service"; +import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service"; @Injectable() export class ArchivalService { private readonly logger = new Logger(ArchivalService.name); constructor( - private readonly timesheetsService: TimesheetsQueryService, - private readonly expensesService: ExpensesQueryService, - private readonly shiftsService: ShiftsQueryService, + private readonly timesheetsService: TimesheetArchiveService, + private readonly expensesService: ExpensesArchivalService, + private readonly shiftsService: ShiftsArchivalService, ) {} @Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00 From c58e8db59f03acc79c841132d6df582e32fe42b2 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Tue, 7 Oct 2025 10:07:47 -0400 Subject: [PATCH 3/3] fix(timesheets: minor fix --- docs/swagger/swagger-spec.json | 190 +++++++----------- .../services/timesheets-query.service.ts | 1 - 2 files changed, 75 insertions(+), 116 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index e8045ac..8170ec6 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -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": { "get": { "operationId": "EmployeesController_findListEmployees", @@ -1127,6 +1087,81 @@ } }, "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": { "type": "object", "properties": { @@ -1204,81 +1239,6 @@ "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": { "type": "object", "properties": {} diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 511331f..edbc830 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -189,7 +189,6 @@ export class TimesheetsQueryService { date: formatDateISO(exp.date), amount: Number(exp.amount) || 0, mileage: exp.mileage != null ? Number(exp.mileage) : 0, - km: exp.mileage != null ? Number(exp.mileage) : 0, comment: exp.comment ?? '', is_approved: exp.is_approved ?? false, supervisor_comment: exp.supervisor_comment ?? '',