From 0516736fa28aa7da4b234c01e4aba9c4f044d4a5 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 29 Aug 2025 08:28:47 -0400 Subject: [PATCH] refactor(timesheet): added shift types and expenses type data --- prisma/mock-seeds-scripts/01-bankCodes.ts | 24 +-- prisma/mock-seeds-scripts/12-expenses.ts | 2 +- .../timesheets/dtos/timesheet-period.dto.ts | 11 +- .../services/timesheets-query.service.ts | 71 +++---- .../timesheets/utils/timesheet.helpers.ts | 195 +++++++++++++----- 5 files changed, 194 insertions(+), 109 deletions(-) diff --git a/prisma/mock-seeds-scripts/01-bankCodes.ts b/prisma/mock-seeds-scripts/01-bankCodes.ts index 14d552f..320db5d 100644 --- a/prisma/mock-seeds-scripts/01-bankCodes.ts +++ b/prisma/mock-seeds-scripts/01-bankCodes.ts @@ -5,18 +5,18 @@ const prisma = new PrismaClient(); async function main() { const presets = [ // type, categorie, modifier, bank_code - ['REGULAR' ,'SHIFT', 1.0 , 'G1'], - ['EVENING' ,'SHIFT', 1.25, 'G43'], - ['Emergency','SHIFT', 2 , 'G48'], - ['HOLIDAY' ,'SHIFT', 2.0 , 'G700'], - - - ['EXPENSES','EXPENSE', 1.0 , 'G517'], - ['MILEAGE' ,'EXPENSE', 0.72, 'G57'], - ['PER_DIEM','EXPENSE', 1.0 , 'G502'], - - ['SICK' ,'LEAVE', 1.0, 'G105'], - ['VACATION' ,'LEAVE', 1.0, 'G305'], + ['REGULAR' ,'SHIFT' , 1.0 , 'G1' ], + ['OVERTIME' ,'SHIFT' , 2 , 'G43' ], + ['EMERGENCY' ,'SHIFT' , 2 , 'G48' ], + ['EVENING' ,'SHIFT' , 1.25, 'G56' ], + ['SICK' ,'SHIFT' , 1.0 , 'G105'], + ['PRIME_DISPO','EXPENSE', 1.0 , 'G202'], + ['COMMISSION' ,'EXPENSE', 1.0 , 'G234'], + ['VACATION' ,'SHIFT' , 1.0 , 'G305'], + ['PER_DIEM' ,'EXPENSE', 1.0 , 'G502'], + ['MILEAGE' ,'EXPENSE', 0.72, 'G503'], + ['EXPENSES' ,'EXPENSE', 1.0 , 'G517'], + ['HOLIDAY' ,'SHIFT' , 2.0 , 'G700'], ]; await prisma.bankCodes.createMany({ diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 1b015c1..177e971 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -76,7 +76,7 @@ async function main() { await prisma.expenses.create({ data: { timesheet_id: ts.id, - bank_code_id: map.get('G57')!, + bank_code_id: map.get('G503')!, date, amount: km.toString(), // on stocke le nombre de km dans amount (si tu as un champ "quantity_km", remplace ici) attachement: null, diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index cd98926..d8c42a6 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -1,13 +1,13 @@ export class ShiftDto { start: string; end : string; - bank_code: string; is_approved: boolean; } export class ExpenseDto { amount: number; - bank_code: string; + total_mileage: number; + total_expense: number; is_approved: boolean; } @@ -15,7 +15,10 @@ export type DayShiftsDto = ShiftDto[]; export class DetailedShifts { shifts: DayShiftsDto; - total_hours: number; + regular_hours: number; + evening_hours: number; + overtime_hours: number; + emergency_hours: number; short_date: string; break_durations?: number; } @@ -23,7 +26,7 @@ export class DetailedShifts { export class DayExpensesDto { cash: ExpenseDto[] = []; km : ExpenseDto[] = []; - [otherType:string]: ExpenseDto[] | any; //pour si on ajoute d'autre type de dépenses + [otherType:string]: ExpenseDto[] | any; } export class WeekDto { diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 59af51f..84ad942 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -46,55 +46,52 @@ export class TimesheetsQueryService { const from = toUTCDateOnly(period.period_start); const to = endOfDayUTC(period.period_end); - //collects data from shifts and expenses - const [ raw_shifts, raw_expenses] = await Promise.all([ - this.prisma.shifts.findMany({ - where: { - timesheet: { is: { employee_id: employee.id } }, - date: { gte: from, lte: to }, - }, - select: { - date: true, - start_time: true, - end_time: true, - is_approved: true, - bank_code: { select: { bank_code: true } }, - }, - orderBy: [{date: 'asc'}, { start_time: 'asc' }], - }), - this.prisma.expenses.findMany({ - where: { - timesheet: { is: { employee_id: employee.id } }, - date: { gte: from, lte: to }, - }, - select: { - date: true, - amount: true, - is_approved: true, - bank_code: { select: { - type: true, - bank_code: true, - } }, - }, - orderBy: { date: 'asc' }, - }), - ]); + const raw_shifts = await this.prisma.shifts.findMany({ + where: { + timesheet: { is: { employee_id: employee.id } }, + date: { gte: from, lte: to }, + }, + select: { + date: true, + start_time: true, + end_time: true, + is_approved: true, + bank_code: { select: { type: true } }, + }, + orderBy:[ { date:'asc'}, { start_time: 'asc'} ], + }); + + const raw_expenses = await this.prisma.expenses.findMany({ + where: { + timesheet: { is: { employee_id: employee.id } }, + date: { gte: from, lte: to }, + }, + select: { + date: true, + amount: true, + is_approved: true, + bank_code: { select: { type: true } }, + }, + orderBy: { date: 'asc' }, + }); + + const to_num = (value: any) => typeof value.toNumber === 'function' ? value.toNumber() : Number(value); + // data mapping const shifts: ShiftRow[] = raw_shifts.map(shift => ({ date: shift.date, start_time: shift.start_time, end_time: shift.end_time, - bank_code: shift.bank_code?.bank_code ?? '', + type: String(shift.bank_code?.type ?? '').toUpperCase(), is_approved: shift.is_approved ?? true, })); const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ date: expense.date, - amount: typeof (expense.amount as any)?.toNumber() === 'function' ? + amount: typeof (expense.amount as any)?.toNumber === 'function' ? (expense.amount as any).toNumber() : Number(expense.amount), - type: expense.bank_code?.type ?? 'CASH', - bank_code: expense.bank_code?.bank_code ?? '', + type: String(expense.bank_code?.type ?? '').toUpperCase(), is_approved: expense.is_approved ?? true, })); diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 29e7a1a..c36a4c4 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -4,18 +4,39 @@ import { DayExpensesDto, DayShiftsDto, DetailedShifts, ShiftDto, TimesheetPeriod export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; export type DayKey = typeof DAY_KEYS[number]; -//DB line types -export type ShiftRow = { date: Date; start_time: Date; end_time: Date; bank_code: string; is_approved?: boolean }; -export type ExpenseRow = { date: Date; amount: number; type: string; bank_code: string; is_approved?: boolean }; - export function dayKeyFromDate(date: Date, useUTC = true): DayKey { const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday return DAY_KEYS[index]; } +//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; is_approved?: boolean; type: string }; +export type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean }; + +//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())); @@ -45,22 +66,25 @@ export function round2(num: number) { return Math.round(num * 100) / 100; } + 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}`; } -// export function makeEmptyDayShifts(): DayShiftsDto { return []; } - +// Factories export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; } export function makeEmptyWeek(week_start: Date): WeekDto { const make_empty_shifts = (offset: number): DetailedShifts => ({ shifts: [], - total_hours: 0, + regular_hours: 0, + evening_hours: 0, + emergency_hours: 0, + overtime_hours: 0, short_date: shortDate(addDays(week_start, offset)), - break_durations: undefined, + break_durations: 0, }); return { is_approved: true, @@ -89,13 +113,6 @@ export function makeEmptyPeriod(): TimesheetPeriodDto { return { week1: makeEmptyWeek(new Date()), week2: makeEmptyWeek(new Date()) }; } -//needs ajusting according to DB's data for expenses types -export function normalizeExpenseBucket(db_type: string): 'km' | 'cash' { - const type = db_type.trim().toUpperCase(); - if(type.includes('KM') || type.includes('MILEAGE')) return 'km'; - return 'cash'; -} - export function buildWeek( week_start: Date, week_end: Date, @@ -105,61 +122,127 @@ export function buildWeek( const week = makeEmptyWeek(week_start); let all_approved = true; - //array of shifts per day ( to check for break_gaps and calculate daily total hours ) - const dayTimes: Record> = { - sun: [], mon: [], tue: [], wed: [],thu: [], fri: [], sat: [], - }; + //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); - //Shifts mapped and filtered by dates + //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); - dayTimes[key].push({start: shift.start_time, end:shift.end_time }); + for (const shift of shifts) { + const key = dayKeyFromDate(shift.date, true); week.shifts[key].shifts.push({ start: toTimeString(shift.start_time), - end : toTimeString(shift.end_time), - bank_code: shift.bank_code, + end: toTimeString(shift.end_time), is_approved: shift.is_approved ?? true, - }); - all_approved = all_approved && (shift.is_approved ?? true); + } 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 ); } - - //Expenses mapped and filtered by dates + + //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 bucket = normalizeExpenseBucket(expense.type); - if (!Array.isArray(week.expenses[key][bucket])) week.expenses[key][bucket] = []; - week.expenses[key][bucket].push({ - amount: round2(expense.amount), - is_approved: expense.is_approved ?? true, - bank_code: expense.bank_code, - }); - all_approved = all_approved && (expense.is_approved ?? true); + 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) { - //sorts shifts in chronological order - const times = dayTimes[key].sort((a,b) => a.start.getTime() - b.start.getTime()); + //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); - //daily total hours - const total = times.reduce((sum, time) => { - const duration = (time.end.getTime() - time.start.getTime()) / MS_PER_HOUR; - return sum + Math.max(0, duration); - }, 0); - week.shifts[key].total_hours = round2(total); + //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); - //break_duration - if (times.length >= 2) { - let break_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) break_gaps += gap; - } - if(break_gaps > 0) week.shifts[key].break_durations = round2(break_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 + totals.expense; + + //pushing mileage rows + for(const row of dayExpenseRows[key].km) { + week.expenses[key].km.push({ + amount: round2(row.amount), + 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), + total_mileage: round2(total_mileage), + total_expense: round2(total_expense), + is_approved: row.is_approved ?? true, + }); } } + week.is_approved = all_approved; return week; } @@ -179,4 +262,6 @@ export function buildPeriod( week1: buildWeek(week1_start, week1_end, shifts, expenses), week2: buildWeek(week2_start, week2_end, shifts, expenses), }; -} \ No newline at end of file +} + +