diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 133f2d5..99f16ad 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -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": { diff --git a/src/app.module.ts b/src/app.module.ts index 7a4aadf..ebd5c5d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..a44c4c9 --- /dev/null +++ b/src/common/filters/http-exception.filter.ts @@ -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(); + const request = http_context.getRequest(); + const http_status = exception.getStatus(); + + const exception_response = exception.getResponse(); + const normalized = typeof exception_response === 'string' + ? { message: exception_response } + : (exception_response as Record); + const response_body = { + statusCode: http_status, + timestamp: new Date().toISOString(), + path: request.url, + ...normalized, + }; + response.status(http_status).json(response_body); + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 504ffe1..88237b6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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({ diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index 15932b3..8e0a952 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -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; diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index 3a33170..b323988 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -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,9 +18,18 @@ 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) @@ -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') diff --git a/src/modules/shifts/dtos/upsert-shift.dto.ts b/src/modules/shifts/dtos/upsert-shift.dto.ts new file mode 100644 index 0000000..83900dd --- /dev/null +++ b/src/modules/shifts/dtos/upsert-shift.dto.ts @@ -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; +}; \ No newline at end of file diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts new file mode 100644 index 0000000..94ecf5e --- /dev/null +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -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())); +} diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index 0de643c..e0c55af 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -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 { 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((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 { + 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; } diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index b948f4d..cfd0194 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -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; } diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index e98a62c..f4517e2 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -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 { //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, diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index eadae40..95ee5f3 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -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>); //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 = 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 }); + 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); @@ -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,