Merge branch 'main' of git.targo.ca:Targo/targo_backend into auth-session-fullstack
This commit is contained in:
commit
5621f72342
|
|
@ -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": {
|
"/shifts": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "ShiftsController_create",
|
"operationId": "ShiftsController_create",
|
||||||
|
|
@ -2513,6 +2559,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"UpsertShiftDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
"CreateShiftDto": {
|
"CreateShiftDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { BadRequestException, Module, ValidationPipe } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { ArchivalModule } from './modules/archival/archival.module';
|
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 { TimesheetsModule } from './modules/timesheets/timesheets.module';
|
||||||
import { UsersModule } from './modules/users-management/users.module';
|
import { UsersModule } from './modules/users-management/users.module';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -46,6 +49,29 @@ import { ConfigModule } from '@nestjs/config';
|
||||||
UsersModule,
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, HealthController],
|
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 {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
24
src/common/filters/http-exception.filter.ts
Normal file
24
src/common/filters/http-exception.filter.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/common";
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Catch(HttpException)
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: HttpException, host: ArgumentsHost) {
|
||||||
|
const http_context = host.switchToHttp();
|
||||||
|
const response = http_context.getResponse<Response>();
|
||||||
|
const request = http_context.getRequest<Request>();
|
||||||
|
const http_status = exception.getStatus();
|
||||||
|
|
||||||
|
const exception_response = exception.getResponse();
|
||||||
|
const normalized = typeof exception_response === 'string'
|
||||||
|
? { message: exception_response }
|
||||||
|
: (exception_response as Record<string, unknown>);
|
||||||
|
const response_body = {
|
||||||
|
statusCode: http_status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
...normalized,
|
||||||
|
};
|
||||||
|
response.status(http_status).json(response_body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 { ModuleRef, NestFactory, Reflector } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
|
||||||
// import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard';
|
// import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './common/guards/roles.guard';
|
import { RolesGuard } from './common/guards/roles.guard';
|
||||||
import { OwnershipGuard } from './common/guards/ownership.guard';
|
import { OwnershipGuard } from './common/guards/ownership.guard';
|
||||||
|
|
@ -25,8 +24,6 @@ async function bootstrap() {
|
||||||
|
|
||||||
const reflector = app.get(Reflector); //setup Reflector for Roles()
|
const reflector = app.get(Reflector); //setup Reflector for Roles()
|
||||||
|
|
||||||
app.useGlobalPipes(
|
|
||||||
new ValidationPipe({ whitelist: true, transform: true}));
|
|
||||||
app.useGlobalGuards(
|
app.useGlobalGuards(
|
||||||
// new JwtAuthGuard(reflector), //Authentification JWT
|
// new JwtAuthGuard(reflector), //Authentification JWT
|
||||||
new RolesGuard(reflector), //deny-by-default and Role-based Access Control
|
new RolesGuard(reflector), //deny-by-default and Role-based Access Control
|
||||||
|
|
|
||||||
|
|
@ -239,10 +239,18 @@ export class PayPeriodsQueryService {
|
||||||
const hours = computeHours(shift.start_time, shift.end_time);
|
const hours = computeHours(shift.start_time, shift.end_time);
|
||||||
const type = (shift.bank_code?.type ?? '').toUpperCase();
|
const type = (shift.bank_code?.type ?? '').toUpperCase();
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "EVENING": record.evening_hours += hours; record.total_hours += hours; break;
|
case "EVENING": record.evening_hours += hours;
|
||||||
case "EMERGENCY": record.emergency_hours += hours; record.total_hours += hours; break;
|
record.total_hours += hours;
|
||||||
case "OVERTIME": record.overtime_hours += hours; record.total_hours += hours; break;
|
break;
|
||||||
case "REGULAR" : record.regular_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;
|
record.is_approved = record.is_approved && shift.timesheet.is_approved;
|
||||||
|
|
|
||||||
|
|
@ -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 { Shifts } from "@prisma/client";
|
||||||
import { CreateShiftDto } from "../dtos/create-shift.dto";
|
import { CreateShiftDto } from "../dtos/create-shift.dto";
|
||||||
import { UpdateShiftsDto } from "../dtos/update-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 { SearchShiftsDto } from "../dtos/search-shift.dto";
|
||||||
import { OverviewRow, ShiftsQueryService } from "../services/shifts-query.service";
|
import { OverviewRow, ShiftsQueryService } from "../services/shifts-query.service";
|
||||||
import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
|
import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
|
||||||
|
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
||||||
|
|
||||||
@ApiTags('Shifts')
|
@ApiTags('Shifts')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
|
|
@ -17,10 +18,19 @@ import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
|
||||||
export class ShiftsController {
|
export class ShiftsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly shiftsService: ShiftsQueryService,
|
private readonly shiftsService: ShiftsQueryService,
|
||||||
private readonly shiftsApprovalService: ShiftsCommandService,
|
private readonly shiftsCommandService: ShiftsCommandService,
|
||||||
private readonly shiftsValidationService: ShiftsQueryService,
|
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()
|
@Post()
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
@ApiOperation({ summary: 'Create shift' })
|
@ApiOperation({ summary: 'Create shift' })
|
||||||
|
|
@ -70,7 +80,7 @@ export class ShiftsController {
|
||||||
@Patch('approval/:id')
|
@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.shiftsApprovalService.updateApproval(id, isApproved);
|
return this.shiftsCommandService.updateApproval(id, isApproved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
|
|
|
||||||
37
src/modules/shifts/dtos/upsert-shift.dto.ts
Normal file
37
src/modules/shifts/dtos/upsert-shift.dto.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
export const COMMENT_MAX_LENGTH = 512;
|
||||||
|
|
||||||
|
export class ShiftPayloadDto {
|
||||||
|
|
||||||
|
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
|
||||||
|
start_time!: string;
|
||||||
|
|
||||||
|
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
|
||||||
|
end_time!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
is_remote!: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(COMMENT_MAX_LENGTH)
|
||||||
|
comment?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class UpsertShiftDto {
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(()=> ShiftPayloadDto)
|
||||||
|
old_shift?: ShiftPayloadDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(()=> ShiftPayloadDto)
|
||||||
|
new_shift?: ShiftPayloadDto;
|
||||||
|
};
|
||||||
18
src/modules/shifts/helpers/shifts-date-time-helpers.ts
Normal file
18
src/modules/shifts/helpers/shifts-date-time-helpers.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
export function timeFromHHMMUTC(hhmm: string): Date {
|
||||||
|
const [hour, min] = hhmm.split(':').map(Number);
|
||||||
|
return new Date(Date.UTC(1970,0,1,hour, min,0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weekStartMondayUTC(date: Date): Date {
|
||||||
|
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||||
|
const day = d.getUTCDay();
|
||||||
|
const diff = (day + 6) % 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() - diff);
|
||||||
|
d.setUTCHours(0,0,0,0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDateOnlyUTC(input: string | Date): Date {
|
||||||
|
const date = new Date(input);
|
||||||
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||||
|
}
|
||||||
|
|
@ -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 { Prisma, Shifts } from "@prisma/client";
|
||||||
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.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()
|
@Injectable()
|
||||||
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
|
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
|
||||||
constructor(prisma: PrismaService) { super(prisma); }
|
constructor(prisma: PrismaService) { super(prisma); }
|
||||||
|
|
||||||
|
//create/update/delete master method
|
||||||
|
async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto):
|
||||||
|
Promise<{ action: UpsertAction; day: DayShiftResponse[] }> {
|
||||||
|
const { old_shift, new_shift } = dto;
|
||||||
|
|
||||||
|
if(!dto.old_shift && !dto.new_shift) {
|
||||||
|
throw new BadRequestException('At least one of old or new shift must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const date_only = toDateOnlyUTC(date_string);
|
||||||
|
|
||||||
|
//Resolve employee by email
|
||||||
|
const employee = await this.prisma.employees.findFirst({
|
||||||
|
where: { user: {email } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if(!employee) throw new NotFoundException(`Employee not found for email : ${ email }`);
|
||||||
|
|
||||||
|
//making sure a timesheet exist in selected week
|
||||||
|
const start_of_week = weekStartMondayUTC(date_only);
|
||||||
|
let timesheet = await this.prisma.timesheets.findFirst({
|
||||||
|
where: {
|
||||||
|
employee_id: employee.id,
|
||||||
|
start_date: start_of_week
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if(!timesheet) {
|
||||||
|
timesheet = await this.prisma.timesheets.create({
|
||||||
|
data: {
|
||||||
|
employee_id: employee.id,
|
||||||
|
start_date: start_of_week
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//normalization of data to ensure a valid comparison between DB and payload
|
||||||
|
const old_norm = dto.old_shift
|
||||||
|
? this.normalize_shift_payload(dto.old_shift)
|
||||||
|
: undefined;
|
||||||
|
const new_norm = dto.new_shift
|
||||||
|
? this.normalize_shift_payload(dto.new_shift)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (old_norm && old_norm.end_time.getTime() <= old_norm.start_time.getTime()) {
|
||||||
|
throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time');
|
||||||
|
}
|
||||||
|
if (new_norm && new_norm.end_time.getTime() <= new_norm.start_time.getTime()) {
|
||||||
|
throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time');
|
||||||
|
}
|
||||||
|
|
||||||
|
//Resolve bank_code_id with type
|
||||||
|
const old_bank_code_id = old_norm
|
||||||
|
? await this.lookup_bank_code_id_or_throw(old_norm.type)
|
||||||
|
: undefined;
|
||||||
|
const new_bank_code_id = new_norm
|
||||||
|
? await this.lookup_bank_code_id_or_throw(new_norm.type)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
//fetch all shifts in a single day
|
||||||
|
const day_shifts = await this.prisma.shifts.findMany({
|
||||||
|
where: {
|
||||||
|
timesheet_id: timesheet.id,
|
||||||
|
date: date_only
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
bank_code: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
start_time: 'asc'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.prisma.$transaction(async (transaction)=> {
|
||||||
|
let action: UpsertAction;
|
||||||
|
|
||||||
|
const find_exact_old_shift = async ()=> {
|
||||||
|
if(!old_norm || old_bank_code_id === undefined) return undefined;
|
||||||
|
const old_comment = old_norm.comment ?? null;
|
||||||
|
|
||||||
|
return transaction.shifts.findFirst({
|
||||||
|
where: {
|
||||||
|
timesheet_id: timesheet.id,
|
||||||
|
date: date_only,
|
||||||
|
start_time: old_norm.start_time,
|
||||||
|
end_time: old_norm.end_time,
|
||||||
|
is_remote: old_norm.is_remote,
|
||||||
|
comment: old_comment,
|
||||||
|
bank_code_id: old_bank_code_id,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//checks for overlaping shifts
|
||||||
|
const assert_no_overlap = (exclude_shift_id?: number)=> {
|
||||||
|
if (!new_norm) return;
|
||||||
|
|
||||||
|
const overlap_with = day_shifts.filter((shift)=> {
|
||||||
|
if(exclude_shift_id && shift.id === exclude_shift_id) return false;
|
||||||
|
return this.overlaps(
|
||||||
|
new_norm.start_time.getTime(),
|
||||||
|
new_norm.end_time.getTime(),
|
||||||
|
shift.start_time.getTime(),
|
||||||
|
shift.end_time.getTime(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if(overlap_with.length > 0) {
|
||||||
|
const conflicts = overlap_with.map((shift)=> ({
|
||||||
|
start_time: this.format_hhmm(shift.start_time),
|
||||||
|
end_time: this.format_hhmm(shift.end_time),
|
||||||
|
type: shift.bank_code?.type ?? 'UNKNOWN',
|
||||||
|
}));
|
||||||
|
throw new ConflictException({
|
||||||
|
error_code: 'SHIFT_OVERLAP',
|
||||||
|
message: 'New shift overlaps with existing shift(s)',
|
||||||
|
conflicts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
if ( old_shift && !new_shift ) {
|
||||||
|
const existing = await find_exact_old_shift();
|
||||||
|
if(!existing) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
error_code: 'SHIFT_STALE',
|
||||||
|
message: 'The shift was modified or deleted by someone else',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await transaction.shifts.delete({ where: { id: existing.id } } );
|
||||||
|
action = 'deleted';
|
||||||
|
}
|
||||||
|
// CREATE
|
||||||
|
else if (!old_shift && new_shift) {
|
||||||
|
assert_no_overlap();
|
||||||
|
await transaction.shifts.create({
|
||||||
|
data: {
|
||||||
|
timesheet_id: timesheet.id,
|
||||||
|
date: date_only,
|
||||||
|
start_time: new_norm!.start_time,
|
||||||
|
end_time: new_norm!.end_time,
|
||||||
|
is_remote: new_norm!.is_remote,
|
||||||
|
comment: new_norm!.comment ?? null,
|
||||||
|
bank_code_id: new_bank_code_id!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
action = 'created';
|
||||||
|
}
|
||||||
|
//UPDATE
|
||||||
|
else if (old_shift && new_shift){
|
||||||
|
const existing = await find_exact_old_shift();
|
||||||
|
if(!existing) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
error_code: 'SHIFT_STALE',
|
||||||
|
message: 'The shift was modified or deleted by someone else',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert_no_overlap(existing.id);
|
||||||
|
await transaction.shifts.update({
|
||||||
|
where: {
|
||||||
|
id: existing.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
start_time: new_norm!.start_time,
|
||||||
|
end_time: new_norm!.end_time,
|
||||||
|
is_remote: new_norm!.is_remote,
|
||||||
|
comment: new_norm!.comment ?? null,
|
||||||
|
bank_code_id: new_bank_code_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
action = 'updated';
|
||||||
|
} else {
|
||||||
|
throw new BadRequestException('At least one of old_shift or new_shift must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
//Reload the day (truth source)
|
||||||
|
const fresh_day = await transaction.shifts.findMany({
|
||||||
|
where: {
|
||||||
|
timesheet_id: timesheet.id,
|
||||||
|
date: date_only,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
bank_code: true
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
start_time: 'asc'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
day: fresh_day.map<DayShiftResponse>((shift)=> ({
|
||||||
|
start_time: this.format_hhmm(shift.start_time),
|
||||||
|
end_time: this.format_hhmm(shift.end_time),
|
||||||
|
type: shift.bank_code?.type ?? 'UNKNOWN',
|
||||||
|
is_remote: shift.is_remote,
|
||||||
|
comment: shift.comment ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize_shift_payload(payload: ShiftPayloadDto) {
|
||||||
|
//normalize shift's infos
|
||||||
|
const start_time = timeFromHHMMUTC(payload.start_time);
|
||||||
|
const end_time = timeFromHHMMUTC(payload.end_time );
|
||||||
|
const type = (payload.type || '').trim().toUpperCase();
|
||||||
|
const is_remote = payload.is_remote === true;
|
||||||
|
//normalize comment
|
||||||
|
const raw_comment = payload.comment ?? null;
|
||||||
|
const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null;
|
||||||
|
const comment = trimmed && trimmed.length > 0 ? trimmed: null;
|
||||||
|
|
||||||
|
return { start_time, end_time, type, is_remote, comment };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookup_bank_code_id_or_throw(type: string): Promise<number> {
|
||||||
|
const bank = await this.prisma.bankCodes.findFirst({
|
||||||
|
where: { type },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!bank) {
|
||||||
|
throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` });
|
||||||
|
}
|
||||||
|
return bank.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private overlaps(
|
||||||
|
a_start_ms: number,
|
||||||
|
a_end_ms: number,
|
||||||
|
b_start_ms: number,
|
||||||
|
b_end_ms: number,
|
||||||
|
): boolean {
|
||||||
|
return a_start_ms < b_end_ms && b_start_ms < a_end_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private format_hhmm(time: Date): string {
|
||||||
|
const hh = String(time.getUTCHours()).padStart(2,'0');
|
||||||
|
const mm = String(time.getUTCMinutes()).padStart(2,'0');
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
//approval methods
|
||||||
|
|
||||||
protected get delegate() {
|
protected get delegate() {
|
||||||
return this.prisma.shifts;
|
return this.prisma.shifts;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,15 @@ export class ShiftDto {
|
||||||
type: string;
|
type: string;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time : string;
|
end_time : string;
|
||||||
|
comment: string;
|
||||||
is_approved: boolean;
|
is_approved: boolean;
|
||||||
is_remote: boolean;
|
is_remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ExpenseDto {
|
export class ExpenseDto {
|
||||||
amount: number;
|
amount: number;
|
||||||
|
comment: string;
|
||||||
|
supervisor_comment: string;
|
||||||
total_mileage: number;
|
total_mileage: number;
|
||||||
total_expense: number;
|
total_expense: number;
|
||||||
is_approved: boolean;
|
is_approved: boolean;
|
||||||
|
|
@ -22,6 +25,7 @@ export class DetailedShifts {
|
||||||
evening_hours: number;
|
evening_hours: number;
|
||||||
overtime_hours: number;
|
overtime_hours: number;
|
||||||
emergency_hours: number;
|
emergency_hours: number;
|
||||||
|
comment: string;
|
||||||
short_date: string;
|
short_date: string;
|
||||||
break_durations?: number;
|
break_durations?: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import { Timesheets, TimesheetsArchive } from '@prisma/client';
|
import { Timesheets, TimesheetsArchive } from '@prisma/client';
|
||||||
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, 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 { 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';
|
import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers';
|
||||||
import { TimesheetDto } from '../dtos/overview-timesheet.dto';
|
import { TimesheetDto } from '../dtos/overview-timesheet.dto';
|
||||||
import { CreateWeekShiftsDto } from '../dtos/create-timesheet.dto';
|
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -45,6 +43,7 @@ export class TimesheetsQueryService {
|
||||||
date: true,
|
date: true,
|
||||||
start_time: true,
|
start_time: true,
|
||||||
end_time: true,
|
end_time: true,
|
||||||
|
comment: true,
|
||||||
is_approved: true,
|
is_approved: true,
|
||||||
is_remote: true,
|
is_remote: true,
|
||||||
bank_code: { select: { type: true } },
|
bank_code: { select: { type: true } },
|
||||||
|
|
@ -60,31 +59,37 @@ export class TimesheetsQueryService {
|
||||||
select: {
|
select: {
|
||||||
date: true,
|
date: true,
|
||||||
amount: true,
|
amount: true,
|
||||||
|
comment: true,
|
||||||
|
supervisor_comment: true,
|
||||||
is_approved: true,
|
is_approved: true,
|
||||||
bank_code: { select: { type: true } },
|
bank_code: { select: { type: true } },
|
||||||
},
|
},
|
||||||
orderBy: { date: 'asc' },
|
orderBy: { date: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const to_num = (value: any) => value && typeof (value as any).toNumber === 'function'
|
const to_num = (value: any) =>
|
||||||
? (value as any).toNumber()
|
value && typeof value.toNumber === 'function' ? value.toNumber() :
|
||||||
: Number(value);
|
typeof value === 'number' ? value :
|
||||||
|
value ? Number(value) : 0;
|
||||||
|
|
||||||
// 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,
|
||||||
end_time: shift.end_time,
|
end_time: shift.end_time,
|
||||||
type: String(shift.bank_code?.type ?? '').toUpperCase(),
|
comment: shift.comment ?? '',
|
||||||
is_approved: shift.is_approved ?? true,
|
is_approved: shift.is_approved ?? true,
|
||||||
is_remote: shift.is_remote ?? true,
|
is_remote: shift.is_remote ?? true,
|
||||||
|
type: String(shift.bank_code?.type ?? '').toUpperCase(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const expenses: ExpenseRow[] = raw_expenses.map(expense => ({
|
const expenses: ExpenseRow[] = raw_expenses.map(expense => ({
|
||||||
date: expense.date,
|
date: expense.date,
|
||||||
amount: to_num(expense.amount),
|
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,
|
is_approved: expense.is_approved ?? true,
|
||||||
|
type: String(expense.bank_code?.type ?? '').toUpperCase(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return buildPeriod(period.period_start, period.period_end, shifts , expenses);
|
return buildPeriod(period.period_start, period.period_end, shifts , expenses);
|
||||||
|
|
@ -231,7 +236,7 @@ export class TimesheetsQueryService {
|
||||||
await this.prisma.$transaction(async transaction => {
|
await this.prisma.$transaction(async transaction => {
|
||||||
//fetches all timesheets to cutoff
|
//fetches all timesheets to cutoff
|
||||||
const oldSheets = await transaction.timesheets.findMany({
|
const oldSheets = await transaction.timesheets.findMany({
|
||||||
where: { shift: { every: { date: { lt: cutoff } } },
|
where: { shift: { some: { date: { lt: cutoff } } },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,23 @@ const EXPENSE_TYPES = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
//DB line types
|
//DB line types
|
||||||
export type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean; is_remote: boolean; type: string };
|
export type ShiftRow = {
|
||||||
export type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean; };
|
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
|
//helper functions
|
||||||
export function toUTCDateOnly(date: Date | string): Date {
|
export function toUTCDateOnly(date: Date | string): Date {
|
||||||
|
|
@ -84,6 +99,7 @@ export function makeEmptyWeek(week_start: Date): WeekDto {
|
||||||
evening_hours: 0,
|
evening_hours: 0,
|
||||||
emergency_hours: 0,
|
emergency_hours: 0,
|
||||||
overtime_hours: 0,
|
overtime_hours: 0,
|
||||||
|
comment: '',
|
||||||
short_date: shortDate(addDays(week_start, offset)),
|
short_date: shortDate(addDays(week_start, offset)),
|
||||||
break_durations: 0,
|
break_durations: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -129,19 +145,44 @@ export function buildWeek(
|
||||||
}, {} as Record<DayKey, Array<{ start: Date; end: Date}>>);
|
}, {} as Record<DayKey, Array<{ start: Date; end: Date}>>);
|
||||||
|
|
||||||
//shifts's hour by type
|
//shifts's hour by type
|
||||||
type ShiftsHours =
|
type ShiftsHours = {
|
||||||
{regular: number; evening: number; overtime: number; emergency: number; sick: number; vacation: number; holiday: number;};
|
regular: number;
|
||||||
const make_hours = (): ShiftsHours =>
|
evening: number;
|
||||||
({ regular: 0, evening: 0, overtime: 0, emergency: 0, sick: 0, vacation: 0, holiday: 0 });
|
overtime: number;
|
||||||
|
emergency: number;
|
||||||
|
sick: number;
|
||||||
|
vacation: number;
|
||||||
|
holiday: number;
|
||||||
|
};
|
||||||
|
const make_hours = (): ShiftsHours => ({
|
||||||
|
regular: 0,
|
||||||
|
evening: 0,
|
||||||
|
overtime: 0,
|
||||||
|
emergency: 0,
|
||||||
|
sick: 0,
|
||||||
|
vacation: 0,
|
||||||
|
holiday: 0
|
||||||
|
});
|
||||||
const day_hours: Record<DayKey, ShiftsHours> = DAY_KEYS.reduce((acc, key) => {
|
const day_hours: Record<DayKey, ShiftsHours> = DAY_KEYS.reduce((acc, key) => {
|
||||||
acc[key] = make_hours(); return acc;
|
acc[key] = make_hours(); return acc;
|
||||||
}, {} as Record<DayKey, ShiftsHours>);
|
}, {} as Record<DayKey, ShiftsHours>);
|
||||||
|
|
||||||
//expenses's amount by type
|
//expenses's amount by type
|
||||||
type ExpensesAmount =
|
type ExpensesAmount = {
|
||||||
{mileage: number; expense: number; per_diem: number; commission: number; prime_dispo: number };
|
mileage: number;
|
||||||
const make_amounts = (): ExpensesAmount =>
|
expense: number;
|
||||||
({ mileage: 0, expense: 0, per_diem: 0, commission: 0, prime_dispo: 0 });
|
per_diem: number;
|
||||||
|
commission: number;
|
||||||
|
prime_dispo: number
|
||||||
|
};
|
||||||
|
|
||||||
|
const make_amounts = (): ExpensesAmount => ({
|
||||||
|
mileage: 0,
|
||||||
|
expense: 0,
|
||||||
|
per_diem: 0,
|
||||||
|
commission: 0,
|
||||||
|
prime_dispo: 0
|
||||||
|
});
|
||||||
const day_amounts: Record<DayKey, ExpensesAmount> = DAY_KEYS.reduce((acc, key) => {
|
const day_amounts: Record<DayKey, ExpensesAmount> = DAY_KEYS.reduce((acc, key) => {
|
||||||
acc[key] = make_amounts(); return acc;
|
acc[key] = make_amounts(); return acc;
|
||||||
}, {} as Record<DayKey, ExpensesAmount>);
|
}, {} as Record<DayKey, ExpensesAmount>);
|
||||||
|
|
@ -159,6 +200,7 @@ export function buildWeek(
|
||||||
type: shift.type,
|
type: shift.type,
|
||||||
start_time: toTimeString(shift.start_time),
|
start_time: toTimeString(shift.start_time),
|
||||||
end_time: toTimeString(shift.end_time),
|
end_time: toTimeString(shift.end_time),
|
||||||
|
comment: shift.comment,
|
||||||
is_approved: shift.is_approved ?? true,
|
is_approved: shift.is_approved ?? true,
|
||||||
is_remote: shift.is_remote,
|
is_remote: shift.is_remote,
|
||||||
} as ShiftDto);
|
} as ShiftDto);
|
||||||
|
|
@ -230,6 +272,8 @@ export function buildWeek(
|
||||||
for(const row of dayExpenseRows[key].km) {
|
for(const row of dayExpenseRows[key].km) {
|
||||||
week.expenses[key].km.push({
|
week.expenses[key].km.push({
|
||||||
amount: round2(row.amount),
|
amount: round2(row.amount),
|
||||||
|
comment: row.comment,
|
||||||
|
supervisor_comment: row.supervisor_comment,
|
||||||
total_mileage: round2(total_mileage),
|
total_mileage: round2(total_mileage),
|
||||||
total_expense: round2(total_expense),
|
total_expense: round2(total_expense),
|
||||||
is_approved: row.is_approved ?? true,
|
is_approved: row.is_approved ?? true,
|
||||||
|
|
@ -240,6 +284,8 @@ export function buildWeek(
|
||||||
for(const row of dayExpenseRows[key].cash) {
|
for(const row of dayExpenseRows[key].cash) {
|
||||||
week.expenses[key].cash.push({
|
week.expenses[key].cash.push({
|
||||||
amount: round2(row.amount),
|
amount: round2(row.amount),
|
||||||
|
comment: row.comment,
|
||||||
|
supervisor_comment: row.supervisor_comment,
|
||||||
total_mileage: round2(total_mileage),
|
total_mileage: round2(total_mileage),
|
||||||
total_expense: round2(total_expense),
|
total_expense: round2(total_expense),
|
||||||
is_approved: row.is_approved ?? true,
|
is_approved: row.is_approved ?? true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user