diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 94438f6..41fa85a 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -1536,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": { "post": { "operationId": "CustomersController_create", diff --git a/src/app.module.ts b/src/app.module.ts index c1a20e8..7a4aadf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,7 +5,7 @@ import { ArchivalModule } from './modules/archival/archival.module'; import { AuthenticationModule } from './modules/authentication/auth.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 { 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'; @@ -30,7 +30,7 @@ import { ConfigModule } from '@nestjs/config'; BankCodesModule, BusinessLogicsModule, ConfigModule.forRoot({isGlobal: true}), - CsvExportModule, + // CsvExportModule, CustomersModule, EmployeesModule, ExpensesModule, diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index daf6e96..4fce9e0 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../../../prisma/prisma.service"; import { computeHours, getWeekStart } from "src/common/utils/date-utils"; @@ -8,7 +8,23 @@ export class HolidayService { constructor(private readonly prisma: PrismaService) {} - //switch employeeId for email + //fetch employee_id by email + private async resolveEmployeeByEmail(email: string): Promise { + const employee = await this.prisma.employees.findFirst({ + where: { + user: { email } + }, + select: { id: true }, + }); + if(!employee) throw new NotFoundException(`Employee with email : ${email} not found`); + return employee.id; + } + + private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise { + const employee_id = await this.resolveEmployeeByEmail(email); + return this.computeHoursPrevious4Weeks(employee_id, holiday_date) + } + private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise { //sets the end of the window to 1ms before the week with the holiday const holiday_week_start = getWeekStart(holiday_date); @@ -32,9 +48,8 @@ export class HolidayService { return daily_hours; } - //switch employeeId for email - async calculateHolidayPay( employee_id: number, holiday_date: Date, modifier: number): Promise { - const hours = await this.computeHoursPrevious4Weeks(employee_id, holiday_date); + async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise { + const hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date); const daily_rate = Math.min(hours, 8); this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`); return daily_rate * modifier; diff --git a/src/modules/customers/dtos/create-customer.dto.ts b/src/modules/customers/dtos/create-customer.dto.ts index 24289f8..8382f20 100644 --- a/src/modules/customers/dtos/create-customer.dto.ts +++ b/src/modules/customers/dtos/create-customer.dto.ts @@ -56,7 +56,6 @@ export class CreateCustomerDto { description: 'Customer`s phone number', }) @IsString() - @IsPositive() phone_number: string; @ApiProperty({ diff --git a/src/modules/employees/dtos/create-employee.dto.ts b/src/modules/employees/dtos/create-employee.dto.ts index 2fb22aa..89279ef 100644 --- a/src/modules/employees/dtos/create-employee.dto.ts +++ b/src/modules/employees/dtos/create-employee.dto.ts @@ -63,7 +63,6 @@ export class CreateEmployeeDto { description: 'Employee`s phone number', }) @IsString() - @IsPositive() phone_number: string; @ApiProperty({ diff --git a/src/modules/employees/dtos/update-employee.dto.ts b/src/modules/employees/dtos/update-employee.dto.ts index 3bf49bd..334a01a 100644 --- a/src/modules/employees/dtos/update-employee.dto.ts +++ b/src/modules/employees/dtos/update-employee.dto.ts @@ -17,6 +17,6 @@ export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) { @IsOptional() supervisor_id?: number; - @Max(2147483647) + @IsOptional() phone_number: string; } diff --git a/src/modules/exports/controllers/csv-exports.controller.ts b/src/modules/exports/controllers/csv-exports.controller.ts index f59e84c..c3363f9 100644 --- a/src/modules/exports/controllers/csv-exports.controller.ts +++ b/src/modules/exports/controllers/csv-exports.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, Header, Query, UseGuards } from "@nestjs/common"; import { RolesGuard } from "src/common/guards/roles.guard"; import { Roles as RoleEnum } from '.prisma/client'; 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"; @@ -11,35 +11,34 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators"; export class CsvExportController { constructor(private readonly csvService: CsvExportService) {} - @Get('csv') - @Header('Content-Type', 'text/csv; charset=utf-8') - @Header('Content-Dispoition', 'attachment; filename="export.csv"') - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) - async exportCsv(@Query() options: ExportCsvOptionsDto, - @Query('period') periodId: string ): Promise { - - //sets default values - const companies = options.companies && options.companies.length ? options.companies : - [ ExportCompany.TARGO, ExportCompany.SOLUCOM]; - const types = options.type && options.type.length ? options.type : - Object.values(ExportType); + // @Get('csv/:year/:period_no') + // @Header('Content-Type', 'text/csv; charset=utf-8') + // @Header('Content-Disposition', 'attachment; filename="export.csv"') + // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) + // async exportCsv(@Query() options: ExportCsvOptionsDto, + // @Query('period') periodId: string ): Promise { + // //modify to accept year and period_number + // //sets default values + // const companies = options.companies && options.companies.length ? options.companies : + // [ ExportCompany.TARGO, ExportCompany.SOLUCOM]; + // const types = options.type && options.type.length ? options.type : + // Object.values(ExportType); - //collects all - const all = await this.csvService.collectTransaction(Number(periodId), companies); + // //collects all + // const all = await this.csvService.collectTransaction(Number(periodId), companies); - //filters by type - const filtered = all.filter(r => { - switch (r.bank_code.toLocaleLowerCase()) { - case 'holiday' : return types.includes(ExportType.HOLIDAY); - case 'vacation' : return types.includes(ExportType.VACATION); - case 'sick-leave': return types.includes(ExportType.SICK_LEAVE); - case 'expenses' : return types.includes(ExportType.EXPENSES); - default : return types.includes(ExportType.SHIFTS); - } - }); + // //filters by type + // const filtered = all.filter(row => { + // switch (row.bank_code.toLocaleLowerCase()) { + // case 'holiday' : return types.includes(ExportType.HOLIDAY); + // case 'vacation' : return types.includes(ExportType.VACATION); + // case 'expenses' : return types.includes(ExportType.EXPENSES); + // default : return types.includes(ExportType.SHIFTS); + // } + // }); - //generating the csv file - return this.csvService.generateCsv(filtered); - } + // //generating the csv file + // return this.csvService.generateCsv(filtered); + // } } \ No newline at end of file diff --git a/src/modules/exports/dtos/export-csv-options.dto.ts b/src/modules/exports/dtos/export-csv-options.dto.ts index dc969ad..f2a2b49 100644 --- a/src/modules/exports/dtos/export-csv-options.dto.ts +++ b/src/modules/exports/dtos/export-csv-options.dto.ts @@ -1,26 +1,47 @@ -import { IsArray, IsEnum, IsOptional } from "class-validator"; +import { Transform } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator"; -export enum ExportType { - SHIFTS = 'Quart de travail', - EXPENSES = 'Depenses', - HOLIDAY = 'Ferie', - VACATION = 'Vacance', - SICK_LEAVE = 'Absence' -} - -export enum ExportCompany { - TARGO = 'Targo', - SOLUCOM = 'Solucom', +function toBoolean(v: any) { + if(typeof v === 'boolean') return v; + if(typeof v === 'string') return ['true', '1', 'on','yes'].includes(v.toLowerCase()); + return false; } export class ExportCsvOptionsDto { - @IsOptional() - @IsArray() - @IsEnum(ExportCompany, { each: true }) - companies?: ExportCompany[]; - @IsOptional() - @IsArray() - @IsEnum(ExportType, { each: true }) - type?: ExportType[]; + @Transform(({ value }) => parseInt(value,10)) + @IsInt() @Min(2023) + year! : number; + + @Transform(({ value }) => parseInt(value,10)) + @IsInt() @Min(1) @Max(26) + period_no!: number; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + approved? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + shifts? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + expenses? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + holiday? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + vacation? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + targo? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + solucom? : boolean = true; } \ No newline at end of file diff --git a/src/modules/exports/services/csv-exports.service.ts b/src/modules/exports/services/csv-exports.service.ts index ed9fab2..d8bd73e 100644 --- a/src/modules/exports/services/csv-exports.service.ts +++ b/src/modules/exports/services/csv-exports.service.ts @@ -1,6 +1,5 @@ import { PrismaService } from "src/prisma/prisma.service"; -import { ExportCompany } from "../dtos/export-csv-options.dto"; -import { Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; export interface CsvRow { company_code: number; @@ -14,146 +13,203 @@ export interface CsvRow { holiday_date?: string; } +type Filters = { + types: { + shifts: boolean; + expenses: boolean; + holiday: boolean; + vacation: boolean; + }; + companies: { + targo: boolean; + solucom: boolean; + }; + approved: boolean; +}; + @Injectable() export class CsvExportService { constructor(private readonly prisma: PrismaService) {} - async collectTransaction( period_id: number, companies: ExportCompany[], approved: boolean = true): - Promise { + // async collectTransaction( + // year: number, + // period_no: number, + // filters: Filters, + // approved: boolean = true + // ): Promise { + //fetch period + // const period = await this.prisma.payPeriods.findFirst({ + // where: { pay_year: year, pay_period_no: period_no }, + // select: { period_start: true, period_end: true }, + // }); + // if(!period) throw new NotFoundException(`Pay period ${ year }-${ period_no } not found`); - const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); + // const start = period.period_start; + // const end = period.period_end; - const period = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no: period_id }, - }); - if(!period) throw new NotFoundException(`Pay period ${period_id} not found`); + // //fetch company codes from .env + // const comapany_codes = this.resolveCompanyCodes(filters.companies); + // if(comapany_codes.length === 0) throw new BadRequestException('No company selected'); - const start_date = period.period_start; - const end_date = period.period_end; + // //Flag 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) { + // throw new BadRequestException(' No export type selected '); + // } - const approved_filter = approved ? { is_approved: true } : {}; + // const approved_filter = filters.approved? { is_approved: true } : {}; - //fetching shifts - const shifts = await this.prisma.shifts.findMany({ - where: { - date: { gte: start_date, lte: end_date }, - ...approved_filter, - timesheet: { - employee: { company_code: { in: company_codes} } }, - }, - include: { - bank_code: true, - timesheet: { include: { - employee: { include: { - user:true, - supervisor: { include: { - user:true } } } } } }, - }, - }); + // //Prisma queries + // const [shifts, expenses] = await Promise.all([ + // want_shifts || want_expense || want_holiday || want_vacation + // ]) - //fetching expenses - const expenses = await this.prisma.expenses.findMany({ - where: { - date: { gte: start_date, lte: end_date }, - ...approved_filter, - timesheet: { employee: { company_code: { in: company_codes} } }, - }, - include: { bank_code: true, - timesheet: { include: { - employee: { include: { - user: true, - supervisor: { include: { - user:true } } } } } }, - }, - }); - //fetching leave requests - const leaves = await this.prisma.leaveRequests.findMany({ - where : { - start_date_time: { gte: start_date, lte: end_date }, - employee: { company_code: { in: company_codes } }, - }, - include: { - bank_code: true, - employee: { include: { - user: true, - supervisor: { include: { - user: true } } } }, - }, - }); - const rows: CsvRow[] = []; - //Shifts Mapping - for (const shift of shifts) { - const emp = shift.timesheet.employee; - const week_number = this.computeWeekNumber(start_date, shift.date); - const hours = this.computeHours(shift.start_time, shift.end_time); - rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: shift.bank_code.bank_code, - quantity_hours: hours, - amount: undefined, - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); - } - //Expenses Mapping - for (const e of expenses) { - const emp = e.timesheet.employee; - const week_number = this.computeWeekNumber(start_date, e.date); - rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: e.bank_code.bank_code, - quantity_hours: undefined, - amount: Number(e.amount), - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); - } + // const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); - //Leaves Mapping - for(const l of leaves) { - if(!l.bank_code) continue; - const emp = l.employee; - const start = l.start_date_time; - const end = l.end_date_time ?? start; + // const period = await this.prisma.payPeriods.findFirst({ + // where: { pay_period_no: period_id }, + // }); + // if(!period) throw new NotFoundException(`Pay period ${period_id} not found`); - const week_number = this.computeWeekNumber(start_date, start); - const hours = this.computeHours(start, end); + // const start_date = period.period_start; + // const end_date = period.period_end; - rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: l.bank_code.bank_code, - quantity_hours: hours, - amount: undefined, - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); - } + // const included_shifts = await this.prisma.shifts.findMany({ + // where: { } + // }) + + // const approved_filter = approved ? { is_approved: true } : {}; + + // //fetching shifts + // const shifts = await this.prisma.shifts.findMany({ + // where: { + // date: { gte: start_date, lte: end_date }, + // ...approved_filter, + // timesheet: { + // employee: { company_code: { in: company_codes} } }, + // }, + // include: { + // bank_code: true, + // timesheet: { include: { + // employee: { include: { + // user:true, + // supervisor: { include: { + // user:true } } } } } }, + // }, + // }); + + // //fetching expenses + // const expenses = await this.prisma.expenses.findMany({ + // where: { + // date: { gte: start_date, lte: end_date }, + // ...approved_filter, + // timesheet: { employee: { company_code: { in: company_codes} } }, + // }, + // include: { bank_code: true, + // timesheet: { include: { + // employee: { include: { + // user: true, + // supervisor: { include: { + // user:true } } } } } }, + // }, + // }); + + // //fetching leave requests + // const leaves = await this.prisma.leaveRequests.findMany({ + // where : { + // start_date_time: { gte: start_date, lte: end_date }, + // employee: { company_code: { in: company_codes } }, + // }, + // include: { + // bank_code: true, + // employee: { include: { + // user: true, + // supervisor: { include: { + // user: true } } } }, + // }, + // }); + + // const rows: CsvRow[] = []; + + // //Shifts Mapping + // for (const shift of shifts) { + // const emp = shift.timesheet.employee; + // const week_number = this.computeWeekNumber(start_date, shift.date); + // const hours = this.computeHours(shift.start_time, shift.end_time); + + // rows.push({ + // company_code: emp.company_code, + // external_payroll_id: emp.external_payroll_id, + // full_name: `${emp.user.first_name} ${emp.user.last_name}`, + // bank_code: shift.bank_code.bank_code, + // quantity_hours: hours, + // amount: undefined, + // week_number, + // pay_date: this.formatDate(end_date), + // holiday_date: undefined, + // }); + // } + + // //Expenses Mapping + // for (const e of expenses) { + // const emp = e.timesheet.employee; + // const week_number = this.computeWeekNumber(start_date, e.date); + + // rows.push({ + // company_code: emp.company_code, + // external_payroll_id: emp.external_payroll_id, + // full_name: `${emp.user.first_name} ${emp.user.last_name}`, + // bank_code: e.bank_code.bank_code, + // quantity_hours: undefined, + // amount: Number(e.amount), + // week_number, + // pay_date: this.formatDate(end_date), + // holiday_date: undefined, + // }); + // } + + // //Leaves Mapping + // for(const l of leaves) { + // if(!l.bank_code) continue; + // const emp = l.employee; + // const start = l.start_date_time; + // const end = l.end_date_time ?? start; + + // const week_number = this.computeWeekNumber(start_date, start); + // const hours = this.computeHours(start, end); + + // rows.push({ + // company_code: emp.company_code, + // external_payroll_id: emp.external_payroll_id, + // full_name: `${emp.user.first_name} ${emp.user.last_name}`, + // bank_code: l.bank_code.bank_code, + // quantity_hours: hours, + // amount: undefined, + // week_number, + // pay_date: this.formatDate(end_date), + // holiday_date: undefined, + // }); + // } //Final Mapping and sorts - return rows.sort((a,b) => { - if(a.external_payroll_id !== b.external_payroll_id) { - return a.external_payroll_id - b.external_payroll_id; - } - if(a.bank_code !== b.bank_code) { - return a.bank_code.localeCompare(b.bank_code); - } - return a.week_number - b.week_number; - }); + // return rows.sort((a,b) => { + // if(a.external_payroll_id !== b.external_payroll_id) { + // return a.external_payroll_id - b.external_payroll_id; + // } + // if(a.bank_code !== b.bank_code) { + // return a.bank_code.localeCompare(b.bank_code); + // } + // return a.week_number - b.week_number; + // }); + // } + resolveCompanyCodes(companies: { targo: boolean; solucom: boolean; }) { + throw new Error("Method not implemented."); } generateCsv(rows: CsvRow[]): Buffer { @@ -172,7 +228,7 @@ export class CsvExportService { const body = rows.map(r => [ r.company_code, r.external_payroll_id, - `${r.full_name.replace(/"/g, '""')}"`, + `${r.full_name.replace(/"/g, '""')}`, r.bank_code, r.quantity_hours?.toFixed(2) ?? '', r.week_number, diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index eb495b8..7280c3c 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -36,8 +36,8 @@ export class TimesheetsController { @Query('period_no', ParseIntPipe ) period_no: number, @Query('email') email?: string ): Promise { - if(!email || !email.trim()) throw new BadRequestException('Query param "email" is mandatory for this route.'); - return this.timesheetsQuery.findAll(year, period_no, email.trim()); + if(!email || !(email = email.trim())) throw new BadRequestException('Query param "email" is mandatory for this route.'); + return this.timesheetsQuery.findAll(year, period_no, email); } @Get(':id') diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index 71a8e2f..e383b08 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -11,6 +11,13 @@ export class ExpenseDto { export type DayShiftsDto = ShiftDto[]; +export class DetailedShifts { + shifts: DayShiftsDto; + total_hours: number; + short_date: string; + break_durations?: number; +} + export class DayExpensesDto { cash: ExpenseDto[] = []; km : ExpenseDto[] = []; @@ -20,13 +27,13 @@ export class DayExpensesDto { export class WeekDto { is_approved: boolean; shifts: { - sun: DayShiftsDto; - mon: DayShiftsDto; - tue: DayShiftsDto; - wed: DayShiftsDto; - thu: DayShiftsDto; - fri: DayShiftsDto; - sat: DayShiftsDto; + sun: DetailedShifts; + mon: DetailedShifts; + tue: DetailedShifts; + wed: DetailedShifts; + thu: DetailedShifts; + fri: DetailedShifts; + sat: DetailedShifts; } expenses: { sun: DayExpensesDto; diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 0fe08ee..cb6f67f 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -5,14 +5,10 @@ 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 } from 'src/common/utils/date-utils'; -import { buildPrismaWhere } from 'src/common/shared/build-prisma-where'; -import { SearchTimesheetDto } from '../dtos/search-timesheet.dto'; import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; +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() export class TimesheetsQueryService { @@ -21,8 +17,6 @@ export class TimesheetsQueryService { private readonly overtime: OvertimeService, ) {} - - async create(dto : CreateTimesheetDto): Promise { const { employee_id, is_approved } = dto; return this.prisma.timesheets.create({ @@ -74,7 +68,7 @@ export class TimesheetsQueryService { }), ]); - //Shift data mapping + // data mapping const shifts: ShiftRow[] = raw_shifts.map(shift => ({ date: shift.date, start_time: shift.start_time, diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 6dfef18..7e32901 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -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 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 -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 ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean }; +export type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean }; export function dayKeyFromDate(date: Date, useUTC = true): DayKey { const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday @@ -14,6 +14,7 @@ export function dayKeyFromDate(date: Date, useUTC = true): DayKey { } const MS_PER_DAY = 86_400_000; +const MS_PER_HOUR = 3_600_000; export function toUTCDateOnly(date: Date | string): 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(); } -export function dayIndex(week_start: Date, date: Date): 1|2|3|4|5|6|7 { - const diff = Math.floor((toUTCDateOnly(date).getTime() - toUTCDateOnly(week_start).getTime())/ MS_PER_DAY); - const index = Math.min(6, Math.max(0, diff)) + 1; - return index as 1|2|3|4|5|6|7; -} - export function toTimeString(date: Date): string { const hours = String(date.getUTCHours()).padStart(2,'0'); const minutes = String(date.getUTCMinutes()).padStart(2,'0'); @@ -50,21 +45,33 @@ export function round2(num: number) { 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 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 { is_approved: true, shifts: { - sun: makeEmptyDayShifts(), - mon: makeEmptyDayShifts(), - tue: makeEmptyDayShifts(), - wed: makeEmptyDayShifts(), - thu: makeEmptyDayShifts(), - fri: makeEmptyDayShifts(), - sat: makeEmptyDayShifts(), + sun: make_empty_shifts(0), + mon: make_empty_shifts(1), + tue: make_empty_shifts(2), + wed: make_empty_shifts(3), + thu: make_empty_shifts(4), + fri: make_empty_shifts(5), + sat: make_empty_shifts(6), }, expenses: { sun: makeEmptyDayExpenses(), @@ -79,7 +86,7 @@ export function makeEmptyWeek(): WeekDto { } 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 @@ -89,15 +96,27 @@ export function normalizeExpenseBucket(db_type: string): 'km' | 'cash' { return 'cash'; } -export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): WeekDto { - const week = makeEmptyWeek(); +export function buildWeek( + week_start: Date, + week_end: Date, + shifts: ShiftRow[], + expenses: ExpenseRow[], + ): WeekDto { + const week = makeEmptyWeek(week_start); let all_approved = true; + //array of shifts per day ( to check for break_gaps and calculate daily total hours ) + const dayTimes: Record> = { + sun: [], mon: [], tue: [], wed: [],thu: [], fri: [], sat: [], + }; + + //Shifts mapped and filtered by dates const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end)); for (const shift of week_shifts) { const key = dayKeyFromDate(shift.date, true); - week.shifts[key].push({ + dayTimes[key].push({start: shift.start_time, end:shift.end_time }); + week.shifts[key].shifts.push({ start: toTimeString(shift.start_time), end : toTimeString(shift.end_time), is_approved: shift.is_approved ?? true, @@ -117,11 +136,38 @@ export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], }); 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; 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_end = endOfDayUTC(addDays(week1_start, 6)); const week2_start = toUTCDateOnly(addDays(week1_start, 7));