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": {
|
||||
"post": {
|
||||
"operationId": "ShiftsController_create",
|
||||
|
|
@ -2513,6 +2559,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"UpsertShiftDto": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"CreateShiftDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { BadRequestException, Module, ValidationPipe } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { ArchivalModule } from './modules/archival/archival.module';
|
||||
|
|
@ -22,6 +22,9 @@ import { ShiftsModule } from './modules/shifts/shifts.module';
|
|||
import { TimesheetsModule } from './modules/timesheets/timesheets.module';
|
||||
import { UsersModule } from './modules/users-management/users.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ValidationError } from 'class-validator';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -46,6 +49,29 @@ import { ConfigModule } from '@nestjs/config';
|
|||
UsersModule,
|
||||
],
|
||||
controllers: [AppController, HealthController],
|
||||
providers: [AppService, OvertimeService],
|
||||
providers: [
|
||||
AppService,
|
||||
OvertimeService,
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: HttpExceptionFilter
|
||||
},
|
||||
{
|
||||
provide: APP_PIPE,
|
||||
useValue: new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
exceptionFactory: (errors: ValidationError[] = [])=> {
|
||||
const messages = errors.flatMap((e)=> Object.values(e.constraints ?? {}));
|
||||
return new BadRequestException({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message: messages.length ? messages : errors,
|
||||
});
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
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 { AppModule } from './app.module';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
// import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './common/guards/roles.guard';
|
||||
import { OwnershipGuard } from './common/guards/ownership.guard';
|
||||
|
|
@ -25,8 +24,6 @@ async function bootstrap() {
|
|||
|
||||
const reflector = app.get(Reflector); //setup Reflector for Roles()
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, transform: true}));
|
||||
app.useGlobalGuards(
|
||||
// new JwtAuthGuard(reflector), //Authentification JWT
|
||||
new RolesGuard(reflector), //deny-by-default and Role-based Access Control
|
||||
|
|
|
|||
|
|
@ -239,10 +239,18 @@ export class PayPeriodsQueryService {
|
|||
const hours = computeHours(shift.start_time, shift.end_time);
|
||||
const type = (shift.bank_code?.type ?? '').toUpperCase();
|
||||
switch (type) {
|
||||
case "EVENING": record.evening_hours += hours; record.total_hours += hours; break;
|
||||
case "EMERGENCY": record.emergency_hours += hours; record.total_hours += hours; break;
|
||||
case "OVERTIME": record.overtime_hours += hours; record.total_hours += hours; break;
|
||||
case "REGULAR" : record.regular_hours += hours; record.total_hours += hours; break;
|
||||
case "EVENING": record.evening_hours += hours;
|
||||
record.total_hours += hours;
|
||||
break;
|
||||
case "EMERGENCY": record.emergency_hours += hours;
|
||||
record.total_hours += hours;
|
||||
break;
|
||||
case "OVERTIME": record.overtime_hours += hours;
|
||||
record.total_hours += hours;
|
||||
break;
|
||||
case "REGULAR" : record.regular_hours += hours;
|
||||
record.total_hours += hours;
|
||||
break;
|
||||
}
|
||||
|
||||
record.is_approved = record.is_approved && shift.timesheet.is_approved;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||
import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
|
||||
import { Shifts } from "@prisma/client";
|
||||
import { CreateShiftDto } from "../dtos/create-shift.dto";
|
||||
import { UpdateShiftsDto } from "../dtos/update-shift.dto";
|
||||
|
|
@ -9,6 +9,7 @@ import { ShiftsCommandService } from "../services/shifts-command.service";
|
|||
import { SearchShiftsDto } from "../dtos/search-shift.dto";
|
||||
import { OverviewRow, ShiftsQueryService } from "../services/shifts-query.service";
|
||||
import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
|
||||
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
||||
|
||||
@ApiTags('Shifts')
|
||||
@ApiBearerAuth('access-token')
|
||||
|
|
@ -17,10 +18,19 @@ import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
|
|||
export class ShiftsController {
|
||||
constructor(
|
||||
private readonly shiftsService: ShiftsQueryService,
|
||||
private readonly shiftsApprovalService: ShiftsCommandService,
|
||||
private readonly shiftsCommandService: ShiftsCommandService,
|
||||
private readonly shiftsValidationService: ShiftsQueryService,
|
||||
){}
|
||||
|
||||
@Put('upsert/:email/:date')
|
||||
async upsert_by_date(
|
||||
@Param('email') email_param: string,
|
||||
@Param('date') date_param: string,
|
||||
@Body() payload: UpsertShiftDto,
|
||||
) {
|
||||
return this.shiftsCommandService.upsertShfitsByDate(email_param, date_param, payload);
|
||||
}
|
||||
|
||||
@Post()
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({ summary: 'Create shift' })
|
||||
|
|
@ -70,7 +80,7 @@ export class ShiftsController {
|
|||
@Patch('approval/:id')
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
|
||||
return this.shiftsApprovalService.updateApproval(id, isApproved);
|
||||
return this.shiftsCommandService.updateApproval(id, isApproved);
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
|
|
|
|||
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 { BaseApprovalService } from "src/common/shared/base-approval.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
||||
import { timeFromHHMMUTC, toDateOnlyUTC, weekStartMondayUTC } from "../helpers/shifts-date-time-helpers";
|
||||
|
||||
type DayShiftResponse = {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
type: string;
|
||||
is_remote: boolean;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
type UpsertAction = 'created' | 'updated' | 'deleted';
|
||||
|
||||
@Injectable()
|
||||
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
|
||||
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() {
|
||||
return this.prisma.shifts;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,15 @@ export class ShiftDto {
|
|||
type: string;
|
||||
start_time: string;
|
||||
end_time : string;
|
||||
comment: string;
|
||||
is_approved: boolean;
|
||||
is_remote: boolean;
|
||||
}
|
||||
|
||||
export class ExpenseDto {
|
||||
amount: number;
|
||||
comment: string;
|
||||
supervisor_comment: string;
|
||||
total_mileage: number;
|
||||
total_expense: number;
|
||||
is_approved: boolean;
|
||||
|
|
@ -22,6 +25,7 @@ export class DetailedShifts {
|
|||
evening_hours: number;
|
||||
overtime_hours: number;
|
||||
emergency_hours: number;
|
||||
comment: string;
|
||||
short_date: string;
|
||||
break_durations?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { Timesheets, TimesheetsArchive } from '@prisma/client';
|
||||
import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto';
|
||||
import { OvertimeService } from 'src/modules/business-logics/services/overtime.service';
|
||||
import { computeHours, formatDateISO, getCurrentWeek, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
|
||||
import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
|
||||
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
|
||||
import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers';
|
||||
import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers';
|
||||
import { TimesheetDto } from '../dtos/overview-timesheet.dto';
|
||||
import { CreateWeekShiftsDto } from '../dtos/create-timesheet.dto';
|
||||
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -45,6 +43,7 @@ export class TimesheetsQueryService {
|
|||
date: true,
|
||||
start_time: true,
|
||||
end_time: true,
|
||||
comment: true,
|
||||
is_approved: true,
|
||||
is_remote: true,
|
||||
bank_code: { select: { type: true } },
|
||||
|
|
@ -60,31 +59,37 @@ export class TimesheetsQueryService {
|
|||
select: {
|
||||
date: true,
|
||||
amount: true,
|
||||
comment: true,
|
||||
supervisor_comment: true,
|
||||
is_approved: true,
|
||||
bank_code: { select: { type: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
});
|
||||
|
||||
const to_num = (value: any) => value && typeof (value as any).toNumber === 'function'
|
||||
? (value as any).toNumber()
|
||||
: Number(value);
|
||||
const to_num = (value: any) =>
|
||||
value && typeof value.toNumber === 'function' ? value.toNumber() :
|
||||
typeof value === 'number' ? value :
|
||||
value ? Number(value) : 0;
|
||||
|
||||
// data mapping
|
||||
const shifts: ShiftRow[] = raw_shifts.map(shift => ({
|
||||
date: shift.date,
|
||||
start_time: shift.start_time,
|
||||
end_time: shift.end_time,
|
||||
type: String(shift.bank_code?.type ?? '').toUpperCase(),
|
||||
comment: shift.comment ?? '',
|
||||
is_approved: shift.is_approved ?? true,
|
||||
is_remote: shift.is_remote ?? true,
|
||||
type: String(shift.bank_code?.type ?? '').toUpperCase(),
|
||||
}));
|
||||
|
||||
const expenses: ExpenseRow[] = raw_expenses.map(expense => ({
|
||||
date: expense.date,
|
||||
amount: to_num(expense.amount),
|
||||
type: String(expense.bank_code?.type ?? '').toUpperCase(),
|
||||
comment: expense.comment ?? '',
|
||||
supervisor_comment: expense.supervisor_comment ?? '',
|
||||
is_approved: expense.is_approved ?? true,
|
||||
type: String(expense.bank_code?.type ?? '').toUpperCase(),
|
||||
}));
|
||||
|
||||
return buildPeriod(period.period_start, period.period_end, shifts , expenses);
|
||||
|
|
@ -231,7 +236,7 @@ export class TimesheetsQueryService {
|
|||
await this.prisma.$transaction(async transaction => {
|
||||
//fetches all timesheets to cutoff
|
||||
const oldSheets = await transaction.timesheets.findMany({
|
||||
where: { shift: { every: { date: { lt: cutoff } } },
|
||||
where: { shift: { some: { date: { lt: cutoff } } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
|
|
|||
|
|
@ -34,8 +34,23 @@ const EXPENSE_TYPES = {
|
|||
} as const;
|
||||
|
||||
//DB line types
|
||||
export type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean; is_remote: boolean; type: string };
|
||||
export type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean; };
|
||||
export type ShiftRow = {
|
||||
date: Date;
|
||||
start_time: Date;
|
||||
end_time: Date;
|
||||
comment: string;
|
||||
is_approved?: boolean;
|
||||
is_remote: boolean;
|
||||
type: string
|
||||
};
|
||||
export type ExpenseRow = {
|
||||
date: Date;
|
||||
amount: number;
|
||||
comment: string;
|
||||
supervisor_comment: string;
|
||||
is_approved?: boolean;
|
||||
type: string;
|
||||
};
|
||||
|
||||
//helper functions
|
||||
export function toUTCDateOnly(date: Date | string): Date {
|
||||
|
|
@ -84,6 +99,7 @@ export function makeEmptyWeek(week_start: Date): WeekDto {
|
|||
evening_hours: 0,
|
||||
emergency_hours: 0,
|
||||
overtime_hours: 0,
|
||||
comment: '',
|
||||
short_date: shortDate(addDays(week_start, offset)),
|
||||
break_durations: 0,
|
||||
});
|
||||
|
|
@ -129,19 +145,44 @@ export function buildWeek(
|
|||
}, {} as Record<DayKey, Array<{ start: Date; end: Date}>>);
|
||||
|
||||
//shifts's hour by type
|
||||
type ShiftsHours =
|
||||
{regular: number; evening: number; overtime: number; emergency: number; sick: number; vacation: number; holiday: number;};
|
||||
const make_hours = (): ShiftsHours =>
|
||||
({ regular: 0, evening: 0, overtime: 0, emergency: 0, sick: 0, vacation: 0, holiday: 0 });
|
||||
type ShiftsHours = {
|
||||
regular: number;
|
||||
evening: number;
|
||||
overtime: number;
|
||||
emergency: number;
|
||||
sick: number;
|
||||
vacation: number;
|
||||
holiday: number;
|
||||
};
|
||||
const make_hours = (): ShiftsHours => ({
|
||||
regular: 0,
|
||||
evening: 0,
|
||||
overtime: 0,
|
||||
emergency: 0,
|
||||
sick: 0,
|
||||
vacation: 0,
|
||||
holiday: 0
|
||||
});
|
||||
const day_hours: Record<DayKey, ShiftsHours> = DAY_KEYS.reduce((acc, key) => {
|
||||
acc[key] = make_hours(); return acc;
|
||||
}, {} as Record<DayKey, ShiftsHours>);
|
||||
|
||||
//expenses's amount by type
|
||||
type ExpensesAmount =
|
||||
{mileage: number; expense: number; per_diem: number; commission: number; prime_dispo: number };
|
||||
const make_amounts = (): ExpensesAmount =>
|
||||
({ mileage: 0, expense: 0, per_diem: 0, commission: 0, prime_dispo: 0 });
|
||||
type ExpensesAmount = {
|
||||
mileage: number;
|
||||
expense: number;
|
||||
per_diem: number;
|
||||
commission: number;
|
||||
prime_dispo: number
|
||||
};
|
||||
|
||||
const make_amounts = (): ExpensesAmount => ({
|
||||
mileage: 0,
|
||||
expense: 0,
|
||||
per_diem: 0,
|
||||
commission: 0,
|
||||
prime_dispo: 0
|
||||
});
|
||||
const day_amounts: Record<DayKey, ExpensesAmount> = DAY_KEYS.reduce((acc, key) => {
|
||||
acc[key] = make_amounts(); return acc;
|
||||
}, {} as Record<DayKey, ExpensesAmount>);
|
||||
|
|
@ -159,6 +200,7 @@ export function buildWeek(
|
|||
type: shift.type,
|
||||
start_time: toTimeString(shift.start_time),
|
||||
end_time: toTimeString(shift.end_time),
|
||||
comment: shift.comment,
|
||||
is_approved: shift.is_approved ?? true,
|
||||
is_remote: shift.is_remote,
|
||||
} as ShiftDto);
|
||||
|
|
@ -230,6 +272,8 @@ export function buildWeek(
|
|||
for(const row of dayExpenseRows[key].km) {
|
||||
week.expenses[key].km.push({
|
||||
amount: round2(row.amount),
|
||||
comment: row.comment,
|
||||
supervisor_comment: row.supervisor_comment,
|
||||
total_mileage: round2(total_mileage),
|
||||
total_expense: round2(total_expense),
|
||||
is_approved: row.is_approved ?? true,
|
||||
|
|
@ -240,6 +284,8 @@ export function buildWeek(
|
|||
for(const row of dayExpenseRows[key].cash) {
|
||||
week.expenses[key].cash.push({
|
||||
amount: round2(row.amount),
|
||||
comment: row.comment,
|
||||
supervisor_comment: row.supervisor_comment,
|
||||
total_mileage: round2(total_mileage),
|
||||
total_expense: round2(total_expense),
|
||||
is_approved: row.is_approved ?? true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user