feat(timesheet): replaced findAll by getPeriodByQuery with year, period_no and email query params.

This commit is contained in:
Matthieu Haineault 2025-08-25 08:07:48 -04:00
parent 2eadabbdb4
commit 301d5f2c9d
4 changed files with 254 additions and 44 deletions

View File

@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
import { TimesheetsQueryService } from '../services/timesheets-query.service';
import { CreateTimesheetDto } from '../dtos/create-timesheet.dto';
import { Timesheets } from '@prisma/client';
@ -8,6 +8,7 @@ import { Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { TimesheetsCommandService } from '../services/timesheets-command.service';
import { SearchTimesheetDto } from '../dtos/search-timesheet.dto';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
@ApiTags('Timesheets')
@ApiBearerAuth('access-token')
@ -15,8 +16,8 @@ import { SearchTimesheetDto } from '../dtos/search-timesheet.dto';
@Controller('timesheets')
export class TimesheetsController {
constructor(
private readonly timesheetsService: TimesheetsQueryService,
private readonly timesheetsCommandService: TimesheetsCommandService,
private readonly timesheetsQuery: TimesheetsQueryService,
private readonly timesheetsCommand: TimesheetsCommandService,
) {}
@Post()
@ -25,17 +26,18 @@ export class TimesheetsController {
@ApiResponse({ status: 201, description: 'Timesheet created', type: CreateTimesheetDto })
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
create(@Body() dto: CreateTimesheetDto): Promise<Timesheets> {
return this.timesheetsService.create(dto);
return this.timesheetsQuery.create(dto);
}
@Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find all timesheets' })
@ApiResponse({ status: 201, description: 'List of timesheet found', type: CreateTimesheetDto, isArray: true })
@ApiResponse({ status: 400, description: 'List of timesheets not found' })
@UsePipes(new ValidationPipe({transform: true, whitelist: true }))
findAll(@Query() filters: SearchTimesheetDto): Promise<any[]> {
return this.timesheetsService.findAll(filters);
async getPeriodByQuery(
@Query('year', ParseIntPipe ) year: number,
@Query('period_no', ParseIntPipe ) period_no: number,
@Query('email') email?: string
): Promise<TimesheetPeriodDto> {
if(!email || !email.trim()) throw new BadRequestException('Query param "email" is mandatory for this route.');
return this.timesheetsQuery.findAll(year, period_no, email.trim());
}
@Get(':id')
@ -44,7 +46,7 @@ export class TimesheetsController {
@ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto })
@ApiResponse({ status: 400, description: 'Timesheet not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
return this.timesheetsService.findOne(id);
return this.timesheetsQuery.findOne(id);
}
@Patch(':id')
@ -56,7 +58,7 @@ export class TimesheetsController {
@Param('id', ParseIntPipe) id:number,
@Body() dto: UpdateTimesheetDto,
): Promise<Timesheets> {
return this.timesheetsService.update(id, dto);
return this.timesheetsQuery.update(id, dto);
}
@Delete(':id')
@ -65,12 +67,12 @@ export class TimesheetsController {
@ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto })
@ApiResponse({ status: 400, description: 'Timesheet not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
return this.timesheetsService.remove(id);
return this.timesheetsQuery.remove(id);
}
@Patch(':id/approval')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
return this.timesheetsCommandService.updateApproval(id, isApproved);
return this.timesheetsCommand.updateApproval(id, isApproved);
}
}

View File

@ -0,0 +1,45 @@
export class ShiftDto {
start: string;
end : string;
is_approved: boolean;
}
export class ExpenseDto {
amount: number;
is_approved: boolean;
}
export type DayShiftsDto = ShiftDto[];
export class DayExpensesDto {
cash: ExpenseDto[] = [];
km : ExpenseDto[] = [];
[otherType:string]: ExpenseDto[] | any; //pour si on ajoute d'autre type de dépenses
}
export class WeekDto {
is_approved: boolean;
shifts: {
sun: DayShiftsDto;
mon: DayShiftsDto;
tue: DayShiftsDto;
wed: DayShiftsDto;
thu: DayShiftsDto;
fri: DayShiftsDto;
sat: DayShiftsDto;
}
expenses: {
sun: DayExpensesDto;
mon: DayExpensesDto;
tue: DayExpensesDto;
wed: DayExpensesDto;
thu: DayExpensesDto;
fri: DayExpensesDto;
sat: DayExpensesDto;
}
}
export class TimesheetPeriodDto {
week1: WeekDto;
week2: WeekDto;
}

View File

@ -7,8 +7,12 @@ import { OvertimeService } from 'src/modules/business-logics/services/overtime.s
import { computeHours } from 'src/common/utils/date-utils';
import { buildPrismaWhere } from 'src/common/shared/build-prisma-where';
import { SearchTimesheetDto } from '../dtos/search-timesheet.dto';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers';
const ROUND_TO = 5;
// deprecated (used with old findAll) const ROUND_TO = 5;
type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean };
type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean };
@Injectable()
export class TimesheetsQueryService {
@ -30,38 +34,63 @@ export class TimesheetsQueryService {
});
}
async findAll(filters: SearchTimesheetDto): Promise<any[]> {
const where = buildPrismaWhere(filters);
//fetchs lists of shifts and expenses for a selected timesheet
const rawlist = await this.prisma.timesheets.findMany({
where, include: {
shift: { include: {bank_code: true } },
expense: { include: { bank_code: true } },
employee: { include: { user : true } },
},
async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
//finds the employee
const employee = await this.prisma.employees.findFirst({
where: { user: { is: { email } } },
select: { id: true },
});
if(!employee) throw new NotFoundException(`no employee with email ${email} found`);
const detailedlist = await Promise.all(
rawlist.map(async timesheet => {
//detailed shifts
const detailedShifts = timesheet.shift.map(shift => {
const totalhours = computeHours(shift.start_time, shift.end_time, ROUND_TO);
const regularHours = Math.min(8, totalhours);
const dailyOvertime = this.overtime.getDailyOvertimeHours(shift.start_time, shift.end_time);
const payRegular = regularHours * shift.bank_code.modifier;
const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, shift.bank_code.modifier);
return { ...shift, totalhours, payRegular, payOvertime };
});
//calculate overtime weekly
const weeklyOvertimeHours = detailedShifts.length
? await this.overtime.getWeeklyOvertimeHours(
timesheet.employee_id,
timesheet.shift[0].date): 0;
return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours };
//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 },
});
if(!period) throw new NotFoundException(`Period ${year}-${period_no} not found`);
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 },
orderBy: [{date: 'asc'}, { start_time: 'asc' }],
}),
);
return detailedlist;
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' },
}),
]);
//Shift data mapping
const shifts: ShiftRow[] = raw_shifts.map(shift => ({
date: shift.date,
start_time: shift.start_time,
end_time: shift.end_time,
is_approved: shift.is_approved ?? true,
}));
const expenses: ExpenseRow[] = raw_expenses.map(expense => ({
date: expense.date,
amount: typeof (expense.amount as any)?.toNumber() === 'function' ?
(expense.amount as any).toNumber() : Number(expense.amount),
type: expense.bank_code?.type ?? 'CASH',
is_approved: expense.is_approved ?? true,
}));
return buildPeriod(period.period_start, period.period_end, shifts , expenses);
}
async findOne(id: number): Promise<any> {

View File

@ -0,0 +1,134 @@
import { DayExpensesDto, DayShiftsDto, 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 = 'sun'|'mon'|'tue'|'wed'|'thu'|'fri'|'sat';
//DB line types
type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean };
type ExpenseRow = {date: Date; amount: number; type: 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];
}
const MS_PER_DAY = 86_400_000;
export function toUTCDateOnly(date: Date | string): Date {
const d = new Date(date);
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
export function addDays(date:Date, days: number): Date {
return new Date(date.getTime() + days * MS_PER_DAY);
}
export function endOfDayUTC(date: Date | string): Date {
const d = toUTCDateOnly(date);
return new Date(d.getTime() + MS_PER_DAY - 1);
}
export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): boolean {
const time = date.getTime();
return time >= start.getTime() && time <= end_inclusive.getTime();
}
export function dayIndex(week_start: Date, date: Date): 1|2|3|4|5|6|7 {
const diff = Math.floor((toUTCDateOnly(date).getTime() - toUTCDateOnly(week_start).getTime())/ MS_PER_DAY);
const index = Math.min(6, Math.max(0, diff)) + 1;
return index as 1|2|3|4|5|6|7;
}
export function toTimeString(date: Date): string {
const hours = String(date.getUTCHours()).padStart(2,'0');
const minutes = String(date.getUTCMinutes()).padStart(2,'0');
return `${hours}:${minutes}`;
}
export function round2(num: number) {
return Math.round(num * 100) / 100;
}
export function makeEmptyDayShifts(): DayShiftsDto { return []; }
export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; }
export function makeEmptyWeek(): WeekDto {
return {
is_approved: true,
shifts: {
sun: makeEmptyDayShifts(),
mon: makeEmptyDayShifts(),
tue: makeEmptyDayShifts(),
wed: makeEmptyDayShifts(),
thu: makeEmptyDayShifts(),
fri: makeEmptyDayShifts(),
sat: makeEmptyDayShifts(),
},
expenses: {
sun: makeEmptyDayExpenses(),
mon: makeEmptyDayExpenses(),
tue: makeEmptyDayExpenses(),
wed: makeEmptyDayExpenses(),
thu: makeEmptyDayExpenses(),
fri: makeEmptyDayExpenses(),
sat: makeEmptyDayExpenses(),
},
};
}
export function makeEmptyPeriod(): TimesheetPeriodDto {
return { week1: makeEmptyWeek(), week2: makeEmptyWeek() };
}
//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, shifts: ShiftRow[], expenses: ExpenseRow[]): WeekDto {
const week = makeEmptyWeek();
let all_approved = true;
//Shifts mapped and filtered by dates
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].push({
start: toTimeString(shift.start_time),
end : toTimeString(shift.end_time),
is_approved: shift.is_approved ?? true,
} as ShiftDto);
all_approved = all_approved && (shift.is_approved ?? true);
}
//Expenses mapped and filtered by dates
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,
});
all_approved = all_approved && (expense.is_approved ?? true);
}
week.is_approved = all_approved;
return week;
}
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),
};
}