Merge branch 'main' of git.targo.ca:Targo/targo_backend into auth-session-fullstack

This commit is contained in:
Nicolas Drolet 2025-09-16 11:21:56 -04:00
commit 5621f72342
12 changed files with 524 additions and 35 deletions

View File

@ -876,6 +876,52 @@
]
}
},
"/shifts/upsert/{email}/{date}": {
"put": {
"operationId": "ShiftsController_upsert_by_date",
"parameters": [
{
"name": "email",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "date",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpsertShiftDto"
}
}
}
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Shifts"
]
}
},
"/shifts": {
"post": {
"operationId": "ShiftsController_create",
@ -2513,6 +2559,10 @@
}
}
},
"UpsertShiftDto": {
"type": "object",
"properties": {}
},
"CreateShiftDto": {
"type": "object",
"properties": {

View File

@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { BadRequestException, Module, ValidationPipe } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ArchivalModule } from './modules/archival/archival.module';
@ -22,6 +22,9 @@ import { ShiftsModule } from './modules/shifts/shifts.module';
import { TimesheetsModule } from './modules/timesheets/timesheets.module';
import { UsersModule } from './modules/users-management/users.module';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ValidationError } from 'class-validator';
@Module({
imports: [
@ -46,6 +49,29 @@ import { ConfigModule } from '@nestjs/config';
UsersModule,
],
controllers: [AppController, HealthController],
providers: [AppService, OvertimeService],
providers: [
AppService,
OvertimeService,
{
provide: APP_FILTER,
useClass: HttpExceptionFilter
},
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
exceptionFactory: (errors: ValidationError[] = [])=> {
const messages = errors.flatMap((e)=> Object.values(e.constraints ?? {}));
return new BadRequestException({
statusCode: 400,
error: 'Bad Request',
message: messages.length ? messages : errors,
});
},
}),
},
],
})
export class AppModule {}

View File

@ -0,0 +1,24 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/common";
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const http_context = host.switchToHttp();
const response = http_context.getResponse<Response>();
const request = http_context.getRequest<Request>();
const http_status = exception.getStatus();
const exception_response = exception.getResponse();
const normalized = typeof exception_response === 'string'
? { message: exception_response }
: (exception_response as Record<string, unknown>);
const response_body = {
statusCode: http_status,
timestamp: new Date().toISOString(),
path: request.url,
...normalized,
};
response.status(http_status).json(response_body);
}
}

View File

