refactor(expenses): commented old files, set up create update and delete expenses. set up findAll method and ajust query params of the timesheet controller

This commit is contained in:
Matthieu Haineault 2025-10-22 08:59:04 -04:00
parent 9270033f24
commit af9d89da01
22 changed files with 393 additions and 814 deletions

View File

@ -57,6 +57,20 @@
] ]
} }
}, },
"/auth/me": {
"get": {
"operationId": "AuthController_getProfile",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
"/employees/employee-list": { "/employees/employee-list": {
"get": { "get": {
"operationId": "EmployeesController_findListEmployees", "operationId": "EmployeesController_findListEmployees",
@ -153,8 +167,6 @@
] ]
} }
}, },
<<<<<<< HEAD
=======
"/employees/profile/{email}": { "/employees/profile/{email}": {
"get": { "get": {
"operationId": "EmployeesController_findOneProfile", "operationId": "EmployeesController_findOneProfile",
@ -195,381 +207,6 @@
] ]
} }
}, },
"/timesheets": {
"get": {
"operationId": "TimesheetsController_getPeriodByQuery",
"parameters": [
{
"name": "year",
"required": true,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "period_no",
"required": true,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "email",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Timesheets"
]
}
},
"/timesheets/{email}": {
"get": {
"operationId": "TimesheetsController_getByEmail",
"parameters": [
{
"name": "email",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "offset",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Timesheets"
]
}
},
"/timesheets/shifts/{email}": {
"post": {
"operationId": "TimesheetsController_createTimesheetShifts",
"parameters": [
{
"name": "email",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "offset",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateWeekShiftsDto"
}
}
}
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Timesheets"
]
}
},
"/Expenses/upsert/{email}/{date}": {
"put": {
"operationId": "ExpensesController_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/UpsertExpenseDto"
}
}
}
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Expenses"
]
}
},
"/Expenses/list/{email}/{year}/{period_no}": {
"get": {
"operationId": "ExpensesController_findExpenseListByPayPeriodAndEmail",
"parameters": [
{
"name": "email",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "year",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "period_no",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Expenses"
]
}
},
"/shifts/upsert/{email}": {
"put": {
"operationId": "ShiftsController_upsert_by_date",
"parameters": [
{
"name": "email",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "action",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpsertShiftDto"
}
}
}
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Shifts"
]
}
},
"/shifts/delete/{email}/{date}": {
"delete": {
"operationId": "ShiftsController_remove",
"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/approval/{id}": {
"patch": {
"operationId": "ShiftsController_approve",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Shifts"
]
}
},
"/shifts/summary": {
"get": {
"operationId": "ShiftsController_getSummary",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Shifts"
]
}
},
"/shifts/export.csv": {
"get": {
"operationId": "ShiftsController_exportCsv",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Shifts"
]
}
},
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
"/notifications/summary": { "/notifications/summary": {
"get": { "get": {
"operationId": "NotificationsController_summary", "operationId": "NotificationsController_summary",
@ -598,80 +235,6 @@
] ]
} }
}, },
<<<<<<< HEAD
=======
"/leave-requests/upsert": {
"post": {
"operationId": "LeaveRequestController_upsertLeaveRequest",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpsertLeaveRequestDto"
}
}
}
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"access-token": []
}
],
"tags": [
"Leave Requests"
]
}
},
"/auth/v1/login": {
"get": {
"operationId": "AuthController_login",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
"/auth/callback": {
"get": {
"operationId": "AuthController_loginCallback",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
"/auth/me": {
"get": {
"operationId": "AuthController_getProfile",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
"/oauth-sessions": { "/oauth-sessions": {
"post": { "post": {
"operationId": "OauthSessionsController_create", "operationId": "OauthSessionsController_create",
@ -1327,29 +890,10 @@
"first_work_day" "first_work_day"
] ]
}, },
<<<<<<< HEAD
=======
"EmployeeProfileItemDto": { "EmployeeProfileItemDto": {
"type": "object", "type": "object",
"properties": {} "properties": {}
}, },
"CreateWeekShiftsDto": {
"type": "object",
"properties": {}
},
"UpsertExpenseDto": {
"type": "object",
"properties": {}
},
"UpsertShiftDto": {
"type": "object",
"properties": {}
},
"UpsertLeaveRequestDto": {
"type": "object",
"properties": {}
},
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
"CreateOauthSessionDto": { "CreateOauthSessionDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -0,0 +1,10 @@
import { Controller } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
@Controller('expense')
export class ExpenseController {
constructor(private readonly prisma: PrismaService){}
}

View File

@ -1,66 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { Allow, IsBoolean, IsDate, IsDateString, IsInt, IsNumber, IsOptional, IsString } from "class-validator";
export class CreateExpenseDto {
@ApiProperty({
example: 1,
description: 'Unique ID of the expense (auto-generated)',
})
@Allow()
id?: number;
@ApiProperty({
example: 101,
description: 'ID number for a set timesheet',
})
@Type(()=> Number)
@IsInt()
timesheet_id: number;
@ApiProperty({
example: 7,
description: 'ID number of an bank code (link with bank-codes)',
})
@Type(() => Number)
@IsInt()
bank_code_id: number;
@ApiProperty({
example: '3018-10-20T00:00:00.000Z',
description: 'Date where the expense was made',
})
@IsDateString()
date: string;
@ApiProperty({
example: 17.82,
description: 'amount in $ for a refund',
})
@Type(() => Number)
@IsNumber()
amount: number;
@ApiProperty({
example:'Spent for mileage between A and B',
description:'explain`s why the expense was made'
})
@IsString()
comment: string;
@ApiProperty({
example: 'DENIED, APPROUVED, PENDING, etc...',
description: 'validation status',
})
@IsOptional()
@IsBoolean()
is_approved?: boolean;
@ApiProperty({
example:'Asked X to go there as an emergency response',
description:'Supervisro`s justification for the spending of an employee'
})
@IsString()
@IsOptional()
supervisor_comment?: string;
}

View File

View File

@ -1,26 +0,0 @@
import { Type } from "class-transformer";
import { IsDateString, IsInt, IsOptional, IsString } from "class-validator";
export class SearchExpensesDto {
@IsOptional()
@Type(()=> Number)
@IsInt()
timesheet_id?: number;
@IsOptional()
@Type(()=> Number)
@IsInt()
bank_code_id?: number;
@IsOptional()
@IsString()
comment_contains?: string;
@IsOptional()
@IsDateString()
start_date: string;
@IsOptional()
@IsDateString()
end_date: string;
}

View File

@ -1,4 +0,0 @@
import { PartialType } from "@nestjs/swagger";
import { CreateExpenseDto } from "./create-expense.dto";
export class UpdateExpenseDto extends PartialType(CreateExpenseDto) {}

View File

@ -1,59 +0,0 @@
import { Transform, Type } from "class-transformer";
import {
IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
ValidateIf,
ValidateNested
} from "class-validator";
export class ExpensePayloadDto {
@IsString()
type!: string;
@ValidateIf(o => (o.type ?? '').toUpperCase() !== 'MILEAGE')
@IsNumber()
@Min(0)
amount?: number;
@ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE')
@IsNumber()
@Min(0)
mileage?: number;
@IsString()
@MaxLength(280)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
comment!: string;
@IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined || value === '') return undefined;
if (typeof value === 'number') return value.toString();
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length ? trimmed : undefined;
}
return undefined;
})
@IsString()
@Matches(/^\d+$/)
@MaxLength(255)
attachment?: string;
}
export class UpsertExpenseDto {
@IsOptional()
@ValidateNested()
@Type(()=> ExpensePayloadDto)
old_expense?: ExpensePayloadDto;
@IsOptional()
@ValidateNested()
@Type(()=> ExpensePayloadDto)
new_expense?: ExpensePayloadDto;
}

View File

@ -1,14 +0,0 @@
export type UpsertAction = 'create' | 'update' | 'delete';
export interface ExpenseResponse {
date: string;
type: string;
amount: number;
comment: string;
is_approved: boolean;
};
export type UpsertExpenseResult = {
action: UpsertAction;
day: ExpenseResponse[]
};

View File

@ -1,111 +0,0 @@
import { BadRequestException } from "@nestjs/common";
import { ExpenseResponse } from "../types and interfaces/expenses.types.interfaces";
import { Prisma } from "@prisma/client";
//uppercase and trim for validation
export function normalizeType(type: string): string {
return (type ?? '').trim().toUpperCase();
};
//required comment after trim
export function assertAndTrimComment(comment: string): string {
const cmt = (comment ?? '').trim();
if(cmt.length === 0) {
throw new BadRequestException('A comment is required');
}
return cmt;
};
//rounding $ to 2 decimals
export function roundMoney2(num: number): number {
return Math.round((num + Number.EPSILON) * 100)/ 100;
};
export function computeMileageAmount(km: number, modifier: number): number {
if(km < 0) throw new BadRequestException('mileage must be positive');
if(modifier < 0) throw new BadRequestException('modifier must be positive');
return roundMoney2(km * modifier);
};
//compat. types with Prisma.Decimal. work around Prisma import in utils.
export type DecimalLike =
| number
| string
| { toNumber?: () => number }
| { toString?: () => string };
//safe conversion to number
export function toNumberSafe(value: DecimalLike): number {
if(typeof value === 'number') return value;
if(value && typeof (value as any).toNumber === 'function') return (value as any).toNumber();
return Number(
typeof (value as any)?.toString === 'function'
? (value as any).toString()
: value,
);
}
export const parseAttachmentId = (value: unknown): number | null => {
if (value == null) {
return null;
}
if (typeof value === 'number') {
if (!Number.isInteger(value) || value <= 0) {
throw new BadRequestException('Invalid attachment id');
}
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed.length) return null;
if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id');
const parsed = Number(trimmed);
if (parsed <= 0) throw new BadRequestException('Invalid attachment id');
return parsed;
}
throw new BadRequestException('Invalid attachment id');
};
//map of a row for DayExpenseResponse
export function mapDbExpenseToDayResponse(row: {
date: Date;
amount: Prisma.Decimal | number | string | null;
mileage?: Prisma.Decimal | number | string | null;
comment: string;
is_approved: boolean;
bank_code?: { type?: string | null } | null;
}): ExpenseResponse {
const yyyyMmDd = row.date.toISOString().slice(0,10);
const toNum = (value: any)=> (value == null ? 0 : Number(value));
return {
date: yyyyMmDd,
type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'),
amount: toNum(row.amount),
comment: row.comment,
is_approved: row.is_approved,
...(row.mileage !== null ? { mileage: toNum(row.mileage) }: {}),
};
}
export const computeAmountDecimal = (
type: string,
payload: {
amount?: number;
mileage?: number;
},
modifier: number,
): Prisma.Decimal => {
if(type === 'MILEAGE') {
const km = payload.mileage ?? 0;
const amountNumber = computeMileageAmount(km, modifier);
return new Prisma.Decimal(amountNumber);
}
return new Prisma.Decimal(payload.amount!);
};

View File

@ -0,0 +1,66 @@
// import { ApiProperty } from "@nestjs/swagger";
// import { Type } from "class-transformer";
// import { Allow, IsBoolean, IsDate, IsDateString, IsInt, IsNumber, IsOptional, IsString } from "class-validator";
// export class CreateExpenseDto {
// @ApiProperty({
// example: 1,
// description: 'Unique ID of the expense (auto-generated)',
// })
// @Allow()
// id?: number;
// @ApiProperty({
// example: 101,
// description: 'ID number for a set timesheet',
// })
// @Type(()=> Number)
// @IsInt()
// timesheet_id: number;
// @ApiProperty({
// example: 7,
// description: 'ID number of an bank code (link with bank-codes)',
// })
// @Type(() => Number)
// @IsInt()
// bank_code_id: number;
// @ApiProperty({
// example: '3018-10-20T00:00:00.000Z',
// description: 'Date where the expense was made',
// })
// @IsDateString()
// date: string;
// @ApiProperty({
// example: 17.82,
// description: 'amount in $ for a refund',
// })
// @Type(() => Number)
// @IsNumber()
// amount: number;
// @ApiProperty({
// example:'Spent for mileage between A and B',
// description:'explain`s why the expense was made'
// })
// @IsString()
// comment: string;
// @ApiProperty({
// example: 'DENIED, APPROUVED, PENDING, etc...',
// description: 'validation status',
// })
// @IsOptional()
// @IsBoolean()
// is_approved?: boolean;
// @ApiProperty({
// example:'Asked X to go there as an emergency response',
// description:'Supervisro`s justification for the spending of an employee'
// })
// @IsString()
// @IsOptional()
// supervisor_comment?: string;
// }

View File

@ -0,0 +1,14 @@
// export type UpsertAction = 'create' | 'update' | 'delete';
// export interface ExpenseResponse {
// date: string;
// type: string;
// amount: number;
// comment: string;
// is_approved: boolean;
// };
// export type UpsertExpenseResult = {
// action: UpsertAction;
// day: ExpenseResponse[]
// };

View File

@ -0,0 +1,111 @@
// import { BadRequestException } from "@nestjs/common";
// import { ExpenseResponse } from "../types and interfaces/expenses.types.interfaces";
// import { Prisma } from "@prisma/client";
// //uppercase and trim for validation
// export function normalizeType(type: string): string {
// return (type ?? '').trim().toUpperCase();
// };
// //required comment after trim
// export function assertAndTrimComment(comment: string): string {
// const cmt = (comment ?? '').trim();
// if(cmt.length === 0) {
// throw new BadRequestException('A comment is required');
// }
// return cmt;
// };
// //rounding $ to 2 decimals
// export function roundMoney2(num: number): number {
// return Math.round((num + Number.EPSILON) * 100)/ 100;
// };
// export function computeMileageAmount(km: number, modifier: number): number {
// if(km < 0) throw new BadRequestException('mileage must be positive');
// if(modifier < 0) throw new BadRequestException('modifier must be positive');
// return roundMoney2(km * modifier);
// };
// //compat. types with Prisma.Decimal. work around Prisma import in utils.
// export type DecimalLike =
// | number
// | string
// | { toNumber?: () => number }
// | { toString?: () => string };
// //safe conversion to number
// export function toNumberSafe(value: DecimalLike): number {
// if(typeof value === 'number') return value;
// if(value && typeof (value as any).toNumber === 'function') return (value as any).toNumber();
// return Number(
// typeof (value as any)?.toString === 'function'
// ? (value as any).toString()
// : value,
// );
// }
// export const parseAttachmentId = (value: unknown): number | null => {
// if (value == null) {
// return null;
// }
// if (typeof value === 'number') {
// if (!Number.isInteger(value) || value <= 0) {
// throw new BadRequestException('Invalid attachment id');
// }
// return value;
// }
// if (typeof value === 'string') {
// const trimmed = value.trim();
// if (!trimmed.length) return null;
// if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id');
// const parsed = Number(trimmed);
// if (parsed <= 0) throw new BadRequestException('Invalid attachment id');
// return parsed;
// }
// throw new BadRequestException('Invalid attachment id');
// };
// //map of a row for DayExpenseResponse
// export function mapDbExpenseToDayResponse(row: {
// date: Date;
// amount: Prisma.Decimal | number | string | null;
// mileage?: Prisma.Decimal | number | string | null;
// comment: string;
// is_approved: boolean;
// bank_code?: { type?: string | null } | null;
// }): ExpenseResponse {
// const yyyyMmDd = row.date.toISOString().slice(0,10);
// const toNum = (value: any)=> (value == null ? 0 : Number(value));
// return {
// date: yyyyMmDd,
// type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'),
// amount: toNum(row.amount),
// comment: row.comment,
// is_approved: row.is_approved,
// ...(row.mileage !== null ? { mileage: toNum(row.mileage) }: {}),
// };
// }
// export const computeAmountDecimal = (
// type: string,
// payload: {
// amount?: number;
// mileage?: number;
// },
// modifier: number,
// ): Prisma.Decimal => {
// if(type === 'MILEAGE') {
// const km = payload.mileage ?? 0;
// const amountNumber = computeMileageAmount(km, modifier);
// return new Prisma.Decimal(amountNumber);
// }
// return new Prisma.Decimal(payload.amount!);
// };

View File

@ -0,0 +1,26 @@
// import { Type } from "class-transformer";
// import { IsDateString, IsInt, IsOptional, IsString } from "class-validator";
// export class SearchExpensesDto {
// @IsOptional()
// @Type(()=> Number)
// @IsInt()
// timesheet_id?: number;
// @IsOptional()
// @Type(()=> Number)
// @IsInt()
// bank_code_id?: number;
// @IsOptional()
// @IsString()
// comment_contains?: string;
// @IsOptional()
// @IsDateString()
// start_date: string;
// @IsOptional()
// @IsDateString()
// end_date: string;
// }

View File

@ -0,0 +1,4 @@
// import { PartialType } from "@nestjs/swagger";
// import { CreateExpenseDto } from "./create-expense.dto";
// export class UpdateExpenseDto extends PartialType(CreateExpenseDto) {}

View File

@ -0,0 +1,59 @@
// import { Transform, Type } from "class-transformer";
// import {
// IsNumber,
// IsOptional,
// IsString,
// Matches,
// MaxLength,
// Min,
// ValidateIf,
// ValidateNested
// } from "class-validator";
// export class ExpensePayloadDto {
// @IsString()
// type!: string;
// @ValidateIf(o => (o.type ?? '').toUpperCase() !== 'MILEAGE')
// @IsNumber()
// @Min(0)
// amount?: number;
// @ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE')
// @IsNumber()
// @Min(0)
// mileage?: number;
// @IsString()
// @MaxLength(280)
// @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
// comment!: string;
// @IsOptional()
// @Transform(({ value }) => {
// if (value === null || value === undefined || value === '') return undefined;
// if (typeof value === 'number') return value.toString();
// if (typeof value === 'string') {
// const trimmed = value.trim();
// return trimmed.length ? trimmed : undefined;
// }
// return undefined;
// })
// @IsString()
// @Matches(/^\d+$/)
// @MaxLength(255)
// attachment?: string;
// }
// export class UpsertExpenseDto {
// @IsOptional()
// @ValidateNested()
// @Type(()=> ExpensePayloadDto)
// old_expense?: ExpensePayloadDto;
// @IsOptional()
// @ValidateNested()
// @Type(()=> ExpensePayloadDto)
// new_expense?: ExpensePayloadDto;
// }

View File

@ -1,17 +1,26 @@
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { GetTimesheetsOverviewService } from "../services/timesheet-get-overview.service"; import { GetTimesheetsOverviewService } from "../services/timesheet-get-overview.service";
import { Controller, Get, Query} from "@nestjs/common"; import { BadRequestException, Controller, Get, Query} from "@nestjs/common";
@Controller('timesheets') @Controller('timesheets')
export class TimesheetController { export class TimesheetController {
constructor(private readonly timesheetOverview: GetTimesheetsOverviewService){} constructor(
private readonly timesheetOverview: GetTimesheetsOverviewService,
private readonly emailResolver: EmailToIdResolver,
){}
@Get() @Get()
async getTimesheetByIds( async getTimesheetByIds(
@Query('timesheet_ids') timesheet_ids: string ) { @Query('employee_email') employee_email: string,
const parsed = timesheet_ids.split(/,\s*/).map(value => Number(value)).filter(Number.isFinite); @Query('year') year: string,
return this.timesheetOverview.getTimesheetsByIds(parsed); @Query('period_number') period_number: string,
) {
if (!employee_email || !year || !period_number) {
throw new BadRequestException('Query params "employee_email", "year" and eriod_number" are required.');
}
const employee_id = await this.emailResolver.findIdByEmail(employee_email);
const pay_year = Number(year);
const period_num = Number(period_number);
return this.timesheetOverview.getTimesheetsForEmployeeByPeriod(employee_id, pay_year, period_num);
} }
}
}

View File

@ -1,6 +1,6 @@
import { sevenDaysFrom, toDateFromString, toHHmmFromDate, toStringFromDate } from "../helpers/timesheets-date-time-helpers";
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 { sevenDaysFrom, toDateFromString, toHHmmFromDate, toStringFromDate } from "../helpers/timesheets-date-time-helpers";
type TotalHours = { type TotalHours = {
regular: number; regular: number;
@ -23,26 +23,31 @@ type TotalExpenses = {
export class GetTimesheetsOverviewService { export class GetTimesheetsOverviewService {
constructor(private readonly prisma: PrismaService) { } constructor(private readonly prisma: PrismaService) { }
async getTimesheetsByIds(timesheet_ids: number[]) { async getTimesheetsForEmployeeByPeriod(employee_id: number, pay_year: number, pay_period_no: number) {
if (!Array.isArray(timesheet_ids) || timesheet_ids.length === 0) throw new NotFoundException(`Timesheet_ids are missing`); //find period using year and period_no
const period = await this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no },
});
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${pay_period_no} not found`);
//fetch all needed data using timesheet ids //loads the timesheets related to the fetched pay-period
const rows = await this.prisma.timesheets.findMany({ const rows = await this.loadTimesheets({
where: { id: { in: timesheet_ids } }, employee_id,
include: { start_date: { gte: period.period_start, lte: period.period_end },
employee: { include: { user: true } },
shift: { include: { bank_code: true } },
expense: { include: { bank_code: true, attachment_record: true } },
},
orderBy: { start_date: 'asc' },
}); });
if (rows.length === 0) throw new NotFoundException('Timesheet(s) not found'); //find user infos using the employee_id
const employee = await this.prisma.employees.findUnique({
//build full name where: { id: employee_id },
const user = rows[0].employee.user; include: { user: true },
});
if (!employee) throw new NotFoundException(`Employee #${employee_id} not found`);
//builds employee full name
const user = employee.user;
const employee_fullname = `${user.first_name} ${user.last_name}`.trim(); const employee_fullname = `${user.first_name} ${user.last_name}`.trim();
//maps all timesheet's infos
const timesheets = rows.map((timesheet) => this.mapOneTimesheet(timesheet)); const timesheets = rows.map((timesheet) => this.mapOneTimesheet(timesheet));
return { employee_fullname, timesheets }; return { employee_fullname, timesheets };
} }
@ -51,16 +56,29 @@ export class GetTimesheetsOverviewService {
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
// MAPPERS & HELPERS // MAPPERS & HELPERS
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
//fetch timesheet's infos
private async loadTimesheets(where: any) {
return this.prisma.timesheets.findMany({
where,
include: {
employee: { include: { user: true } },
shift: { include: { bank_code: true } },
expense: { include: { bank_code: true, attachment_record: true } },
},
orderBy: { start_date: 'asc' },
});
}
private mapOneTimesheet(timesheet: any) { private mapOneTimesheet(timesheet: any) {
//converts string to UTC date format //converts string to UTC date format
const start = toDateFromString(timesheet.start_date); const start = toDateFromString(timesheet.start_date);
const day_dates = sevenDaysFrom(start); const day_dates = sevenDaysFrom(start);
//map of shifts by days //map of shifts by days
const shifts_by_date = new Map<string, any[]>(); const shifts_by_date = new Map<string, any[]>();
for (const shift of timesheet.shift) { for (const shift of timesheet.shift) {
const date = toStringFromDate(shift.date); const date = toStringFromDate(shift.date);
const arr = shifts_by_date.get(date) ?? []; const arr = shifts_by_date.get(date) ?? [];
arr.push(shift); arr.push(shift);
shifts_by_date.set(date, arr); shifts_by_date.set(date, arr);
} }
@ -68,40 +86,40 @@ export class GetTimesheetsOverviewService {
const expenses_by_date = new Map<string, any[]>(); const expenses_by_date = new Map<string, any[]>();
for (const expense of timesheet.expense) { for (const expense of timesheet.expense) {
const date = toStringFromDate(expense.date); const date = toStringFromDate(expense.date);
const arr = expenses_by_date.get(date) ?? []; const arr = expenses_by_date.get(date) ?? [];
arr.push(expense); arr.push(expense);
expenses_by_date.set(date, arr); expenses_by_date.set(date, arr);
} }
//weekly totals //weekly totals
const weekly_hours: TotalHours[] = [emptyHours()]; const weekly_hours: TotalHours[] = [emptyHours()];
const weekly_expenses: TotalExpenses[] = [emptyExpenses()]; const weekly_expenses: TotalExpenses[] = [emptyExpenses()];
//map of days //map of days
const days = day_dates.map((date) => { const days = day_dates.map((date) => {
const date_iso = toStringFromDate(date); const date_iso = toStringFromDate(date);
const shifts_source = shifts_by_date.get(date_iso) ?? []; const shifts_source = shifts_by_date.get(date_iso) ?? [];
const expenses_source = expenses_by_date.get(date_iso) ?? []; const expenses_source = expenses_by_date.get(date_iso) ?? [];
//inner map of shifts //inner map of shifts
const shifts = shifts_source.map((shift) => ({ const shifts = shifts_source.map((shift) => ({
date: toStringFromDate(shift.date), date: toStringFromDate(shift.date),
start_time: toHHmmFromDate(shift.start_time), start_time: toHHmmFromDate(shift.start_time),
end_time: toHHmmFromDate(shift.end_time), end_time: toHHmmFromDate(shift.end_time),
type: shift.bank_code?.type ?? '', type: shift.bank_code?.type ?? '',
is_remote: shift.is_remote ?? false, is_remote: shift.is_remote ?? false,
is_approved: shift.is_approved ?? false, is_approved: shift.is_approved ?? false,
shift_id: shift.id ?? null, shift_id: shift.id ?? null,
comment: shift.comment ?? null, comment: shift.comment ?? null,
})); }));
//inner map of expenses //inner map of expenses
const expenses = expenses_source.map((expense) => ({ const expenses = expenses_source.map((expense) => ({
date: toStringFromDate(expense.date), date: toStringFromDate(expense.date),
amount: expense.amount ? Number(expense.amount) : undefined, amount: expense.amount ? Number(expense.amount) : undefined,
mileage: expense.mileage ? Number(expense.mileage) : undefined, mileage: expense.mileage ? Number(expense.mileage) : undefined,
expense_id: expense.id ?? null, expense_id: expense.id ?? null,
attachment: expense.attachment_record ? String(expense.attachment_record.id) : undefined, attachment: expense.attachment_record ? String(expense.attachment_record.id) : undefined,
is_approved: expense.is_approved ?? false, is_approved: expense.is_approved ?? false,
comment: expense.comment ?? '', comment: expense.comment ?? '',
supervisor_comment: expense.supervisor_comment, supervisor_comment: expense.supervisor_comment,
})); }));
@ -113,7 +131,7 @@ export class GetTimesheetsOverviewService {
for (const shift of shifts_source) { for (const shift of shifts_source) {
const hours = diffOfHours(shift.start_time, shift.end_time); const hours = diffOfHours(shift.start_time, shift.end_time);
const subgroup = hoursSubGroupFromBankCode(shift.bank_code); const subgroup = hoursSubGroupFromBankCode(shift.bank_code);
daily_hours[0][subgroup] += hours; daily_hours[0][subgroup] += hours;
weekly_hours[0][subgroup] += hours; weekly_hours[0][subgroup] += hours;
} }
@ -148,7 +166,7 @@ export class GetTimesheetsOverviewService {
}); });
return { return {
timesheet_id: timesheet.id, timesheet_id: timesheet.id,
is_approved: timesheet.is_approved ?? false, is_approved: timesheet.is_approved ?? false,
days, days,
weekly_hours, weekly_hours,
weekly_expenses, weekly_expenses,
@ -156,37 +174,36 @@ export class GetTimesheetsOverviewService {
} }
} }
const emptyHours = (): TotalHours => { //filled array with default values
return { regular: 0, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0 }; const emptyHours = (): TotalHours => { return { regular: 0, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0 } };
} const emptyExpenses = (): TotalExpenses => { return { expenses: 0, per_diem: 0, on_call: 0, mileage: 0 } };
const emptyExpenses = (): TotalExpenses => {
return { expenses: 0, per_diem: 0, on_call: 0, mileage: 0 };
}
//calculate the differences of hours
const diffOfHours = (a: Date, b: Date): number => { const diffOfHours = (a: Date, b: Date): number => {
const ms = new Date(b).getTime() - new Date(a).getTime(); const ms = new Date(b).getTime() - new Date(a).getTime();
return Math.max(0, Math.round((ms / 36e5) * 1000) / 1000); return Math.max(0, Math.round((ms / 36e5) * 1000) / 1000);
} }
const num = (value: any): number => { //validate numeric values
return value ? Number(value) : 0; const num = (value: any): number => { return value ? Number(value) : 0 };
}
// shift's subgroup types
const hoursSubGroupFromBankCode = (bank_code: any): keyof TotalHours => { const hoursSubGroupFromBankCode = (bank_code: any): keyof TotalHours => {
const type = bank_code.type; const type = bank_code.type;
if (type.includes('EVENING')) return 'evening'; if (type.includes('EVENING')) return 'evening';
if (type.includes('EMERGENCY')) return 'emergency'; if (type.includes('EMERGENCY')) return 'emergency';
if (type.includes('OVERTIME')) return 'overtime'; if (type.includes('OVERTIME')) return 'overtime';
if (type.includes('VACATION')) return 'vacation'; if (type.includes('VACATION')) return 'vacation';
if (type.includes('HOLIDAY')) return 'holiday'; if (type.includes('HOLIDAY')) return 'holiday';
if (type.includes('SICK')) return 'sick'; if (type.includes('SICK')) return 'sick';
return 'regular' return 'regular'
} }
// expense's subgroup types
const expenseSubgroupFromBankCode = (bank_code: any): keyof TotalExpenses => { const expenseSubgroupFromBankCode = (bank_code: any): keyof TotalExpenses => {
const type = bank_code.type; const type = bank_code.type;
if (type.includes('MILEAGE')) return 'mileage'; if (type.includes('MILEAGE')) return 'mileage';
if (type.includes('PER_DIEM')) return 'per_diem'; if (type.includes('PER_DIEM')) return 'per_diem';
if (type.includes('ON_CALL')) return 'on_call'; if (type.includes('ON_CALL')) return 'on_call';
return 'expenses'; return 'expenses';
} }

View File

@ -16,9 +16,8 @@ import { Module } from '@nestjs/common';
providers: [ providers: [
TimesheetArchiveService, TimesheetArchiveService,
GetTimesheetsOverviewService, GetTimesheetsOverviewService,
SharedModule,
], ],
exports: [ exports: [],
],
}) })
export class TimesheetsModule {} export class TimesheetsModule {}