refactor(timesheets): refactored findAll to return more data

This commit is contained in:
Matthieu Haineault 2025-08-28 10:22:19 -04:00
parent 9bc5c41de8
commit 994e02ba7f
13 changed files with 196 additions and 193 deletions

View File

@ -464,24 +464,36 @@
] ]
}, },
"get": { "get": {
"operationId": "TimesheetsController_findAll", "operationId": "TimesheetsController_getPeriodByQuery",
"parameters": [], "parameters": [
"responses": { {
"201": { "name": "year",
"description": "List of timesheet found", "required": true,
"content": { "in": "query",
"application/json": { "schema": {
"schema": { "type": "number"
"type": "array",
"items": {
"$ref": "#/components/schemas/CreateTimesheetDto"
}
}
}
} }
}, },
"400": { {
"description": "List of timesheets not found" "name": "period_no",
"required": true,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "email",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
} }
}, },
"security": [ "security": [
@ -489,7 +501,6 @@
"access-token": [] "access-token": []
} }
], ],
"summary": "Find all timesheets",
"tags": [ "tags": [
"Timesheets" "Timesheets"
] ]
@ -618,7 +629,7 @@
] ]
} }
}, },
"/timesheets/{id}/approval": { "/timesheets/approval/{id}": {
"patch": { "patch": {
"operationId": "TimesheetsController_approve", "operationId": "TimesheetsController_approve",
"parameters": [ "parameters": [
@ -1206,7 +1217,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/CreateLeaveRequestsDto" "$ref": "#/components/schemas/LeaveRequestViewDto"
} }
} }
} }
@ -1246,7 +1257,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CreateLeaveRequestsDto" "$ref": "#/components/schemas/LeaveRequestViewDto"
} }
} }
} }
@ -1293,7 +1304,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CreateLeaveRequestsDto" "$ref": "#/components/schemas/LeaveRequestViewDto"
} }
} }
} }
@ -1525,29 +1536,6 @@
] ]
} }
}, },
"/exports/csv": {
"get": {
"operationId": "CsvExportController_exportCsv",
"parameters": [
{
"name": "period",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"CsvExport"
]
}
},
"/customers": { "/customers": {
"post": { "post": {
"operationId": "CustomersController_create", "operationId": "CustomersController_create",
@ -2293,7 +2281,7 @@
"description": "Employee`s email" "description": "Employee`s email"
}, },
"phone_number": { "phone_number": {
"type": "number", "type": "string",
"example": "82538437464", "example": "82538437464",
"description": "Employee`s phone number" "description": "Employee`s phone number"
}, },
@ -2378,7 +2366,7 @@
"description": "Employee`s email" "description": "Employee`s email"
}, },
"phone_number": { "phone_number": {
"type": "number", "type": "string",
"example": "82538437464", "example": "82538437464",
"description": "Employee`s phone number" "description": "Employee`s phone number"
}, },
@ -2646,16 +2634,6 @@
"CreateLeaveRequestsDto": { "CreateLeaveRequestsDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": {
"type": "number",
"example": 1,
"description": "Leave request`s unique id(auto-incremented)"
},
"employee_id": {
"type": "number",
"example": "4655867",
"description": "Employee`s id"
},
"bank_code_id": { "bank_code_id": {
"type": "number", "type": "number",
"example": 7, "example": 7,
@ -2667,13 +2645,11 @@
"description": "type of leave request for an accounting perception" "description": "type of leave request for an accounting perception"
}, },
"start_date_time": { "start_date_time": {
"format": "date-time",
"type": "string", "type": "string",
"example": "22/06/2463", "example": "22/06/2463",
"description": "Leave request`s start date" "description": "Leave request`s start date"
}, },
"end_date_time": { "end_date_time": {
"format": "date-time",
"type": "string", "type": "string",
"example": "25/03/3019", "example": "25/03/3019",
"description": "Leave request`s end date" "description": "Leave request`s end date"
@ -2690,8 +2666,6 @@
} }
}, },
"required": [ "required": [
"id",
"employee_id",
"bank_code_id", "bank_code_id",
"leave_type", "leave_type",
"start_date_time", "start_date_time",
@ -2700,19 +2674,13 @@
"approval_status" "approval_status"
] ]
}, },
"LeaveRequestViewDto": {
"type": "object",
"properties": {}
},
"UpdateLeaveRequestsDto": { "UpdateLeaveRequestsDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": {
"type": "number",
"example": 1,
"description": "Leave request`s unique id(auto-incremented)"
},
"employee_id": {
"type": "number",
"example": "4655867",
"description": "Employee`s id"
},
"bank_code_id": { "bank_code_id": {
"type": "number", "type": "number",
"example": 7, "example": 7,
@ -2724,13 +2692,11 @@
"description": "type of leave request for an accounting perception" "description": "type of leave request for an accounting perception"
}, },
"start_date_time": { "start_date_time": {
"format": "date-time",
"type": "string", "type": "string",
"example": "22/06/2463", "example": "22/06/2463",
"description": "Leave request`s start date" "description": "Leave request`s start date"
}, },
"end_date_time": { "end_date_time": {
"format": "date-time",
"type": "string", "type": "string",
"example": "25/03/3019", "example": "25/03/3019",
"description": "Leave request`s end date" "description": "Leave request`s end date"
@ -2845,7 +2811,7 @@
"description": "Customer`s email" "description": "Customer`s email"
}, },
"phone_number": { "phone_number": {
"type": "number", "type": "string",
"example": "8436637464", "example": "8436637464",
"description": "Customer`s phone number" "description": "Customer`s phone number"
}, },
@ -2898,7 +2864,7 @@
"description": "Customer`s email" "description": "Customer`s email"
}, },
"phone_number": { "phone_number": {
"type": "number", "type": "string",
"example": "8436637464", "example": "8436637464",
"description": "Customer`s phone number" "description": "Customer`s phone number"
}, },