@ -11,7 +11,6 @@ import { ATT_TMP_DIR } from './config/attachment.config'; // log to be removed p
import { ModuleRef, NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
// import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
import { OwnershipGuard } from './common/guards/ownership.guard';
@ -25,13 +24,11 @@ async function bootstrap() {
const reflector = app.get(Reflector); //setup Reflector for Roles()
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true}));
app.useGlobalGuards(
// new JwtAuthGuard(reflector), //Authentification JWT
new RolesGuard(reflector), //deny-by-default and Role-based Access Control
new OwnershipGuard(reflector, app.get(ModuleRef)), //Global use of OwnershipGuard, not implemented yet
);
);
// Authentication and session
app.use(session({

View File

@ -239,10 +239,18 @@ export class PayPeriodsQueryService {
const hours = computeHours(shift.start_time, shift.end_time);
const type = (shift.bank_code?.type ?? '').toUpperCase();
switch (type) {
case "EVENING": record.evening_hours += hours; record.total_hours += hours; break;
case "EMERGENCY": record.emergency_hours += hours; record.total_hours += hours; break;
case "OVERTIME": record.overtime_hours += hours; record.total_hours += hours; break;
case "REGULAR" : record.regular_hours += hours; record.total_hours += hours; break;
case "EVENING": record.evening_hours += hours;
record.total_hours += hours;
break;
case "EMERGENCY": record.emergency_hours += hours;
record.total_hours += hours;
break;
case "OVERTIME": record.overtime_hours += hours;
record.total_hours += hours;
break;
case "REGULAR" : record.regular_hours += hours;
record.total_hours += hours;
break;
}
record.is_approved = record.is_approved && shift.timesheet.is_approved;

View File

@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { Shifts } from "@prisma/client";
import { CreateShiftDto } from "../dtos/create-shift.dto";
import { UpdateShiftsDto } from "../dtos/update-shift.dto";
@ -9,6 +9,7 @@ import { ShiftsCommandService } from "../services/shifts-command.service";
import { SearchShiftsDto } from "../dtos/search-shift.dto";
import { OverviewRow, ShiftsQueryService } from "../services/shifts-query.service";
import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
@ApiTags('Shifts')
@ApiBearerAuth('access-token')
@ -17,10 +18,19 @@ import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
export class ShiftsController {
constructor(
private readonly shiftsService: ShiftsQueryService,
private readonly shiftsApprovalService: ShiftsCommandService,
private readonly shiftsCommandService: ShiftsCommandService,
private readonly shiftsValidationService: ShiftsQueryService,
){}
@Put('upsert/:email/:date')
async upsert_by_date(
@Param('email') email_param: string,
@Param('date') date_param: string,
@Body() payload: UpsertShiftDto,
) {
return this.shiftsCommandService.upsertShfitsByDate(email_param, date_param, payload);
}
@Post()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Create shift' })
@ -70,7 +80,7 @@ export class ShiftsController {
@Patch('approval/:id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
return this.shiftsApprovalService.updateApproval(id, isApproved);
return this.shiftsCommandService.updateApproval(id, isApproved);
}
@Get('summary')

View File

@ -0,0 +1,37 @@
import { Type } from "class-transformer";
import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
export const COMMENT_MAX_LENGTH = 512;
export class ShiftPayloadDto {
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
start_time!: string;
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
end_time!: string;
@IsString()
type!: string;
@IsBoolean()
is_remote!: boolean;
@IsOptional()
@IsString()
@MaxLength(COMMENT_MAX_LENGTH)
comment?: string;
};
export class UpsertShiftDto {
@IsOptional()
@ValidateNested()
@Type(()=> ShiftPayloadDto)
old_shift?: ShiftPayloadDto;
@IsOptional()
@ValidateNested()
@Type(()=> ShiftPayloadDto)
new_shift?: ShiftPayloadDto;
};

View File

@ -0,0 +1,18 @@
export function timeFromHHMMUTC(hhmm: string): Date {
const [hour, min] = hhmm.split(':').map(Number);
return new Date(Date.UTC(1970,0,1,hour, min,0));
}
export function weekStartMondayUTC(date: Date): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const day = d.getUTCDay();
const diff = (day + 6) % 7;
d.setUTCDate(d.getUTCDate() - diff);
d.setUTCHours(0,0,0,0);
return d;
}
export function toDateOnlyUTC(input: string | Date): Date {
const date = new Date(input);
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}

View File

@ -1,12 +1,276 @@
import { Injectable } from "@nestjs/common";
import { BadRequestException, ConflictException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common";
import { Prisma, Shifts } from "@prisma/client";
import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { PrismaService } from "src/prisma/prisma.service";
import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { timeFromHHMMUTC, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers";
type DayShiftResponse = {
start_time: string;
end_time: string;
type: string;
is_remote: boolean;
comment: string | null;
}
type UpsertAction = 'created' | 'updated' | 'deleted';
@Injectable()
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
constructor(prisma: PrismaService) { super(prisma); }
//create/update/delete master method
async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto):
Promise<{ action: UpsertAction; day: DayShiftResponse[] }> {
const { old_shift, new_shift } = dto;
if(!dto.old_shift && !dto.new_shift) {
throw new BadRequestException('At least one of old or new shift must be provided');
}
const date_only = toDateOnlyUTC(date_string);
//Resolve employee by email
const employee = await this.prisma.employees.findFirst({
where: { user: {email } },
select: { id: true },
});
if(!employee) throw new NotFoundException(`Employee not found for email : ${ email }`);
//making sure a timesheet exist in selected week
const start_of_week = weekStartMondayUTC(date_only);
let timesheet = await this.prisma.timesheets.findFirst({
where: {
employee_id: employee.id,
start_date: start_of_week
},
select: {
id: true
},
});
if(!timesheet) {
timesheet = await this.prisma.timesheets.create({
data: {
employee_id: employee.id,
start_date: start_of_week
},
select: {
id: true
},
});
}
//normalization of data to ensure a valid comparison between DB and payload
const old_norm = dto.old_shift
? this.normalize_shift_payload(dto.old_shift)
: undefined;
const new_norm = dto.new_shift
? this.normalize_shift_payload(dto.new_shift)
: undefined;
if (old_norm && old_norm.end_time.getTime() <= old_norm.start_time.getTime()) {
throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time');
}
if (new_norm && new_norm.end_time.getTime() <= new_norm.start_time.getTime()) {
throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time');
}
//Resolve bank_code_id with type
const old_bank_code_id = old_norm
? await this.lookup_bank_code_id_or_throw(old_norm.type)
: undefined;
const new_bank_code_id = new_norm
? await this.lookup_bank_code_id_or_throw(new_norm.type)
: undefined;
//fetch all shifts in a single day
const day_shifts = await this.prisma.shifts.findMany({
where: {
timesheet_id: timesheet.id,
date: date_only
},
include: {
bank_code: true
},
orderBy: {
start_time: 'asc'
},
});
const result = await this.prisma.$transaction(async (transaction)=> {
let action: UpsertAction;
const find_exact_old_shift = async ()=> {
if(!old_norm || old_bank_code_id === undefined) return undefined;
const old_comment = old_norm.comment ?? null;
return transaction.shifts.findFirst({
where: {
timesheet_id: timesheet.id,
date: date_only,
start_time: old_norm.start_time,
end_time: old_norm.end_time,
is_remote: old_norm.is_remote,
comment: old_comment,
bank_code_id: old_bank_code_id,
},
select: { id: true },
});
};
//checks for overlaping shifts
const assert_no_overlap = (exclude_shift_id?: number)=> {
if (!new_norm) return;
const overlap_with = day_shifts.filter((shift)=> {
if(exclude_shift_id && shift.id === exclude_shift_id) return false;
return this.overlaps(
new_norm.start_time.getTime(),
new_norm.end_time.getTime(),
shift.start_time.getTime(),
shift.end_time.getTime(),
);
});
if(overlap_with.length > 0) {
const conflicts = overlap_with.map((shift)=> ({
start_time: this.format_hhmm(shift.start_time),
end_time: this.format_hhmm(shift.end_time),
type: shift.bank_code?.type ?? 'UNKNOWN',
}));
throw new ConflictException({
error_code: 'SHIFT_OVERLAP',
message: 'New shift overlaps with existing shift(s)',
conflicts,
});
}
};
// DELETE
if ( old_shift && !new_shift ) {
const existing = await find_exact_old_shift();
if(!existing) {
throw new NotFoundException({
error_code: 'SHIFT_STALE',
message: 'The shift was modified or deleted by someone else',
});
}
await transaction.shifts.delete({ where: { id: existing.id } } );
action = 'deleted';
}
// CREATE
else if (!old_shift && new_shift) {
assert_no_overlap();
await transaction.shifts.create({
data: {
timesheet_id: timesheet.id,
date: date_only,
start_time: new_norm!.start_time,
end_time: new_norm!.end_time,
is_remote: new_norm!.is_remote,
comment: new_norm!.comment ?? null,
bank_code_id: new_bank_code_id!,
},
});
action = 'created';
}
//UPDATE
else if (old_shift && new_shift){
const existing = await find_exact_old_shift();
if(!existing) {
throw new NotFoundException({
error_code: 'SHIFT_STALE',
message: 'The shift was modified or deleted by someone else',
});
}
assert_no_overlap(existing.id);
await transaction.shifts.update({
where: {
id: existing.id
},
data: {
start_time: new_norm!.start_time,
end_time: new_norm!.end_time,
is_remote: new_norm!.is_remote,
comment: new_norm!.comment ?? null,
bank_code_id: new_bank_code_id,
},
});
action = 'updated';
} else {
throw new BadRequestException('At least one of old_shift or new_shift must be provided');
}
//Reload the day (truth source)
const fresh_day = await transaction.shifts.findMany({
where: {
timesheet_id: timesheet.id,
date: date_only,
},
include: {
bank_code: true
},
orderBy: {
start_time: 'asc'
},
});
return {
action,
day: fresh_day.map<DayShiftResponse>((shift)=> ({
start_time: this.format_hhmm(shift.start_time),
end_time: this.format_hhmm(shift.end_time),
type: shift.bank_code?.type ?? 'UNKNOWN',
is_remote: shift.is_remote,
comment: shift.comment ?? null,
})),
};
});
return result;
}
private normalize_shift_payload(payload: ShiftPayloadDto) {
//normalize shift's infos
const start_time = timeFromHHMMUTC(payload.start_time);
const end_time = timeFromHHMMUTC(payload.end_time );
const type = (payload.type || '').trim().toUpperCase();
const is_remote = payload.is_remote === true;
//normalize comment
const raw_comment = payload.comment ?? null;
const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null;
const comment = trimmed && trimmed.length > 0 ? trimmed: null;
return { start_time, end_time, type, is_remote, comment };
}
private async lookup_bank_code_id_or_throw(type: string): Promise<number> {
const bank = await this.prisma.bankCodes.findFirst({
where: { type },
select: { id: true },
});
if (!bank) {
throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` });
}
return bank.id;
}
private overlaps(
a_start_ms: number,
a_end_ms: number,
b_start_ms: number,
b_end_ms: number,
): boolean {
return a_start_ms < b_end_ms && b_start_ms < a_end_ms;
}
private format_hhmm(time: Date): string {
const hh = String(time.getUTCHours()).padStart(2,'0');
const mm = String(time.getUTCMinutes()).padStart(2,'0');
return `${hh}:${mm}`;
}
//approval methods
protected get delegate() {
return this.prisma.shifts;
}

View File

@ -3,12 +3,15 @@ export class ShiftDto {
type: string;
start_time: string;
end_time : string;
comment: string;
is_approved: boolean;
is_remote: boolean;
}
export class ExpenseDto {
amount: number;
comment: string;
supervisor_comment: string;
total_mileage: number;
total_expense: number;
is_approved: boolean;
@ -22,6 +25,7 @@ export class DetailedShifts {
evening_hours: number;
overtime_hours: number;
emergency_hours: number;
comment: string;
short_date: string;
break_durations?: number;
}

View File

@ -1,14 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { Timesheets, TimesheetsArchive } from '@prisma/client';
import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto';
import { OvertimeService } from 'src/modules/business-logics/services/overtime.service';
import { computeHours, formatDateISO, getCurrentWeek, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers';
import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers';
import { TimesheetDto } from '../dtos/overview-timesheet.dto';
import { CreateWeekShiftsDto } from '../dtos/create-timesheet.dto';
@Injectable()
@ -21,14 +19,14 @@ export class TimesheetsQueryService {
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 } } },
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 },
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`);
@ -45,6 +43,7 @@ export class TimesheetsQueryService {
date: true,
start_time: true,
end_time: true,
comment: true,
is_approved: true,
is_remote: true,
bank_code: { select: { type: true } },
@ -60,31 +59,37 @@ export class TimesheetsQueryService {
select: {
date: true,
amount: true,
comment: true,
supervisor_comment: true,
is_approved: true,
bank_code: { select: { type: true } },
},
orderBy: { date: 'asc' },
});
const to_num = (value: any) => value && typeof (value as any).toNumber === 'function'
? (value as any).toNumber()
: Number(value);
const to_num = (value: any) =>
value && typeof value.toNumber === 'function' ? value.toNumber() :
typeof value === 'number' ? value :
value ? Number(value) : 0;
// data mapping
const shifts: ShiftRow[] = raw_shifts.map(shift => ({
date: shift.date,
start_time: shift.start_time,
end_time: shift.end_time,
type: String(shift.bank_code?.type ?? '').toUpperCase(),
comment: shift.comment ?? '',
is_approved: shift.is_approved ?? true,
is_remote: shift.is_remote ?? true,
type: String(shift.bank_code?.type ?? '').toUpperCase(),
}));
const expenses: ExpenseRow[] = raw_expenses.map(expense => ({
date: expense.date,
amount: to_num(expense.amount),
type: String(expense.bank_code?.type ?? '').toUpperCase(),
comment: expense.comment ?? '',
supervisor_comment: expense.supervisor_comment ?? '',
is_approved: expense.is_approved ?? true,
type: String(expense.bank_code?.type ?? '').toUpperCase(),
}));
return buildPeriod(period.period_start, period.period_end, shifts , expenses);
@ -231,7 +236,7 @@ export class TimesheetsQueryService {
await this.prisma.$transaction(async transaction => {
//fetches all timesheets to cutoff
const oldSheets = await transaction.timesheets.findMany({
where: { shift: { every: { date: { lt: cutoff } } },
where: { shift: { some: { date: { lt: cutoff } } },
},
select: {
id: true,

View File

@ -34,8 +34,23 @@ const EXPENSE_TYPES = {
} as const;
//DB line types
export type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean; is_remote: boolean; type: string };
export type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean; };
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 {
@ -84,6 +99,7 @@ export function makeEmptyWeek(week_start: Date): WeekDto {
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
comment: '',
short_date: shortDate(addDays(week_start, offset)),
break_durations: 0,
});
@ -129,19 +145,44 @@ export function buildWeek(
}, {} as Record<DayKey, Array<{ start: Date; end: Date}>>);
//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 });
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<DayKey, ShiftsHours> = DAY_KEYS.reduce((acc, key) => {
acc[key] = make_hours(); return acc;
}, {} as Record<DayKey, ShiftsHours>);
//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 });
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<DayKey, ExpensesAmount> = DAY_KEYS.reduce((acc, key) => {
acc[key] = make_amounts(); return acc;
}, {} as Record<DayKey, ExpensesAmount>);
@ -159,6 +200,7 @@ export function buildWeek(
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);
@ -230,6 +272,8 @@ export function buildWeek(
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,
@ -240,6 +284,8 @@ export function buildWeek(
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,