feat(timesheet): added Post function to create a new shifts inside a timesheet

This commit is contained in:
Matthieu Haineault 2025-09-04 15:14:48 -04:00
parent 5063c1dfec
commit 4f7563ce9b
6 changed files with 148 additions and 153 deletions

View File

@ -542,53 +542,6 @@
"Timesheets"
]
},
"patch": {
"operationId": "TimesheetsController_update",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateTimesheetDto"
}
}
}
},
"responses": {
"201": {
"description": "Timesheet updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateTimesheetDto"
}
}
}
},
"400": {
"description": "Timesheet not found"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Update timesheet",
"tags": [
"Timesheets"
]
},
"delete": {
"operationId": "TimesheetsController_remove",
"parameters": [
@ -2408,48 +2361,7 @@
},
"CreateTimesheetDto": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1,
"description": "timesheet`s unique ID (auto-generated)"
},
"employee_id": {
"type": "number",
"example": 426433,
"description": "employee`s ID number of linked timsheet"
},
"is_approved": {
"type": "boolean",
"example": true,
"description": "Timesheet`s status approval"
}
},
"required": [
"id",
"employee_id",
"is_approved"
]
},
"UpdateTimesheetDto": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1,
"description": "timesheet`s unique ID (auto-generated)"
},
"employee_id": {
"type": "number",
"example": 426433,
"description": "employee`s ID number of linked timsheet"
},
"is_approved": {
"type": "boolean",
"example": true,
"description": "Timesheet`s status approval"
}
}
"properties": {}
},
"CreateExpenseDto": {
"type": "object",

View File

@ -250,10 +250,10 @@ export class PayPeriodsQueryService {
const amount = toMoney(expense.amount);
record.expenses += amount;
const categorie = (expense.bank_code?.categorie || "").toUpperCase();
const type = (expense.bank_code?.type || "").toUpperCase();
const rate = expense.bank_code?.modifier ?? 0;
if (categorie === "MILEAGE" && rate > 0) {
record.mileage += amount / rate;
if (type === "MILEAGE" && rate > 0) {
record.mileage += Math.round((amount / rate)/100)*100;
}
record.is_approved = record.is_approved && expense.timesheet.is_approved;
}

View File

@ -1,8 +1,7 @@
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query } from '@nestjs/common';
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query } from '@nestjs/common';
import { TimesheetsQueryService } from '../services/timesheets-query.service';
import { CreateTimesheetDto } from '../dtos/create-timesheet.dto';
import { CreateTimesheetDto, CreateWeekShiftsDto } from '../dtos/create-timesheet.dto';
import { Timesheets } from '@prisma/client';
import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto';
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
@ -20,15 +19,6 @@ export class TimesheetsController {
private readonly timesheetsCommand: TimesheetsCommandService,
) {}
// @Post()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Create timesheet' })
// @ApiResponse({ status: 201, description: 'Timesheet created', type: CreateTimesheetDto })
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
// create(@Body() dto: CreateTimesheetDto): Promise<Timesheets> {
// return this.timesheetsQuery.create(dto);
// }
@Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
async getPeriodByQuery(
@ -49,6 +39,17 @@ export class TimesheetsController {
return this.timesheetsQuery.getTimesheetByEmail(email, week_offset);
}
@Post('shifts/:email')
async createTimesheetShifts(
@Param('email') email: string,
@Body() dto: CreateWeekShiftsDto,
@Query('offset') offset?: string,
): Promise<TimesheetDto> {
const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0;
return this.timesheetsCommand.createWeekShiftsAndReturnOverview(email, dto.shifts, week_offset);
}
@Get(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Find timesheet' })
@ -58,18 +59,6 @@ export class TimesheetsController {
return this.timesheetsQuery.findOne(id);
}
@Patch(':id')
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Update timesheet' })
@ApiResponse({ status: 201, description: 'Timesheet updated', type: CreateTimesheetDto })
@ApiResponse({ status: 400, description: 'Timesheet not found' })
update(
@Param('id', ParseIntPipe) id:number,
@Body() dto: UpdateTimesheetDto,
): Promise<Timesheets> {
return this.timesheetsQuery.update(id, dto);
}
@Delete(':id')
// @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: 'Delete timesheet' })

View File

@ -1,28 +1,33 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { Allow, IsBoolean, IsInt, IsOptional } from "class-validator";
import { IsArray, IsOptional, IsString, Length, Matches, ValidateNested } from "class-validator";
export class CreateTimesheetDto {
@ApiProperty({
example: 1,
description: 'timesheet`s unique ID (auto-generated)',
})
@Allow()
id?: number;
@ApiProperty({
example: 426433,
description: 'employee`s ID number of linked timsheet',
})
@Type(() => Number)
@IsInt()
employee_id: number;
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
date!: string;
@ApiProperty({
example: true,
description: 'Timesheet`s status approval',
})
@IsOptional()
@IsBoolean()
is_approved?: boolean;
@IsString()
@Length(1,64)
type!: string;
@IsString()
@Matches(/^\d{2}:\d{2}$/)
start_time!: string;
@IsString()
@Matches(/^\d{2}:\d{2}$/)
end_time!: string;
@IsOptional()
@IsString()
@Length(0,512)
description?: string;
}
export class CreateWeekShiftsDto {
@IsArray()
@ValidateNested({each:true})
@Type(()=> CreateTimesheetDto)
shifts!: CreateTimesheetDto[];
}

View File

@ -1,16 +1,24 @@
import { Injectable } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, Timesheets } from "@prisma/client";
import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { PrismaService } from "src/prisma/prisma.service";
import { TimesheetsQueryService } from "./timesheets-query.service";
import { CreateTimesheetDto } from "../dtos/create-timesheet.dto";
import { TimesheetDto } from "../dtos/overview-timesheet.dto";
import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils";
@Injectable()
export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
constructor(prisma: PrismaService) {super(prisma);}
constructor(
prisma: PrismaService,
private readonly query: TimesheetsQueryService,
) {super(prisma);}
protected get delegate() {
return this.prisma.timesheets;
}
protected delegateFor(transaction: Prisma.TransactionClient) {
return transaction.timesheets;
}
@ -37,4 +45,83 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
return timesheet;
}
//create shifts within timesheet's week - employee overview functions
private parseISODate(iso: string): Date {
const [ y, m, d ] = iso.split('-').map(Number);
return new Date(y, (m ?? 1) - 1, d ?? 1);
}
private parseHHmm(t: string): Date {
const [ hh, mm ] = t.split(':').map(Number);
return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0);
}
async createWeekShiftsAndReturnOverview(
email:string,
shifts: CreateTimesheetDto[],
week_offset = 0,
): Promise<TimesheetDto> {
//match user's email with email
const user = await this.prisma.users.findUnique({
where: { email },
select: { id: true },
});
if(!user) throw new NotFoundException(`user with email ${email} not found`);
//fetchs employee matchint user's email
const employee = await this.prisma.employees.findFirst({
where: { user_id: user?.id },
select: { id: true },
});
if(!employee) throw new NotFoundException(`employee for ${ email } not found`);
//insure that the week starts on sunday and finishes on saturday
const base = new Date();
base.setDate(base.getDate() + week_offset * 7);
const start_week = getWeekStart(base, 0);
const end_week = getWeekEnd(start_week);
const timesheet = await this.prisma.timesheets.upsert({
where: {
employee_id_start_date: {
employee_id: employee.id,
start_date: start_week,
},
},
create: {
employee_id: employee.id,
start_date: start_week,
is_approved: false,
},
update: {},
select: { id: true },
});
//validations and insertions
for(const shift of shifts) {
const date = this.parseISODate(shift.date);
if (date < start_week || date > end_week) throw new BadRequestException(`date ${shift.date} not in current week`);
const bank_code = await this.prisma.bankCodes.findFirst({
where: { type: shift.type },
select: { id: true },
});
if(!bank_code) throw new BadRequestException(`Invalid bank_code type: ${shift.type}`);
await this.prisma.shifts.create({
data: {
timesheet_id: timesheet.id,
bank_code_id: bank_code.id,
date: date,
start_time: this.parseHHmm(shift.start_time),
end_time: this.parseHHmm(shift.end_time),
description: shift.description ?? null,
is_approved: false,
},
});
}
return this.query.getTimesheetByEmail(email, week_offset);
}
}

View File

@ -8,6 +8,7 @@ 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()
@ -219,19 +220,20 @@ export class TimesheetsQueryService {
return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours };
}
async update(id: number, dto:UpdateTimesheetDto): Promise<Timesheets> {
await this.findOne(id);
const { employee_id, is_approved } = dto;
return this.prisma.timesheets.update({
where: { id },
data: {
...(employee_id !== undefined && { employee_id }),
...(is_approved !== undefined && { is_approved }),
},
include: { employee: { include: { user: true } },
},
});
}
//deprecated
// async update(id: number, dto:UpdateTimesheetDto): Promise<Timesheets> {
// await this.findOne(id);
// const { employee_id, is_approved } = dto;
// return this.prisma.timesheets.update({
// where: { id },
// data: {
// ...(employee_id !== undefined && { employee_id }),
// ...(is_approved !== undefined && { is_approved }),
// },
// include: { employee: { include: { user: true } },
// },
// });
// }
async remove(id: number): Promise<Timesheets> {
await this.findOne(id);