View File

@ -1,7 +1,7 @@
import { PrismaClient, Roles } from '@prisma/client'; import { PrismaClient, Roles } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const BASE_PHONE = 1_100_000_000; // < 2_147_483_647 const BASE_PHONE = '1_100_000_000'; // < 2_147_483_647
function emailFor(i: number) { function emailFor(i: number) {
return `user${i + 1}@example.test`; return `user${i + 1}@example.test`;
@ -16,7 +16,7 @@ async function main() {
first_name: string; first_name: string;
last_name: string; last_name: string;
email: string; email: string;
phone_number: number; phone_number: string;
residence?: string | null; residence?: string | null;
role: Roles; role: Roles;
}[] = []; }[] = [];

View File

@ -5,7 +5,7 @@ import { ArchivalModule } from './modules/archival/archival.module';
import { AuthenticationModule } from './modules/authentication/auth.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 { BusinessLogicsModule } from './modules/business-logics/business-logics.module';
import { CsvExportModule } from './modules/exports/csv-exports.module'; // import { CsvExportModule } from './modules/exports/csv-exports.module';
import { CustomersModule } from './modules/customers/customers.module'; import { CustomersModule } from './modules/customers/customers.module';
import { EmployeesModule } from './modules/employees/employees.module'; import { EmployeesModule } from './modules/employees/employees.module';
import { ExpensesModule } from './modules/expenses/expenses.module'; import { ExpensesModule } from './modules/expenses/expenses.module';
@ -30,7 +30,7 @@ import { ConfigModule } from '@nestjs/config';
BankCodesModule, BankCodesModule,
BusinessLogicsModule, BusinessLogicsModule,
ConfigModule.forRoot({isGlobal: true}), ConfigModule.forRoot({isGlobal: true}),
CsvExportModule, // CsvExportModule,
CustomersModule, CustomersModule,
EmployeesModule, EmployeesModule,
ExpensesModule, ExpensesModule,

View File

@ -55,10 +55,10 @@ export class CreateCustomerDto {
example: '8436637464', example: '8436637464',
description: 'Customer`s phone number', description: 'Customer`s phone number',
}) })
@Type(() => Number) @IsString()
@IsInt() @IsInt()
@IsPositive() @IsPositive()
phone_number: number; phone_number: string;
@ApiProperty({ @ApiProperty({
example: '1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ', example: '1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ',

View File

@ -62,10 +62,8 @@ export class CreateEmployeeDto {
example: '82538437464', example: '82538437464',
description: 'Employee`s phone number', description: 'Employee`s phone number',
}) })
@Type(() => Number) @IsString()
@IsInt() phone_number: string;
@IsPositive()
phone_number: number;
@ApiProperty({ @ApiProperty({
example: '1 Bagshot Row, Hobbiton, The Shire, Middle-earth', example: '1 Bagshot Row, Hobbiton, The Shire, Middle-earth',

View File

@ -6,7 +6,7 @@ export class EmployeeProfileItemDto {
company_name: number | null; company_name: number | null;
job_title: string | null; job_title: string | null;
email: string | null; email: string | null;
phone_number: number; phone_number: string;
first_work_day: string; first_work_day: string;
last_work_day?: string | null; last_work_day?: string | null;
residence: string | null; residence: string | null;

View File

@ -17,6 +17,6 @@ export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) {
@IsOptional() @IsOptional()
supervisor_id?: number; supervisor_id?: number;
@Max(2147483647) @IsOptional()
phone_number: number; phone_number: string;
} }

View File

@ -2,7 +2,7 @@ import { Controller, Get, Header, Query, UseGuards } from "@nestjs/common";
import { RolesGuard } from "src/common/guards/roles.guard"; import { RolesGuard } from "src/common/guards/roles.guard";
import { Roles as RoleEnum } from '.prisma/client'; import { Roles as RoleEnum } from '.prisma/client';
import { CsvExportService } from "../services/csv-exports.service"; import { CsvExportService } from "../services/csv-exports.service";
import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto"; // import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
@ -11,34 +11,34 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators";
export class CsvExportController { export class CsvExportController {
constructor(private readonly csvService: CsvExportService) {} constructor(private readonly csvService: CsvExportService) {}
@Get('csv/:year/:period_no') // @Get('csv/:year/:period_no')
@Header('Content-Type', 'text/csv; charset=utf-8') // @Header('Content-Type', 'text/csv; charset=utf-8')
@Header('Content-Disposition', 'attachment; filename="export.csv"') // @Header('Content-Disposition', 'attachment; filename="export.csv"')
//@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR)
async exportCsv(@Query() options: ExportCsvOptionsDto, // async exportCsv(@Query() options: ExportCsvOptionsDto,
@Query('period') periodId: string ): Promise<Buffer> { // @Query('period') periodId: string ): Promise<Buffer> {
//modify to accept year and period_number // //modify to accept year and period_number
//sets default values // //sets default values
const companies = options.companies && options.companies.length ? options.companies : // const companies = options.companies && options.companies.length ? options.companies :
[ ExportCompany.TARGO, ExportCompany.SOLUCOM]; // [ ExportCompany.TARGO, ExportCompany.SOLUCOM];
const types = options.type && options.type.length ? options.type : // const types = options.type && options.type.length ? options.type :
Object.values(ExportType); // Object.values(ExportType);
//collects all // //collects all
const all = await this.csvService.collectTransaction(Number(periodId), companies); // const all = await this.csvService.collectTransaction(Number(periodId), companies);
//filters by type // //filters by type
const filtered = all.filter(row => { // const filtered = all.filter(row => {
switch (row.bank_code.toLocaleLowerCase()) { // switch (row.bank_code.toLocaleLowerCase()) {
case 'holiday' : return types.includes(ExportType.HOLIDAY); // case 'holiday' : return types.includes(ExportType.HOLIDAY);
case 'vacation' : return types.includes(ExportType.VACATION); // case 'vacation' : return types.includes(ExportType.VACATION);
case 'expenses' : return types.includes(ExportType.EXPENSES); // case 'expenses' : return types.includes(ExportType.EXPENSES);
default : return types.includes(ExportType.SHIFTS); // default : return types.includes(ExportType.SHIFTS);
} // }
}); // });
//generating the csv file // //generating the csv file
return this.csvService.generateCsv(filtered); // return this.csvService.generateCsv(filtered);
} // }
} }

View File

@ -31,38 +31,38 @@ type Filters = {
export class CsvExportService { export class CsvExportService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async collectTransaction( // async collectTransaction(
year: number, // year: number,
period_no: number, // period_no: number,
filters: Filters, // filters: Filters,
approved: boolean = true // approved: boolean = true
): Promise<CsvRow[]> { // ): Promise<CsvRow[]> {
//fetch period //fetch period
const period = await this.prisma.payPeriods.findFirst({ // 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 }, // select: { period_start: true, period_end: true },
}); // });
if(!period) throw new NotFoundException(`Pay period ${ year }-${ period_no } not found`); // if(!period) throw new NotFoundException(`Pay period ${ year }-${ period_no } not found`);
const start = period.period_start; // const start = period.period_start;
const end = period.period_end; // const end = period.period_end;
//fetch company codes from .env // //fetch company codes from .env
const comapany_codes = this.resolveCompanyCodes(filters.companies); // const comapany_codes = this.resolveCompanyCodes(filters.companies);
if(comapany_codes.length === 0) throw new BadRequestException('No company selected'); // if(comapany_codes.length === 0) throw new BadRequestException('No company selected');
//Flag types // //Flag types
const { shifts: want_shifts, expenses: want_expense, holiday: want_holiday, vacation: want_vacation } = filters.types; // const { shifts: want_shifts, expenses: want_expense, holiday: want_holiday, vacation: want_vacation } = filters.types;
if(!want_shifts && !want_expense && !want_holiday && !want_vacation) { // if(!want_shifts && !want_expense && !want_holiday && !want_vacation) {
throw new BadRequestException(' No export type selected '); // throw new BadRequestException(' No export type selected ');
} // }
const approved_filter = filters.approved? { is_approved: true } : {}; // const approved_filter = filters.approved? { is_approved: true } : {};
//Prisma queries // //Prisma queries
const [shifts, expenses] = await Promise.all([ // const [shifts, expenses] = await Promise.all([
want_shifts || want_expense || want_holiday || want_vacation // want_shifts || want_expense || want_holiday || want_vacation
]) // ])
@ -198,16 +198,16 @@ export class CsvExportService {
// } // }
//Final Mapping and sorts //Final Mapping and sorts
return rows.sort((a,b) => { // return rows.sort((a,b) => {
if(a.external_payroll_id !== b.external_payroll_id) { // if(a.external_payroll_id !== b.external_payroll_id) {
return a.external_payroll_id - b.external_payroll_id; // return a.external_payroll_id - b.external_payroll_id;
} // }
if(a.bank_code !== b.bank_code) { // if(a.bank_code !== b.bank_code) {
return a.bank_code.localeCompare(b.bank_code); // return a.bank_code.localeCompare(b.bank_code);
} // }
return a.week_number - b.week_number; // return a.week_number - b.week_number;
}); // });
} // }
resolveCompanyCodes(companies: { targo: boolean; solucom: boolean; }) { resolveCompanyCodes(companies: { targo: boolean; solucom: boolean; }) {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }

View File

@ -36,8 +36,8 @@ export class TimesheetsController {
@Query('period_no', ParseIntPipe ) period_no: number, @Query('period_no', ParseIntPipe ) period_no: number,
@Query('email') email?: string @Query('email') email?: string
): Promise<TimesheetPeriodDto> { ): Promise<TimesheetPeriodDto> {
if(!email || !email.trim()) throw new BadRequestException('Query param "email" is mandatory for this route.'); if(!email || !(email = email.trim())) throw new BadRequestException('Query param "email" is mandatory for this route.');
return this.timesheetsQuery.findAll(year, period_no, email.trim()); return this.timesheetsQuery.findAll(year, period_no, email);
} }
@Get(':id') @Get(':id')
@ -70,7 +70,7 @@ export class TimesheetsController {
return this.timesheetsQuery.remove(id); return this.timesheetsQuery.remove(id);
} }
@Patch(':id/approval') @Patch('approval/:id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
return this.timesheetsCommand.updateApproval(id, isApproved); return this.timesheetsCommand.updateApproval(id, isApproved);

View File

@ -27,13 +27,13 @@ export class DayExpensesDto {
export class WeekDto { export class WeekDto {
is_approved: boolean; is_approved: boolean;
shifts: { shifts: {
sun: DayShiftsDto; sun: DetailedShifts;
mon: DayShiftsDto; mon: DetailedShifts;
tue: DayShiftsDto; tue: DetailedShifts;
wed: DayShiftsDto; wed: DetailedShifts;
thu: DayShiftsDto; thu: DetailedShifts;
fri: DayShiftsDto; fri: DetailedShifts;
sat: DayShiftsDto; sat: DetailedShifts;
} }
expenses: { expenses: {
sun: DayExpensesDto; sun: DayExpensesDto;

View File

@ -5,14 +5,10 @@ import { Timesheets, TimesheetsArchive } from '@prisma/client';
import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto'; import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto';
import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; import { OvertimeService } from 'src/modules/business-logics/services/overtime.service';
import { computeHours } from 'src/common/utils/date-utils'; 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 { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers';
import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers';
// 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() @Injectable()
export class TimesheetsQueryService { export class TimesheetsQueryService {
@ -21,8 +17,6 @@ export class TimesheetsQueryService {
private readonly overtime: OvertimeService, private readonly overtime: OvertimeService,
) {} ) {}
async create(dto : CreateTimesheetDto): Promise<Timesheets> { async create(dto : CreateTimesheetDto): Promise<Timesheets> {
const { employee_id, is_approved } = dto; const { employee_id, is_approved } = dto;
return this.prisma.timesheets.create({ return this.prisma.timesheets.create({
@ -74,7 +68,7 @@ export class TimesheetsQueryService {
}), }),
]); ]);
//Shift data mapping // data mapping
const shifts: ShiftRow[] = raw_shifts.map(shift => ({ const shifts: ShiftRow[] = raw_shifts.map(shift => ({
date: shift.date, date: shift.date,
start_time: shift.start_time, start_time: shift.start_time,

View File

@ -1,12 +1,12 @@
import { DayExpensesDto, DayShiftsDto, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto"; import { DayExpensesDto, DayShiftsDto, DetailedShifts, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto";
//makes the strings indexes for arrays //makes the strings indexes for arrays
export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
export type DayKey = 'sun'|'mon'|'tue'|'wed'|'thu'|'fri'|'sat'; export type DayKey = typeof DAY_KEYS[number];
//DB line types //DB line types
type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean }; export 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 type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean };
export function dayKeyFromDate(date: Date, useUTC = true): DayKey { export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday
@ -14,6 +14,7 @@ export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
} }
const MS_PER_DAY = 86_400_000; const MS_PER_DAY = 86_400_000;
const MS_PER_HOUR = 3_600_000;
export function toUTCDateOnly(date: Date | string): Date { export function toUTCDateOnly(date: Date | string): Date {
const d = new Date(date); const d = new Date(date);
@ -34,12 +35,6 @@ export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): bool
return time >= start.getTime() && time <= end_inclusive.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 { export function toTimeString(date: Date): string {
const hours = String(date.getUTCHours()).padStart(2,'0'); const hours = String(date.getUTCHours()).padStart(2,'0');
const minutes = String(date.getUTCMinutes()).padStart(2,'0'); const minutes = String(date.getUTCMinutes()).padStart(2,'0');
@ -50,21 +45,33 @@ export function round2(num: number) {
return Math.round(num * 100) / 100; return Math.round(num * 100) / 100;
} }
export function makeEmptyDayShifts(): DayShiftsDto { return []; } 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 []; }
export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; } export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; }
export function makeEmptyWeek(): WeekDto { export function makeEmptyWeek(week_start: Date): WeekDto {
const make_empty_shifts = (offset: number): DetailedShifts => ({
shifts: [],
total_hours: 0,
short_date: shortDate(addDays(week_start, offset)),
break_durations: undefined,
});
return { return {
is_approved: true, is_approved: true,
shifts: { shifts: {
sun: makeEmptyDayShifts(), sun: make_empty_shifts(0),
mon: makeEmptyDayShifts(), mon: make_empty_shifts(1),
tue: makeEmptyDayShifts(), tue: make_empty_shifts(2),
wed: makeEmptyDayShifts(), wed: make_empty_shifts(3),
thu: makeEmptyDayShifts(), thu: make_empty_shifts(4),
fri: makeEmptyDayShifts(), fri: make_empty_shifts(5),
sat: makeEmptyDayShifts(), sat: make_empty_shifts(6),
}, },
expenses: { expenses: {
sun: makeEmptyDayExpenses(), sun: makeEmptyDayExpenses(),
@ -79,7 +86,7 @@ export function makeEmptyWeek(): WeekDto {
} }
export function makeEmptyPeriod(): TimesheetPeriodDto { export function makeEmptyPeriod(): TimesheetPeriodDto {
return { week1: makeEmptyWeek(), week2: makeEmptyWeek() }; return { week1: makeEmptyWeek(new Date()), week2: makeEmptyWeek(new Date()) };
} }
//needs ajusting according to DB's data for expenses types //needs ajusting according to DB's data for expenses types
@ -89,16 +96,27 @@ export function normalizeExpenseBucket(db_type: string): 'km' | 'cash' {
return 'cash'; return 'cash';
} }
export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): WeekDto { export function buildWeek(
const week = makeEmptyWeek(); week_start: Date,
week_end: Date,
shifts: ShiftRow[],
expenses: ExpenseRow[],
): WeekDto {
const week = makeEmptyWeek(week_start);
let all_approved = true; let all_approved = true;
//array of shifts per day ( to check for break_gaps and calculate daily total hours )
const dayTimes: Record<DayKey, Array<{start:Date; end: Date;}>> = {
sun: [], mon: [], tue: [], wed: [],thu: [], fri: [], sat: [],
};
//Shifts mapped and filtered by dates //Shifts mapped and filtered by dates
const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end)); const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end));
for (const shift of week_shifts) { for (const shift of week_shifts) {
const key = dayKeyFromDate(shift.date, true); const key = dayKeyFromDate(shift.date, true);
week.shifts[key].push({ dayTimes[key].push({start: shift.start_time, end:shift.end_time });
shifts: [], week.shifts[key].shifts.push({
start: toTimeString(shift.start_time), start: toTimeString(shift.start_time),
end : toTimeString(shift.end_time), end : toTimeString(shift.end_time),
is_approved: shift.is_approved ?? true, is_approved: shift.is_approved ?? true,
@ -118,11 +136,38 @@ export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[],
}); });
all_approved = all_approved && (expense.is_approved ?? true); 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());
//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);
//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);
}
}
week.is_approved = all_approved; week.is_approved = all_approved;
return week; return week;
} }
export function buildPeriod( period_start: Date, period_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): TimesheetPeriodDto { export function buildPeriod(
period_start: Date,
period_end: Date,
shifts: ShiftRow[],
expenses: ExpenseRow[]
): TimesheetPeriodDto {
const week1_start = toUTCDateOnly(period_start); const week1_start = toUTCDateOnly(period_start);
const week1_end = endOfDayUTC(addDays(week1_start, 6)); const week1_end = endOfDayUTC(addDays(week1_start, 6));
const week2_start = toUTCDateOnly(addDays(week1_start, 7)); const week2_start = toUTCDateOnly(addDays(week1_start, 7));