feat(expenses): upsert function for expenses

This commit is contained in:
Matthieu Haineault 2025-09-30 10:43:48 -04:00
parent 46deae63bc
commit 52114deb33
17 changed files with 512 additions and 53 deletions

View File

@ -225,9 +225,10 @@ model Expenses {
bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id])
bank_code_id Int
date DateTime @db.Date
amount Decimal @db.Money
attachement String?
comment String?
amount Decimal @db.Money
mileage Decimal?
attachment String?
comment String
is_approved Boolean @default(false)
supervisor_comment String?
@ -244,8 +245,9 @@ model ExpensesArchive {
archived_at DateTime @default(now())
bank_code_id Int
date DateTime @db.Date
amount Decimal @db.Money
attachement String?
amount Decimal? @db.Money
mileage Decimal?
attachment String?
comment String?
is_approved Boolean
supervisor_comment String?

View File

@ -1,30 +1,30 @@
import { BadRequestException, Module, ValidationPipe } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ArchivalModule } from './modules/archival/archival.module';
import { AuthenticationModule } from './modules/authentication/auth.module';
import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
import { BusinessLogicsModule } from './modules/business-logics/business-logics.module';
// import { CsvExportModule } from './modules/exports/csv-exports.module';
import { CustomersModule } from './modules/customers/customers.module';
import { EmployeesModule } from './modules/employees/employees.module';
import { ExpensesModule } from './modules/expenses/expenses.module';
import { HealthModule } from './health/health.module';
import { CustomersModule } from './modules/customers/customers.module';
import { EmployeesModule } from './modules/employees/employees.module';
import { ExpensesModule } from './modules/expenses/expenses.module';
import { HealthModule } from './health/health.module';
import { HealthController } from './health/health.controller';
import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module';
import { OvertimeService } from './modules/business-logics/services/overtime.service';
import { OvertimeService } from './modules/business-logics/services/overtime.service';
import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
import { PrismaModule } from './prisma/prisma.module';
import { ScheduleModule } from '@nestjs/schedule';
import { ShiftsModule } from './modules/shifts/shifts.module';
import { PrismaModule } from './prisma/prisma.module';
import { ScheduleModule } from '@nestjs/schedule';
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 { 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';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ValidationError } from 'class-validator';
@Module({
imports: [

View File

@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { ExpensesQueryService } from "../services/expenses-query.service";
import { CreateExpenseDto } from "../dtos/create-expense.dto";
import { Expenses } from "@prisma/client";
@ -8,6 +8,8 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagg
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { ExpensesCommandService } from "../services/expenses-command.service";
import { SearchExpensesDto } from "../dtos/search-expense.dto";
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces";
@ApiTags('Expenses')
@ApiBearerAuth('access-token')
@ -15,17 +17,26 @@ import { SearchExpensesDto } from "../dtos/search-expense.dto";
@Controller('Expenses')
export class ExpensesController {
constructor(
private readonly expensesService: ExpensesQueryService,
private readonly expensesApprovalService: ExpensesCommandService,
private readonly query: ExpensesQueryService,
private readonly command: ExpensesCommandService,
) {}
@Put('upsert/:email/:date')
async upsert_by_date(
@Param('email') email: string,
@Param('date') date: string,
@Body() dto: UpsertExpenseDto,
): Promise<UpsertExpenseResult> {
return this.command.upsertExpensesByDate(email, date, dto);
}
@Post()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Create expense' })
@ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
return this.expensesService.create(dto);
return this.query.create(dto);
}
@Get()
@ -35,7 +46,7 @@ export class ExpensesController {
@ApiResponse({ status: 400, description: 'List of expenses not found' })
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
return this.expensesService.findAll(filters);
return this.query.findAll(filters);
}
@Get(':id')
@ -44,7 +55,7 @@ export class ExpensesController {
@ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
@ApiResponse({ status: 400, description: 'Expense not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
return this.expensesService.findOne(id);
return this.query.findOne(id);
}
@Patch(':id')
@ -53,7 +64,7 @@ export class ExpensesController {
@ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
@ApiResponse({ status: 400, description: 'Expense not found' })
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
return this.expensesService.update(id,dto);
return this.query.update(id,dto);
}
@Delete(':id')
@ -62,13 +73,13 @@ export class ExpensesController {
@ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
@ApiResponse({ status: 400, description: 'Expense not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
return this.expensesService.remove(id);
return this.query.remove(id);
}
@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.expensesApprovalService.updateApproval(id, isApproved);
return this.command.updateApproval(id, isApproved);
}
}

View File

@ -46,7 +46,7 @@ export class CreateExpenseDto {
description:'explain`s why the expense was made'
})
@IsString()
comment?: string;
comment: string;
@ApiProperty({
example: 'DENIED, APPROUVED, PENDING, etc...',

View File

@ -0,0 +1,42 @@
import { Transform, Type } from "class-transformer";
import { IsDefined, IsNumber, IsOptional, IsString, maxLength, MaxLength, Min, ValidateIf, ValidateNested } from "class-validator";
export class ExpensePayloadDto {
@IsString()
type!: string;
@ValidateIf(o => (o.type ?? '').toUpperCase() !== 'MILEAGE')
@IsDefined()
@IsNumber()
@Min(0)
amount!: number;
@ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE')
@IsDefined()
@IsNumber()
@Min(0)
mileage!: number;
@IsString()
@MaxLength(280)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
comment!: string;
@IsOptional()
@IsString()
@MaxLength(255)
attachment?: string;
}
export class UpsertExpenseDto {
@IsOptional()
@ValidateNested()
@Type(()=> ExpensePayloadDto)
old_expense?: ExpensePayloadDto;
@IsOptional()
@ValidateNested()
@Type(()=> ExpensePayloadDto)
new_expense?: ExpensePayloadDto;
}

View File

@ -3,11 +3,20 @@ import { Module } from "@nestjs/common";
import { ExpensesQueryService } from "./services/expenses-query.service";
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
import { ExpensesCommandService } from "./services/expenses-command.service";
import { BankCodesRepo } from "./repos/bank-codes.repo";
import { TimesheetsRepo } from "./repos/timesheets.repo";
import { EmployeesRepo } from "./repos/employee.repo";
@Module({
imports: [BusinessLogicsModule],
controllers: [ExpensesController],
providers: [ExpensesQueryService, ExpensesCommandService],
providers: [
ExpensesQueryService,
ExpensesCommandService,
BankCodesRepo,
TimesheetsRepo,
EmployeesRepo,
],
exports: [ ExpensesQueryService ],
})

View File

@ -0,0 +1,34 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
type Tx = Prisma.TransactionClient | PrismaClient;
@Injectable()
export class BankCodesRepo {
constructor(private readonly prisma: PrismaService) {}
//find id and modifier by type
readonly findByType = async ( type: string, client?: Tx
): Promise<{id:number; modifier: number }> => {
const db = client ?? this.prisma;
const bank = await db.bankCodes.findFirst({
where: {
type,
},
select: {
id: true,
modifier: true,
},
});
if(!bank) {
throw new NotFoundException(`Unknown bank code type: ${type}`);
}
return {
id: bank.id,
modifier: bank.modifier,
};
};
}

View File

@ -0,0 +1,32 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
type Tx = Prisma.TransactionClient | PrismaClient;
@Injectable()
export class EmployeesRepo {
constructor(private readonly prisma: PrismaService) {}
// find employee id by email
readonly findIdByEmail = async ( email: string, client?: Tx
): Promise<number> => {
const db = client ?? this.prisma;
const employee = await db.employees.findFirst({
where: {
user: {
email,
},
},
select: {
id: true,
},
});
if(!employee) {
throw new NotFoundException(`Employee with email: ${email} not found`);
}
return employee.id;
}
}

View File

@ -0,0 +1,42 @@
import { Injectable } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client";
import { weekStartMondayUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers";
import { PrismaService } from "src/prisma/prisma.service";
type Tx = Prisma.TransactionClient | PrismaClient;
@Injectable()
export class TimesheetsRepo {
constructor(private readonly prisma: PrismaService) {}
//find an existing timesheet linked to the employee
readonly ensureForDate = async (employee_id: number, date: Date, client?: Tx,
): Promise<{id: number; start_date: Date }> => {
const db = client ?? this.prisma;
const startOfWeek = weekStartMondayUTC(date);
const existing = await db.timesheets.findFirst({
where: {
employee_id: employee_id,
start_date: startOfWeek,
},
select: {
id: true,
start_date: true,
},
});
if(existing) return existing;
const created = await db.timesheets.create({
data: {
employee_id: employee_id,
start_date: startOfWeek,
},
select: {
id: true,
start_date: true,
},
});
return created;
}
}

View File

@ -1,13 +1,23 @@
import { Injectable } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Expenses, Prisma } from "@prisma/client";
import { Decimal } from "@prisma/client/runtime/library";
import { transcode } from "buffer";
import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { PrismaService } from "src/prisma/prisma.service";
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { BankCodesRepo } from "../repos/bank-codes.repo";
import { TimesheetsRepo } from "../repos/timesheets.repo";
import { EmployeesRepo } from "../repos/employee.repo";
import { assertAndTrimComment, computeMileageAmount, mapDbExpenseToDayResponse, normalizeType as normalizeTypeUtil } from "../utils/expenses.utils";
import { DayExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers";
@Injectable()
export class ExpensesCommandService extends BaseApprovalService<Expenses> {
constructor(prisma: PrismaService) { super(prisma); }
constructor(
prisma: PrismaService,
private readonly bankCodesRepo: BankCodesRepo,
private readonly timesheetsRepo: TimesheetsRepo,
private readonly employeesRepo: EmployeesRepo,
) { super(prisma); }
protected get delegate() {
return this.prisma.expenses;
@ -22,4 +32,202 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
this.updateApprovalWithTransaction(transaction, id, isApproved),
);
}
//-------------------- Master CRUD function --------------------
readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
): Promise<{ action:UpsertAction; day: DayExpenseResponse[] }> => {
//validates if there is an existing expense, at least 1 old or new
const { old_expense, new_expense } = dto ?? {};
if(!old_expense && !new_expense) {
throw new BadRequestException('At least one expense must be provided');
}
//validate date format
const dateOnly = toDateOnlyUTC(date);
if(Number.isNaN(dateOnly.getTime())) {
throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
}
//resolve employee_id by email
const employee_id = await this.resolveEmployeeIdByEmail(email);
//make sure a timesheet existes
const timesheet_id = await this.ensureTimesheetForDate(employee_id, dateOnly);
return this.prisma.$transaction(async (tx) => {
const loadDay = async (): Promise<DayExpenseResponse[]> => {
const rows = await tx.expenses.findMany({
where: {
timesheet_id: timesheet_id,
date: dateOnly,
},
include: {
bank_code: {
select: {
type: true,
},
},
},
orderBy: [{ date: 'asc' }, { id: 'asc' }],
});
return rows.map(this.mapDbToDayResponse);
};
const normalizePayload = async (payload: {
type: string;
amount?: number;
mileage?: number;
comment: string;
attachment?: string;
}): Promise<{
type: string;
bank_code_id: number;
amount: Prisma.Decimal;
comment: string;
attachment: string | null;
}> => {
const type = this.normalizeType(payload.type);
const comment = this.assertAndTrimComment(payload.comment);
const attachment = payload.attachment?.trim()?.length ? payload.attachment.trim() : null;
const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type);
const amount = this.computeAmountDecimal(type, payload, modifier);
return {
type,
bank_code_id,
amount,
comment,
attachment
};
};
const findExactOld = async (norm: {
bank_code_id: number;
amount: Prisma.Decimal;
comment: string;
attachment: string | null;
}) => {
return tx.expenses.findFirst({
where: {
timesheet_id: timesheet_id,
date: dateOnly,
bank_code_id: norm.bank_code_id,
amount: norm.amount,
comment: norm.comment,
attachment: norm.attachment,
},
select: { id: true },
});
};
let action : UpsertAction;
//-------------------- DELETE --------------------
if(old_expense && !new_expense) {
const oldNorm = await normalizePayload(old_expense);
const existing = await findExactOld(oldNorm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else',
});
}
await tx.expenses.delete({where: { id: existing.id } });
action = 'deleted';
}
//-------------------- CREATE --------------------
else if (!old_expense && new_expense) {
const new_exp = await normalizePayload(new_expense);
await tx.expenses.create({
data: {
timesheet_id: timesheet_id,
date: dateOnly,
bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount,
mileage: null,
comment: new_exp.comment,
attachment: new_exp.attachment,
is_approved: false,
},
});
action = 'created';
}
else if(old_expense && new_expense) {
const oldNorm = await normalizePayload(old_expense);
const existing = await findExactOld(oldNorm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else',
});
}
const new_exp = await normalizePayload(new_expense);
await tx.expenses.update({
where: { id: existing.id },
data: {
bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount,
mileage: null,
comment: new_exp.comment,
attachment: new_exp.attachment,
},
});
action = 'updated';
}
else {
throw new BadRequestException('Invalid upsert combination');
}
const day = await loadDay();
return { action, day };
});
}
//helpers imported from utils and repos.
private readonly normalizeType = (type: string): string =>
normalizeTypeUtil(type);
private readonly assertAndTrimComment = (comment: string): string =>
assertAndTrimComment(comment);
private readonly resolveEmployeeIdByEmail = async (email: string): Promise<number> =>
this.employeesRepo.findIdByEmail(email);
private readonly ensureTimesheetForDate = async ( employee_id: number, date: Date
): Promise<number> => {
const { id } = await this.timesheetsRepo.ensureForDate(employee_id, date);
return id;
};
private readonly lookupBankCodeOrThrow = async ( transaction: Prisma.TransactionClient, type: string
): Promise<{id: number; modifier: number}> =>
this.bankCodesRepo.findByType(type, transaction);
private readonly computeAmountDecimal = (
type: string,
payload: { amount?: number; mileage?: number;},
modifier: number,
): Prisma.Decimal => {
if(type === 'MILEAGE') {
const km = payload.mileage ?? 0;
const amountNumber = computeMileageAmount(km, modifier);
return new Prisma.Decimal(amountNumber);
}
return new Prisma.Decimal(payload.amount!);
};
private readonly mapDbToDayResponse = (row: {
date: Date;
amount: Prisma.Decimal | number | string;
comment: string;
is_approved: boolean;
bank_code: { type: string } | null;
}): DayExpenseResponse => mapDbExpenseToDayResponse(row);
}

View File

@ -121,7 +121,7 @@ export class ExpensesQueryService {
bank_code_id: exp.bank_code_id,
date: exp.date,
amount: exp.amount,
attachement: exp.attachement,
attachment: exp.attachment,
comment: exp.comment,
is_approved: exp.is_approved,
supervisor_comment: exp.supervisor_comment,

View File

@ -0,0 +1,14 @@
export type UpsertAction = 'created' | 'updated' | 'deleted';
export interface DayExpenseResponse {
date: string;
type: string;
amount: number;
comment: string;
is_approved: boolean;
};
export type UpsertExpenseResult = {
action: UpsertAction;
day: DayExpenseResponse[]
};

View File

@ -0,0 +1,65 @@
import { BadRequestException } from "@nestjs/common";
import { DayExpenseResponse } from "../types and interfaces/expenses.types.interfaces";
//uppercase and trim for validation
export function normalizeType(type: string): string {
return (type ?? '').trim().toUpperCase();
};
//required comment after trim
export function assertAndTrimComment(comment: string): string {
const cmt = (comment ?? '').trim();
if(cmt.length === 0) {
throw new BadRequestException('A comment is required');
}
return cmt;
};
//rounding $ to 2 decimals
export function roundMoney2(num: number): number {
return Math.round((num + Number.EPSILON) * 100)/ 100;
};
export function computeMileageAmount(km: number, modifier: number): number {
if(km < 0) throw new BadRequestException('mileage must be positive');
if(modifier < 0) throw new BadRequestException('modifier must be positive');
return roundMoney2(km * modifier);
};
//compat. types with Prisma.Decimal. work around Prisma import in utils.
export type DecimalLike =
| number
| string
| { toNumber?: () => number }
| { toString?: () => string };
//safe conversion to number
export function toNumberSafe(value: DecimalLike): number {
if(typeof value === 'number') return value;
if(value && typeof (value as any).toNumber === 'function') return (value as any).toNumber();
return Number(
typeof (value as any)?.toString === 'function'
? (value as any).toString()
: value,
);
}
//map of a row for DayExpenseResponse
export function mapDbExpenseToDayResponse(row: {
date: Date;
amount: DecimalLike;
comment: string;
is_approved: boolean;
bank_code?: { type?: string | null } | null;
}): DayExpenseResponse {
const yyyyMmDd = row.date.toISOString().slice(0,10);
return {
date: yyyyMmDd,
type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'),
amount: toNumberSafe(row.amount),
comment: row.comment,
is_approved: row.is_approved,
};
}

View File

@ -28,7 +28,7 @@ export class ShiftsController {
@Param('date') date_param: string,
@Body() payload: UpsertShiftDto,
) {
return this.shiftsCommandService.upsertShfitsByDate(email_param, date_param, payload);
return this.shiftsCommandService.upsertShiftsByDate(email_param, date_param, payload);
}
@Post()

View File

@ -1,7 +1,7 @@
import { Type } from "class-transformer";
import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
export const COMMENT_MAX_LENGTH = 512;
export const COMMENT_MAX_LENGTH = 280;
export class ShiftPayloadDto {

View File

@ -20,7 +20,7 @@ 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):
async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto):
Promise<{ action: UpsertAction; day: DayShiftResponse[] }> {
const { old_shift, new_shift } = dto;

View File

@ -1,12 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { Timesheets, TimesheetsArchive } from '@prisma/client';
import { OvertimeService } from 'src/modules/business-logics/services/overtime.service';
import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import { Injectable, NotFoundException } from '@nestjs/common';
import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers';
import { computeHours, 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 { TimesheetDto } from '../dtos/overview-timesheet.dto';
@Injectable()
@ -161,14 +161,14 @@ export class TimesheetsQueryService {
const to_HH_mm = (date: Date) => date.toISOString().slice(11, 16);
//maps all shifts of selected timesheet
const shifts = timesheet.shift.map((sft) => ({
bank_type: sft.bank_code?.type ?? '',
date: formatDateISO(sft.date),
start_time: to_HH_mm(sft.start_time),
end_time: to_HH_mm(sft.end_time),
comment: sft.comment ?? '',
is_approved: sft.is_approved ?? false,
is_remote: sft.is_remote ?? false,
const shifts = timesheet.shift.map((shift_row) => ({
bank_type: shift_row.bank_code?.type ?? '',
date: formatDateISO(shift_row.date),
start_time: to_HH_mm(shift_row.start_time),
end_time: to_HH_mm(shift_row.end_time),
comment: shift_row.comment ?? '',
is_approved: shift_row.is_approved ?? false,
is_remote: shift_row.is_remote ?? false,
}));
//maps all expenses of selected timsheet