Merge branch 'main' of git.targo.ca:Targo/targo_backend

This commit is contained in:
Nicolas Drolet 2025-10-22 08:09:54 -04:00
commit dd15a6dc14
83 changed files with 5083 additions and 3523 deletions

View File

@ -29,99 +29,31 @@
]
}
},
"/archives/employees": {
"/auth/v1/login": {
"get": {
"operationId": "EmployeesArchiveController_findOneArchived",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"operationId": "AuthController_login",
"parameters": [],
"responses": {
"200": {
"description": "Archived employee found"
"description": ""
}
},
"summary": "Fetch employee in archives with its Id",
"tags": [
"Employee Archives"
"Auth"
]
}
},
"/archives/expenses": {
"/auth/callback": {
"get": {
"operationId": "ExpensesArchiveController_findOneArchived",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"operationId": "AuthController_loginCallback",
"parameters": [],
"responses": {
"200": {
"description": "Archived expense found"
"description": ""
}
},
"summary": "Fetch expense in archives with its Id",
"tags": [
"Expense Archives"
]
}
},
"/archives/shifts": {
"get": {
"operationId": "ShiftsArchiveController_findOneArchived",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "Archived shift found"
}
},
"summary": "Fetch shift in archives with its Id",
"tags": [
"Shift Archives"
]
}
},
"/archives/timesheets": {
"get": {
"operationId": "TimesheetsArchiveController_findOneArchived",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "Archived timesheet found"
}
},
"summary": "Fetch timesheet in archives with its Id",
"tags": [
"Timesheet Archives"
"Auth"
]
}
},
@ -221,6 +153,8 @@
]
}
},
<<<<<<< HEAD
=======
"/employees/profile/{email}": {
"get": {
"operationId": "EmployeesController_findOneProfile",
@ -635,6 +569,7 @@
]
}
},
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
"/notifications/summary": {
"get": {
"operationId": "NotificationsController_summary",
@ -663,6 +598,8 @@
]
}
},
<<<<<<< HEAD
=======
"/leave-requests/upsert": {
"post": {
"operationId": "LeaveRequestController_upsertLeaveRequest",
@ -734,6 +671,7 @@
]
}
},
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
"/oauth-sessions": {
"post": {
"operationId": "OauthSessionsController_create",
@ -928,251 +866,6 @@
]
}
},
"/pay-periods/current-and-all": {
"get": {
"operationId": "PayPeriodsController_getCurrentAndAll",
"parameters": [
{
"name": "date",
"required": false,
"in": "query",
"description": "Override for resolving the current period",
"schema": {
"example": "2025-08-11",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Find current and all pay periods",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodBundleDto"
}
}
}
}
},
"summary": "Return current pay period and the full list",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/date/{date}": {
"get": {
"operationId": "PayPeriodsController_findByDate",
"parameters": [
{
"name": "date",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Pay period found for the selected date",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodDto"
}
}
}
},
"404": {
"description": "Pay period not found for the selected date"
}
},
"summary": "Resolve a period by a date within it",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/{year}/{periodNumber}": {
"get": {
"operationId": "PayPeriodsController_findOneByYear",
"parameters": [
{
"name": "year",
"required": true,
"in": "path",
"schema": {
"example": 2024,
"type": "number"
}
},
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
}
}
],
"responses": {
"200": {
"description": "Pay period found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodDto"
}
}
}
},
"404": {
"description": "Pay period not found"
}
},
"summary": "Find pay period by year and period number",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/crew/bulk-approval": {
"patch": {
"operationId": "PayPeriodsController_bulkApproval",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkCrewApprovalDto"
}
}
}
},
"responses": {
"200": {
"description": "Pay period approved"
}
},
"summary": "Approve all selected timesheets in the period",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/{year}/{periodNumber}/{email}": {
"get": {
"operationId": "PayPeriodsController_getCrewOverview",
"parameters": [
{
"name": "year",
"required": true,
"in": "path",
"schema": {
"example": 2024,
"type": "number"
}
},
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
}
},
{
"name": "email",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "includeSubtree",
"required": false,
"in": "query",
"description": "Include indirect reports",
"schema": {
"example": false,
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "Crew overview",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodOverviewDto"
}
}
}
},
"404": {
"description": "Pay period not found"
}
},
"summary": "Supervisor crew overview for a given pay period",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/overview/{year}/{periodNumber}": {
"get": {
"operationId": "PayPeriodsController_getOverviewByYear",
"parameters": [
{
"name": "year",
"required": true,
"in": "path",
"schema": {
"example": 2024,
"type": "number"
}
},
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
}
}
],
"responses": {
"200": {
"description": "Pay period overview found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodOverviewDto"
}
}
}
},
"404": {
"description": "Pay period not found"
}
},
"summary": "Detailed view of a pay period by year + number",
"tags": [
"pay-periods"
]
}
},
"/preferences/{email}": {
"patch": {
"operationId": "PreferencesController_updatePreferences",
@ -1306,6 +999,123 @@
"SchedulePresets"
]
}
},
"/shift": {
"get": {
"operationId": "ShiftController_getShiftsByIds",
"parameters": [
{
"name": "shift_ids",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Shift"
]
},
"patch": {
"operationId": "ShiftController_updateBatch",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Shift"
]
}
},
"/shift/{timesheet_id}": {
"post": {
"operationId": "ShiftController_createBatch",
"parameters": [
{
"name": "timesheet_id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"responses": {
"201": {
"description": ""
}
},
"tags": [
"Shift"
]
}
},
"/shift/{shift_id}": {
"delete": {
"operationId": "ShiftController_remove",
"parameters": [
{
"name": "shift_id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Shift"
]
}
},
"/timesheets": {
"get": {
"operationId": "TimesheetController_getTimesheetByIds",
"parameters": [
{
"name": "timesheet_ids",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Timesheet"
]
}
}
},
"info": {
@ -1517,6 +1327,8 @@
"first_work_day"
]
},
<<<<<<< HEAD
=======
"EmployeeProfileItemDto": {
"type": "object",
"properties": {}
@ -1537,6 +1349,7 @@
"type": "object",
"properties": {}
},
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
"CreateOauthSessionDto": {
"type": "object",
"properties": {
@ -1645,172 +1458,6 @@
}
}
},
"PayPeriodDto": {
"type": "object",
"properties": {
"pay_period_no": {
"type": "number",
"example": 1,
"description": "numéro cyclique de la période entre 1 et 26"
},
"period_start": {
"type": "string",
"example": "2023-12-17",
"format": "date"
},
"period_end": {
"type": "string",
"example": "2023-12-30",
"format": "date"
},
"payday": {
"type": "string",
"example": "2023-01-04",
"format": "date"
},
"pay_year": {
"type": "number",
"example": 2023
},
"label": {
"type": "string",
"example": "2023-12-17 → 2023-12-30"
}
},
"required": [
"pay_period_no",
"period_start",
"period_end",
"payday",
"pay_year",
"label"
]
},
"PayPeriodBundleDto": {
"type": "object",
"properties": {
"current": {
"description": "Current pay period (resolved from date)",
"allOf": [
{
"$ref": "#/components/schemas/PayPeriodDto"
}
]
},
"periods": {
"description": "All pay periods",
"type": "array",
"items": {
"$ref": "#/components/schemas/PayPeriodDto"
}
}
},
"required": [
"current",
"periods"
]
},
"BulkCrewApprovalDto": {
"type": "object",
"properties": {}
},
"EmployeePeriodOverviewDto": {
"type": "object",
"properties": {
"employee_name": {
"type": "string",
"example": "Alex Dupont",
"description": "Nom complet de lemployé"
},
"regular_hours": {
"type": "number",
"example": 40,
"description": "pay-period`s regular hours"
},
"other_hours": {
"type": "object",
"example": 0,
"description": "pay-period`s other hours"
},
"expenses": {
"type": "number",
"example": 420.69,
"description": "pay-period`s total expenses ($)"
},
"mileage": {
"type": "number",
"example": 40,
"description": "pay-period total mileages (km)"
},
"is_approved": {
"type": "boolean",
"example": true,
"description": "Tous les timesheets de la période sont approuvés pour cet employé"
}
},
"required": [
"employee_name",
"regular_hours",
"other_hours",
"expenses",
"mileage",
"is_approved"
]
},
"PayPeriodOverviewDto": {
"type": "object",
"properties": {
"pay_period_no": {
"type": "number",
"example": 1,
"description": "Period number (126)"
},
"pay_year": {
"type": "number",
"example": 2023,
"description": "Calendar year of the period"
},
"period_start": {
"type": "string",
"example": "2023-12-17",
"format": "date",
"description": "Period start date (YYYY-MM-DD)"
},
"period_end": {
"type": "string",
"example": "2023-12-30",
"format": "date",
"description": "Period end date (YYYY-MM-DD)"
},
"payday": {
"type": "string",
"example": "2023-12-30",
"format": "date",
"description": "Period pay day(YYYY-MM-DD)"
},
"label": {
"type": "string",
"example": "2023-12-17 → 2023-12-30",
"description": "Human-readable label"
},
"employees_overview": {
"description": "Per-employee overview for the period",
"type": "array",
"items": {
"$ref": "#/components/schemas/EmployeePeriodOverviewDto"
}
}
},
"required": [
"pay_period_no",
"pay_year",
"period_start",
"period_end",
"payday",
"label",
"employees_overview"
]
},
"PreferencesDto": {
"type": "object",
"properties": {}

1030
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"start:variants": "node dist/attachments/workers/variants.worker.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
@ -48,17 +49,20 @@
"@nestjs/platform-express": "^11.1.6",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.2.0",
"@prisma/client": "^6.14.0",
"@prisma/client": "^6.17.1",
"bullmq": "^5.58.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"express-session": "^1.18.2",
"file-type": "^21.0.0",
"ioredis": "^5.7.0",
"multer": "^2.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-openidconnect": "^0.1.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"sharp": "^0.34.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@ -82,7 +86,7 @@
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^6.17.0",
"prisma": "^6.17.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",

View File

@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "public"."attachment_variants" (
"id" SERIAL NOT NULL,
"attachment_id" INTEGER NOT NULL,
"variant" TEXT NOT NULL,
"patch" TEXT NOT NULL,
"bytes" INTEGER NOT NULL,
"width" INTEGER,
"height" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "attachment_variants_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "attachment_variants_attachment_id_variant_key" ON "public"."attachment_variants"("attachment_id", "variant");
-- AddForeignKey
ALTER TABLE "public"."attachment_variants" ADD CONSTRAINT "attachment_variants_attachment_id_fkey" FOREIGN KEY ("attachment_id") REFERENCES "public"."attachments"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -340,14 +340,14 @@ model Blobs {
refcount Int @default(0)
created_at DateTime @default(now())
attachments Attachments[]
attachments Attachments[] @relation("AttachmnentBlob")
@@map("blobs")
}
model Attachments {
id Int @id @default(autoincrement())
blob Blobs @relation(fields: [sha256], references: [sha256], onUpdate: Cascade)
blob Blobs @relation("AttachmnentBlob",fields: [sha256], references: [sha256], onUpdate: Cascade)
sha256 String @db.Char(64)
owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc
@ -361,11 +361,28 @@ model Attachments {
expenses Expenses[] @relation("ExpenseAttachment")
expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment")
AttachmentVariants AttachmentVariants[] @relation("attachmentVariantAttachment")
@@index([owner_type, owner_id, created_at])
@@index([sha256])
@@map("attachments")
}
model AttachmentVariants {
id Int @id @default(autoincrement())
attachment_id Int
attachment Attachments @relation("attachmentVariantAttachment",fields: [attachment_id], references: [id], onDelete: Cascade)
variant String
patch String
bytes Int
width Int?
height Int?
created_at DateTime @default(now())
@@unique([attachment_id, variant])
@@map("attachment_variants")
}
model Preferences {
id Int @id @default(autoincrement())
user Users @relation("UserPreferences", fields: [user_id], references: [id])

View File

@ -1,21 +1,21 @@
import { BadRequestException, Module, ValidationPipe } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ArchivalModule } from './modules/archival/archival.module';
// import { ArchivalModule } from './modules/archival/archival.module';
import { AuthenticationModule } from './modules/authentication/auth.module';
import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
import { BusinessLogicsModule } from './modules/business-logics/business-logics.module';
// import { CsvExportModule } from './modules/exports/csv-exports.module';
import { CustomersModule } from './modules/customers/customers.module';
import { EmployeesModule } from './modules/employees/employees.module';
import { ExpensesModule } from './modules/expenses/expenses.module';
// import { ExpensesModule } from './modules/expenses/expenses.module';
import { HealthModule } from './health/health.module';
import { HealthController } from './health/health.controller';
import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module';
// import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module';
import { OvertimeService } from './modules/business-logics/services/overtime.service';
import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
// import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
import { PreferencesModule } from './modules/preferences/preferences.module';
import { PrismaModule } from './prisma/prisma.module';
import { ScheduleModule } from '@nestjs/schedule';
@ -30,7 +30,7 @@ import { SchedulePresetsModule } from './modules/schedule-presets/schedule-prese
@Module({
imports: [
ArchivalModule,
// ArchivalModule,
AuthenticationModule,
BankCodesModule,
BusinessLogicsModule,
@ -38,12 +38,12 @@ import { SchedulePresetsModule } from './modules/schedule-presets/schedule-prese
// CsvExportModule,
CustomersModule,
EmployeesModule,
ExpensesModule,
// ExpensesModule,
HealthModule,
LeaveRequestsModule,
// LeaveRequestsModule,
NotificationsModule,
OauthSessionsModule,
PayperiodsModule,
// PayperiodsModule,
PreferencesModule,
PrismaModule,
ScheduleModule.forRoot(), //cronjobs

View File

@ -1,34 +1,34 @@
import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { TimesheetsModule } from "../timesheets/timesheets.module";
import { ExpensesModule } from "../expenses/expenses.module";
import { ShiftsModule } from "../shifts/shifts.module";
import { LeaveRequestsModule } from "../leave-requests/leave-requests.module";
import { ArchivalService } from "./services/archival.service";
import { EmployeesArchiveController } from "./controllers/employees-archive.controller";
import { ExpensesArchiveController } from "./controllers/expenses-archive.controller";
import { LeaveRequestsArchiveController } from "./controllers/leave-requests-archive.controller";
import { ShiftsArchiveController } from "./controllers/shifts-archive.controller";
import { TimesheetsArchiveController } from "./controllers/timesheets-archive.controller";
import { EmployeesModule } from "../employees/employees.module";
// import { Module } from "@nestjs/common";
// import { ScheduleModule } from "@nestjs/schedule";
// import { TimesheetsModule } from "../timesheets/timesheets.module";
// import { ExpensesModule } from "../expenses/expenses.module";
// import { ShiftsModule } from "../shifts/shifts.module";
// import { LeaveRequestsModule } from "../leave-requests/leave-requests.module";
// import { ArchivalService } from "./services/archival.service";
// import { EmployeesArchiveController } from "./controllers/employees-archive.controller";
// import { ExpensesArchiveController } from "./controllers/expenses-archive.controller";
// import { LeaveRequestsArchiveController } from "./controllers/leave-requests-archive.controller";
// import { ShiftsArchiveController } from "./controllers/shifts-archive.controller";
// import { TimesheetsArchiveController } from "./controllers/timesheets-archive.controller";
// import { EmployeesModule } from "../employees/employees.module";
@Module({
imports: [
EmployeesModule,
ScheduleModule,
TimesheetsModule,
ExpensesModule,
ShiftsModule,
LeaveRequestsModule,
],
providers: [ArchivalService],
controllers: [
EmployeesArchiveController,
ExpensesArchiveController,
LeaveRequestsArchiveController,
ShiftsArchiveController,
TimesheetsArchiveController,
],
})
// @Module({
// imports: [
// EmployeesModule,
// ScheduleModule,
// TimesheetsModule,
// ExpensesModule,
// ShiftsModule,
// LeaveRequestsModule,
// ],
// providers: [ArchivalService],
// controllers: [
// EmployeesArchiveController,
// ExpensesArchiveController,
// LeaveRequestsArchiveController,
// ShiftsArchiveController,
// TimesheetsArchiveController,
// ],
// })
export class ArchivalModule {}
// export class ArchivalModule {}

View File

@ -0,0 +1,19 @@
import { ScheduleModule } from "@nestjs/schedule";
import { PrismaService } from "src/prisma/prisma.service";
import { ArchivalAttachmentService } from "./services/archival-attachment.service";
import { Module } from "@nestjs/common";
import { GarbargeCollectorService } from "./services/garbage-collector.service";
@Module({
imports: [ScheduleModule.forRoot()],
providers: [
PrismaService,
ArchivalAttachmentService,
GarbargeCollectorService,
],
exports: [
ArchivalAttachmentService,
GarbargeCollectorService
],
})
export class ArchivalAttachmentModule {}

View File

@ -2,11 +2,14 @@ import { FileInterceptor } from "@nestjs/platform-express";
import { DiskStorageService } from "../services/disk-storage.service";
import {
Controller,NotFoundException, UseInterceptors, Post, Get, Param, Res,
UploadedFile, BadRequestException, UnsupportedMediaTypeException, Body, Delete
UploadedFile, BadRequestException, UnsupportedMediaTypeException, Body, Delete,
Query,
DefaultValuePipe,
ParseIntPipe
} from "@nestjs/common";
import { maxUploadBytes, allowedMimes } from "../config/upload.config";
import { memoryStorage } from 'multer';
import { fileTypeFromBuffer } from "file-type";
import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
import { Readable } from "node:stream";
import { PrismaService } from "src/prisma/prisma.service";
import { UploadMetaAttachmentsDto } from "../dtos/upload-meta-attachments.dto";
@ -15,27 +18,35 @@ import * as path from 'node:path';
import { promises as fsp } from 'node:fs';
import { createReadStream } from "node:fs";
import { Response } from 'express';
import { VariantsQueue } from "../services/variants.queue";
import { AdminSearchDto } from "../dtos/admin-search.dto";
@Controller('attachments')
export class AttachmentsController {
constructor(
private readonly disk: DiskStorageService,
private readonly prisma: PrismaService,
private readonly variantsQ: VariantsQueue,
) {}
@Get(':id')
async getById(@Param('id') id: string, @Res() res: Response) {
async getById(
@Param('id') id: string,
@Query('variant') variant: string | undefined,
@Res() res: Response,
) {
const num_id = Number(id);
if(!Number.isFinite(num_id)) throw new NotFoundException('Invalid id');
const att = await this.prisma.attachments.findUnique({
const attachment = await this.prisma.attachments.findUnique({
where: { id: num_id },
include: { blob: true },
});
if (!att) throw new NotFoundException();
if (!attachment) throw new NotFoundException();
const relative = variant ? `${attachment.blob.storage_path}.${variant}` : attachment.blob.storage_path;
const abs = path.join(resolveAttachmentsRoot(), relative);
const abs = path.join(resolveAttachmentsRoot(), att.blob.storage_path);
let stat;
try {
stat = await fsp.stat(abs);
@ -43,9 +54,14 @@ export class AttachmentsController {
throw new NotFoundException('File not found');
}
res.set('Content-Type', att.blob.mime);
let mime = attachment.blob.mime;
try {
const kind = await fileTypeFromFile(abs);
if(kind?.mime) mime = kind.mime;
} catch {}
res.set('Content-Type', mime);
res.set('Content-Length', String(stat.size));
res.set('ETag', `"sha256-${att.blob.sha256}"`);
res.set('ETag', `"sha256-${attachment.blob.sha256}${variant ? '.'+variant : ''}"`);
res.set('Last-Modified', stat.mtime.toUTCString());
res.set('Cache-Control', 'private, max-age=31536000, immutable');
res.set('X-Content-Type-Options', 'nosniff');
@ -53,7 +69,17 @@ export class AttachmentsController {
createReadStream(abs).pipe(res);
}
// DEV version, uncomment once connected to DB and distant server
@Get('variants/:id')
async listVariants(@Param('id')id: string) {
const num_id = Number(id);
if(!Number.isFinite(num_id)) throw new NotFoundException('Invalid variant id');
return this.prisma.attachmentVariants.findMany({
where: { attachment_id: num_id },
orderBy: { variant: 'asc'},
select: { variant: true, bytes: true, width: true, height: true, patch: true, created_at: true },
});
}
@Delete(':id')
async remove(@Param('id') id: string) {
const result = await this.prisma.$transaction(async (tx) => {
@ -136,6 +162,8 @@ export class AttachmentsController {
return att;
});
await this.variantsQ.enqueue(attachment.id, detected_mime);
return {
ok: true,
id: attachment.id,
@ -148,4 +176,39 @@ export class AttachmentsController {
owner_id: attachment.owner_id,
};
}
@Get('/admin/search')
async adminSearch(
@Query() query: AdminSearchDto ) {
const where: any = {};
if (query.owner_type) where.owner_type = query.owner_type;
if (query.owner_id) where.owner_id = query.owner_id;
if (query.date_from || query.date_to) {
where.created_at = {};
if (query.date_from) where.created_at.gte = new Date(query.date_from + 'T00:00:00Z');
if (query.date_to) where.created_at.lte = new Date(query.date_to + 'T23:59:59Z');
}
const page = query.page ?? 1;
const page_size = query.page_size ?? 50;
const skip = (page - 1)* page_size;
const take = page_size;
const [items, total] = await this.prisma.$transaction([
this.prisma.attachments.findMany({
where,
orderBy: { created_at: 'desc' },
skip, take,
include: {
blob: {
select: { mime: true, size: true, storage_path: true, sha256: true },
},
},
}),
this.prisma.attachments.count({ where }),
]);
return { page, page_size: take, total, items };
}
}

View File

@ -0,0 +1,34 @@
import { Type } from "class-transformer";
import { IsInt, IsISO8601, IsOptional, IsString, Max, Min } from "class-validator";
export class AdminSearchDto {
@IsOptional()
@IsString()
owner_type?: string;
@IsOptional()
@IsString()
owner_id?: string;
@IsOptional()
@IsISO8601()
date_from?: string;
@IsOptional()
@IsISO8601()
date_to?: string;
@IsOptional()
@Type(()=> Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(()=> Number)
@IsInt()
@Min(1)
@Max(200)
page_size?: number = 50;
}

View File

@ -0,0 +1,60 @@
import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ArchivalAttachmentService {
private readonly logger = new Logger(ArchivalAttachmentService.name)
private readonly batch_size = Number(process.env.ARCHIVE_BATCH_SIZE || 1000);
private readonly cron_expression = process.env.ARCHIVE_CRON || '0 3 * * 1';
constructor( private readonly prisma: PrismaService) {}
private startOfYear(): Date {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), 0, 1, 0, 0, 0, 0));
}
@Cron(function (this: ArchivalAttachmentService) { return this.cron_expression; } as any)
async runScheduled() {
await this.archiveCutoffToStartOfYear();
}
//archive everything before current year
async archiveCutoffToStartOfYear() {
const cutoff = this.startOfYear();
this.logger.log(`Archival: cutoff=${cutoff.toISOString()} batch=${this.batch_size}`);
let moved = 0, total = 0, i = 0;
do {
moved = await this.archiveBatch(cutoff, this.batch_size);
total += moved;
i++;
if(moved > 0) this.logger.log(`Batch #${i}: moved ${moved}`);
}while (moved === this.batch_size);
this.logger.log(`Archival done: total moved : ${total}`);
return { moved: total };
}
//only moves table content to archive and not blobs.
private async archiveBatch(cutoff: Date, batch_size: number): Promise<number> {
const moved = await this.prisma.$executeRaw`
WITH moved AS (
DELETE FROM "attachments"
WHERE id IN (
SELECT id FROM "attachments"
WHERE created_at < ${cutoff}
ORDER BY id
LIMIT ${batch_size}
)
RETURNING id, sha256, owner_type, owner_id, original_name, status, retention_policy, created_by, created_at
)
INSERT INTO archive.attachments_archive
(id, sha256, owner_type, owner_id, original_name, status, retention_policy, created_by, created_at)
SELECT * FROM moved;
`;
return Number(moved) || 0;
}
}

View File

@ -12,7 +12,7 @@ export class DiskStorageService {
private casPath(hash: string) {
const a = hash.slice(0,2), b = hash.slice(2,4);
return `sha256/${a}/${b}/${hash}`; //relatif pour stockage dans la DB
return `sha256/${a}/${b}/${hash}`;
}
//chemin absolue du storage

View File

@ -0,0 +1,78 @@
import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { PrismaService } from 'src/prisma/prisma.service';
import * as path from 'node:path';
import { promises as fsp } from 'node:fs';
import { resolveAttachmentsRoot } from "src/config/attachment.config";
@Injectable()
export class GarbargeCollectorService {
private readonly logger = new Logger(GarbargeCollectorService.name);
//.env refs
private readonly batch_size = Number(process.env.GC_BATCH_SIZE || 500);
private readonly cron_expression = process.env.GC_CRON || '15 4 * * *'; // everyday at 04:15 AM
//fetchs root of storage
private readonly root = resolveAttachmentsRoot();
constructor(private readonly prisma: PrismaService) {}
//planif for the Cronjob
@Cron(function(this:GarbargeCollectorService) { return this.cron_expression; } as any)
async runScheduled() {
await this.collect();
}
//Manage Garbage collecting by batch of elements until a batch != full
async collect() {
let total = 0, round = 0;
//infinit loop (;;) with break
for(;;) {
round++;
const num = await this.collectBatch();
total += num;
this.logger.log(`Garbage Collector round #${round} removed ${num}`);
if(num < this.batch_size) break; //breaks if not a full batch
}
this.logger.log(`Garbage Collecting done: total removed ${total}`);
return { removed:total };
}
//Manage a single lot of orphan blobs
private async collectBatch(): Promise<number> {
const blobs = await this.prisma.blobs.findMany({
where: { refcount: { lte: 0 } },
select: { sha256: true, storage_path: true },
take: this.batch_size,
});
if(blobs.length === 0) return 0;
// delete original file and all its variants <hash> in the same file
await Promise.all(
blobs.map(async (blob)=> {
const absolute_path = path.join(this.root, blob.storage_path);
await this.deleteFileIfExists(absolute_path); //tries to delete original file if found
const dir = path.dirname(absolute_path);
const base = path.basename(absolute_path);
try {
const entries = await fsp.readdir(dir, { withFileTypes: true});
const targets = entries.filter(entry => entry.isFile() && entry.name.startsWith(base + '.'))
.map(entry => path.join(dir, entry.name));
//deletes all variants
await Promise.all(targets.map(target => this.deleteFileIfExists(target)));
} catch {}
})
);
//deletes blobs lignes if file is deleted
const hashes = blobs.map(blob => blob.sha256);
await this.prisma.blobs.deleteMany({where: { sha256: { in: hashes } } });
return blobs.length;
}
//helper: deletes path if exists and ignore errors
private async deleteFileIfExists(path: string) {
try { await fsp.unlink(path); } catch {}
}
}

View File

@ -0,0 +1,20 @@
import { Queue } from "bullmq";
export class VariantsQueue {
private queue : Queue;
constructor() {
const name = `${process.env.BULL_PREFIX || 'attachments'}:variants`;
this.queue = new Queue(name, { connection: { url: process.env.REDIS_URL! } });
}
enqueue(attachment_id: number, mime: string) {
if(!mime.startsWith('image/')) {
return Promise.resolve();
}
return this.queue.add('generate',
{ attachment_id, mime },
{ attempts: 3, backoff: { type: 'exponential', delay:2000 } }
);
}
}

View File

@ -0,0 +1,54 @@
import 'dotenv/config';
import { Worker } from 'bullmq';
import sharp from 'sharp';
import { PrismaClient } from '@prisma/client';
import * as path from 'node:path';
import { promises as fsp } from 'node:fs';
import { resolveAttachmentsRoot } from 'src/config/attachment.config';
const prisma = new PrismaClient();
const q_name = `${process.env.BULL_PREFIX || 'attachments'}:variants`;
const root = resolveAttachmentsRoot();
const variants = [
{ name: 'thumb.jpeg', build: (s:sharp.Sharp) => s.rotate().jpeg({quality:80}).resize({width:128}) },
{ name: '256w.webp' , build: (s:sharp.Sharp) => s.rotate().webp({quality:80}).resize({width:256}) },
{ name: '1024w.webp', build: (s:sharp.Sharp) => s.rotate().webp({quality:82}).resize({width:1024}) },
]
new Worker(q_name, async job => {
const attachment_id: number = job.data.attachmentId ?? job.data.attachment_id;
if (!attachment_id) return;
const attachment = await prisma.attachments.findUnique({
where: { id: attachment_id },
include: { blob: true },
});
if(!attachment) return;
const source_abs = path.join(root, attachment.blob.storage_path);
for(const variant of variants) {
const relative = `${attachment.blob.storage_path}.${variant.name}`;
const out_Abs = path.join(root, relative);
//try for idem paths
try{ await fsp.stat(out_Abs); continue; } catch{}
await fsp.mkdir(path.dirname(out_Abs), { recursive: true });
//generate variant
await variant.build(sharp(source_abs)).toFile(out_Abs);
//meta data of generated variant file
const meta = await sharp(out_Abs).metadata();
const bytes = (await fsp.stat(out_Abs)).size;
await prisma.attachmentVariants.upsert({
where: { attachment_id_variant: { attachment_id: attachment_id, variant: variant.name } },
update: { path: relative, bytes, width: meta.width ?? null, height: meta.height ?? null },
create: { path: relative, bytes, width: meta.width ?? null, height: meta.height ?? null, attachment_id: attachment_id, variant: variant.name },
} as any );
}
}, {
connection: { url: process.env.REDIS_URL }, concurrency: 3 }
);

View File

@ -1,7 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils';
import { Prisma } from '@prisma/client';
import { Prisma, PrismaClient } from '@prisma/client';
type Tx = Prisma.TransactionClient | PrismaClient;
export type WeekOvertimeSummary = {
week_start:string;
week_end: string;
week_total_hours: number;
weekly_overtime: number;
daily_overtime_kept: number;
total_overtime: number;
breakdown: Array<{
date:string;
day_hours: number;
day_overtime: number;
daily_kept: number;
running_total_before: number;
}>;
};
@Injectable()
export class OvertimeService {
@ -13,140 +31,217 @@ export class OvertimeService {
constructor(private prisma: PrismaService) {}
//calculate daily overtime
async getDailyOvertimeHours(employee_id: number, date: Date): Promise<number> {
const shifts = await this.prisma.shifts.findMany({
where: { date: date, timesheet: { employee_id: employee_id } },
select: { start_time: true, end_time: true },
});
const total = shifts.map((shift)=>
computeHours(shift.start_time, shift.end_time, 5)).reduce((sum, hours)=> sum + hours, 0);
const overtime = Math.max(0, total - this.daily_max);
async getWeekOvertimeSummary( timesheet_id: number, date: Date, tx?: Tx ): Promise<WeekOvertimeSummary>{
const db = tx ?? this.prisma;
this.logger.debug(`[OVERTIME]-[DAILY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
return overtime;
}
//calculate Weekly overtime
async getWeeklyOvertimeHours(employee_id: number, ref_date: Date): Promise<number> {
const week_start = getWeekStart(ref_date);
const week_start = getWeekStart(date);
const week_end = getWeekEnd(week_start);
//fetches all shifts from INCLUDED_TYPES array
const included_shifts = await this.prisma.shifts.findMany({
const shifts = await db.shifts.findMany({
where: {
timesheet_id,
date: { gte: week_start, lte: week_end },
timesheet: { employee_id },
bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } },
},
select: { start_time: true, end_time: true },
select: { date: true, start_time: true, end_time: true },
orderBy: [{date: 'asc'}, {start_time: 'asc'}],
});
//calculate total hours of those shifts minus weekly Max to find total overtime hours
const total = included_shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5))
.reduce((sum, hours)=> sum+hours, 0);
const overtime = Math.max(0, total - this.weekly_max);
this.logger.debug(`[OVERTIME]-[WEEKLY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
return overtime;
const day_totals = new Map<string, number>();
for (const shift of shifts){
const key = shift.date.toISOString().slice(0,10);
const hours = computeHours(shift.start_time, shift.end_time, 5);
day_totals.set(key, (day_totals.get(key) ?? 0) + hours);
}
//transform REGULAR shifts to OVERTIME when exceed 40hrs of included_types of shift
async transformRegularHoursToWeeklyOvertime(
employee_id: number,
ref_date: Date,
tx?: Prisma.TransactionClient,
): Promise<void> {
//ensures the use of the transaction if needed. fallback to this.prisma if no transaction is detected.
const db = tx ?? this.prisma;
//calculate weekly overtime
const overtime_hours = await this.getWeeklyOvertimeHours(employee_id, ref_date);
if(overtime_hours <= 0) return;
const convert_to_minutes = Math.round(overtime_hours * 60);
const [regular, overtime] = await Promise.all([
db.bankCodes.findFirst({where: { type: 'REGULAR' }, select: { id: true } }),
db.bankCodes.findFirst({where: { type: 'OVERTIME'}, select: { id: true } }),
]);
if(!regular || !overtime) return;
const week_start = getWeekStart(ref_date);
const week_end = getWeekEnd(week_start);
//gets all regular shifts and order them by desc
const regular_shifts_desc = await db.shifts.findMany({
where: {
date: { gte:week_start, lte: week_end },
timesheet: { employee_id },
bank_code_id: regular.id,
},
select: {
id: true,
timesheet_id: true,
date: true,
start_time: true,
end_time: true,
is_remote: true,
comment: true,
},
orderBy: [{date: 'desc'}, {start_time:'desc'}],
});
let remaining_minutes = convert_to_minutes;
for(const shift of regular_shifts_desc) {
if(remaining_minutes <= 0) break;
const start = shift.start_time;
const end = shift.end_time;
const duration_in_minutes = Math.max(0, Math.round((end.getTime() - start.getTime())/60000));
if(duration_in_minutes === 0) continue;
if(duration_in_minutes <= remaining_minutes) {
await db.shifts.update({
where: { id: shift.id },
data: { bank_code_id: overtime.id },
});
remaining_minutes -= duration_in_minutes;
continue;
}
//sets the start_time of the new overtime shift
const new_overtime_start = new Date(end.getTime() - remaining_minutes * 60000);
//shorten the regular shift
await db.shifts.update({
where: { id: shift.id },
data: { end_time: new_overtime_start },
});
//creates the new overtime shift to replace the shorten regular shift
await db.shifts.create({
data: {
timesheet_id: shift.timesheet_id,
date: shift.date,
start_time: new_overtime_start,
end_time: end,
is_remote: shift.is_remote,
comment: shift.comment,
bank_code_id: overtime.id,
},
});
remaining_minutes = 0;
}
this.logger.debug(`[OVERTIME]-[WEEKLY]-[TRANSFORM] emp=${employee_id}
week: ${week_start.toISOString().slice(0,10)}..${week_end.toISOString().slice(0,10)}
converted= ${(convert_to_minutes-remaining_minutes)/60}h`);
const days: string[] = [];
for(let i = 0; i < 7; i++){
const day = new Date(week_start.getTime() + i * 24 * 60 * 60 * 1000);
days.push(day.toISOString().slice(0,10));
}
//apply modifier to overtime hours
// calculateOvertimePay(overtime_hours: number, modifier: number): number {
// const pay = overtime_hours * modifier;
// this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`);
const week_total_hours = [ ...day_totals.values()].reduce((a,b) => a + b, 0);
const weekly_overtime = Math.max(0, week_total_hours - this.weekly_max);
// return pay;
let running = 0;
let daily_kept_sum = 0;
const breakdown: WeekOvertimeSummary['breakdown'] = [];
for (const key of days) {
const day_hours = day_totals.get(key) ?? 0;
const day_overtime = Math.max(0, day_hours - this.daily_max);
const cap_before_40 = Math.max(0, this.weekly_max - running);
const daily_kept = Math.min(day_overtime, cap_before_40);
breakdown.push({
date: key,
day_hours,
day_overtime,
daily_kept,
running_total_before: running,
});
daily_kept_sum += daily_kept;
running += day_hours;
}
const total_overtime = weekly_overtime + daily_kept_sum;
this.logger.debug(
`[OVERTIME][SUMMARY][ts=${timesheet_id}] week=${week_start.toISOString().slice(0,10)}..${week_end
.toISOString()
.slice(0,10)} week_total=${week_total_hours.toFixed(2)}h weekly=${weekly_overtime.toFixed(
2,
)}h daily_kept=${daily_kept_sum.toFixed(2)}h total=${total_overtime.toFixed(2)}h`,
);
return {
week_start: week_start.toISOString().slice(0, 10),
week_end: week_end.toISOString().slice(0, 10),
week_total_hours,
weekly_overtime,
daily_overtime_kept: daily_kept_sum,
total_overtime,
breakdown,
};
}
// //calculate daily overtime
// async getDailyOvertimeHours(timesheet_id: number, date: Date): Promise<number> {
// const shifts = await this.prisma.shifts.findMany({
// where: {
// timesheet_id,
// date: date,
// bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } },
// },
// select: { start_time: true, end_time: true },
// orderBy: [{ start_time: 'asc' }],
// });
// const total = shifts.map((shift)=>
// computeHours(shift.start_time, shift.end_time, 5)).
// reduce((sum, hours)=> sum + hours, 0);
// const overtime = Math.max(0, total - this.daily_max);
// this.logger.debug(`[OVERTIME]-[DAILY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
// return overtime;
// }
// //calculate Weekly overtime
// async getWeeklyOvertimeHours(timesheet_id: number, ref_date: Date): Promise<number> {
// const week_start = getWeekStart(ref_date);
// const week_end = getWeekEnd(week_start);
// //fetches all shifts from INCLUDED_TYPES array
// const included_shifts = await this.prisma.shifts.findMany({
// where: {
// timesheet_id,
// date: { gte:week_start, lte: week_end },
// bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } },
// },
// select: { start_time: true, end_time: true },
// orderBy: [{date: 'asc'}, {start_time:'asc'}],
// });
// //calculate total hours of those shifts minus weekly Max to find total overtime hours
// const total = included_shifts.map(shift =>
// computeHours(shift.start_time, shift.end_time, 5)).
// reduce((sum, hours)=> sum+hours, 0);
// const overtime = Math.max(0, total - this.weekly_max);
// this.logger.debug(`[OVERTIME]-[WEEKLY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
// return overtime;
// }
// //transform REGULAR shifts to OVERTIME when exceed 40hrs of included_types of shift
// async transformRegularHoursToWeeklyOvertime(
// employee_id: number,
// ref_date: Date,
// tx?: Prisma.TransactionClient,
// ): Promise<void> {
// //ensures the use of the transaction if needed. fallback to this.prisma if no transaction is detected.
// const db = tx ?? this.prisma;
// //calculate weekly overtime
// const overtime_hours = await this.getWeeklyOvertimeHours(employee_id, ref_date);
// if(overtime_hours <= 0) return;
// const convert_to_minutes = Math.round(overtime_hours * 60);
// const [regular, overtime] = await Promise.all([
// db.bankCodes.findFirst({where: { type: 'REGULAR' }, select: { id: true } }),
// db.bankCodes.findFirst({where: { type: 'OVERTIME'}, select: { id: true } }),
// ]);
// if(!regular || !overtime) return;
// const week_start = getWeekStart(ref_date);
// const week_end = getWeekEnd(week_start);
// //gets all regular shifts and order them by desc
// const regular_shifts_desc = await db.shifts.findMany({
// where: {
// date: { gte:week_start, lte: week_end },
// timesheet: { employee_id },
// bank_code_id: regular.id,
// },
// select: {
// id: true,
// timesheet_id: true,
// date: true,
// start_time: true,
// end_time: true,
// is_remote: true,
// comment: true,
// },
// orderBy: [{date: 'desc'}, {start_time:'desc'}],
// });
// let remaining_minutes = convert_to_minutes;
// for(const shift of regular_shifts_desc) {
// if(remaining_minutes <= 0) break;
// const start = shift.start_time;
// const end = shift.end_time;
// const duration_in_minutes = Math.max(0, Math.round((end.getTime() - start.getTime())/60000));
// if(duration_in_minutes === 0) continue;
// if(duration_in_minutes <= remaining_minutes) {
// await db.shifts.update({
// where: { id: shift.id },
// data: { bank_code_id: overtime.id },
// });
// remaining_minutes -= duration_in_minutes;
// continue;
// }
// //sets the start_time of the new overtime shift
// const new_overtime_start = new Date(end.getTime() - remaining_minutes * 60000);
// //shorten the regular shift
// await db.shifts.update({
// where: { id: shift.id },
// data: { end_time: new_overtime_start },
// });
// //creates the new overtime shift to replace the shorten regular shift
// await db.shifts.create({
// data: {
// timesheet_id: shift.timesheet_id,
// date: shift.date,
// start_time: new_overtime_start,
// end_time: end,
// is_remote: shift.is_remote,
// comment: shift.comment,
// bank_code_id: overtime.id,
// },
// });
// remaining_minutes = 0;
// }
// this.logger.debug(`[OVERTIME]-[WEEKLY]-[TRANSFORM] emp=${employee_id}
// week: ${week_start.toISOString().slice(0,10)}..${week_end.toISOString().slice(0,10)}
// converted= ${(convert_to_minutes-remaining_minutes)/60}h`);
// }
}

View File

@ -1,95 +1,95 @@
import { Body, Controller, Get, Param, Put, } from "@nestjs/common";
import { Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { ExpensesCommandService } from "../services/expenses-command.service";
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces";
import { DayExpensesDto } from "src/modules/timesheets/dtos/timesheet-period.dto";
import { ExpensesQueryService } from "../services/expenses-query.service";
// import { Body, Controller, Get, Param, Put, } from "@nestjs/common";
// import { Roles as RoleEnum } from '.prisma/client';
// import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
// import { RolesAllowed } from "src/common/decorators/roles.decorators";
// import { ExpensesCommandService } from "../services/expenses-command.service";
// import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
// import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces";
// import { DayExpensesDto } from "src/modules/timesheets/~misc_deprecated-files/timesheet-period.dto";
// import { ExpensesQueryService } from "../services/expenses-query.service";
@ApiTags('Expenses')
@ApiBearerAuth('access-token')
// @UseGuards()
@Controller('Expenses')
export class ExpensesController {
constructor(
private readonly query: ExpensesQueryService,
private readonly command: ExpensesCommandService,
) {}
// @ApiTags('Expenses')
// @ApiBearerAuth('access-token')
// // @UseGuards()
// @Controller('Expenses')
// export class ExpensesController {
// constructor(
// private readonly query: ExpensesQueryService,
// private readonly command: ExpensesCommandService,
// ) {}
@Put('upsert/:email/:date')
async upsert_by_date(
@Param('email') email: string,
@Param('date') date: string,
@Body() dto: UpsertExpenseDto,
): Promise<UpsertExpenseResult> {
return this.command.upsertExpensesByDate(email, date, dto);
}
@Get('list/:email/:year/:period_no')
async findExpenseListByPayPeriodAndEmail(
@Param('email') email:string,
@Param('year') year: number,
@Param('period_no') period_no: number,
): Promise<DayExpensesDto> {
return this.query.findExpenseListByPayPeriodAndEmail(email, year, period_no);
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
// @Post()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Create expense' })
// @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
// create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
// return this.query.create(dto);
// @Put('upsert/:email/:date')
// async upsert_by_date(
// @Param('email') email: string,
// @Param('date') date: string,
// @Body() dto: UpsertExpenseDto,
// ): Promise<UpsertExpenseResult> {
// return this.command.upsertExpensesByDate(email, date, dto);
// }
// @Get()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find all expenses' })
// @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
// @ApiResponse({ status: 400, description: 'List of expenses not found' })
// @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
// findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
// return this.query.findAll(filters);
// @Get('list/:email/:year/:period_no')
// async findExpenseListByPayPeriodAndEmail(
// @Param('email') email:string,
// @Param('year') year: number,
// @Param('period_no') period_no: number,
// ): Promise<DayExpensesDto> {
// return this.query.findExpenseListByPayPeriodAndEmail(email, year, period_no);
// }
// @Get(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find expense' })
// @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Expense not found' })
// findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
// return this.query.findOne(id);
// }
// //_____________________________________________________________________________________________
// // Deprecated or unused methods
// //_____________________________________________________________________________________________
// @Patch(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Expense shift' })
// @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Expense not found' })
// update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
// return this.query.update(id,dto);
// }
// // @Post()
// // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// // @ApiOperation({ summary: 'Create expense' })
// // @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
// // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
// // create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
// // return this.query.create(dto);
// // }
// @Delete(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Delete expense' })
// @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Expense not found' })
// remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
// return this.query.remove(id);
// }
// // @Get()
// // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// // @ApiOperation({ summary: 'Find all expenses' })
// // @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
// // @ApiResponse({ status: 400, description: 'List of expenses not found' })
// // @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
// // findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
// // return this.query.findAll(filters);
// // }
// @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.command.updateApproval(id, isApproved);
// }
// // @Get(':id')
// // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// // @ApiOperation({ summary: 'Find expense' })
// // @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
// // @ApiResponse({ status: 400, description: 'Expense not found' })
// // findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
// // return this.query.findOne(id);
// // }
}
// // @Patch(':id')
// // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// // @ApiOperation({ summary: 'Expense shift' })
// // @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
// // @ApiResponse({ status: 400, description: 'Expense not found' })
// // update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
// // return this.query.update(id,dto);
// // }
// // @Delete(':id')
// // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// // @ApiOperation({ summary: 'Delete expense' })
// // @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
// // @ApiResponse({ status: 400, description: 'Expense not found' })
// // remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
// // return this.query.remove(id);
// // }
// // @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.command.updateApproval(id, isApproved);
// // }
// }

View File

@ -1,23 +1,23 @@
import { ExpensesController } from "./controllers/expenses.controller";
import { Module } from "@nestjs/common";
import { ExpensesQueryService } from "./services/expenses-query.service";
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
import { ExpensesCommandService } from "./services/expenses-command.service";
import { ExpensesArchivalService } from "./services/expenses-archival.service";
import { SharedModule } from "../shared/shared.module";
// import { ExpensesController } from "./controllers/expenses.controller";
// import { Module } from "@nestjs/common";
// import { ExpensesQueryService } from "./services/expenses-query.service";
// import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
// import { ExpensesCommandService } from "./services/expenses-command.service";
// import { ExpensesArchivalService } from "./services/expenses-archival.service";
// import { SharedModule } from "../shared/shared.module";
@Module({
imports: [BusinessLogicsModule, SharedModule],
controllers: [ExpensesController],
providers: [
ExpensesQueryService,
ExpensesArchivalService,
ExpensesCommandService,
],
exports: [
ExpensesQueryService,
ExpensesArchivalService,
],
})
// @Module({
// imports: [BusinessLogicsModule, SharedModule],
// controllers: [ExpensesController],
// providers: [
// ExpensesQueryService,
// ExpensesArchivalService,
// ExpensesCommandService,
// ],
// exports: [
// ExpensesQueryService,
// ExpensesArchivalService,
// ],
// })
export class ExpensesModule {}
// export class ExpensesModule {}

View File

@ -1,250 +1,249 @@
import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { Expenses, Prisma } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils";
import {
BadRequestException,
Injectable,
NotFoundException
} from "@nestjs/common";
import {
assertAndTrimComment,
computeAmountDecimal,
computeMileageAmount,
mapDbExpenseToDayResponse,
normalizeType,
parseAttachmentId
} from "../utils/expenses.utils";
import { toDateOnly } from "src/modules/shifts/helpers/shifts-date-time-helpers";
// import { BaseApprovalService } from "src/common/shared/base-approval.service";
// import { Expenses, Prisma } from "@prisma/client";
// import { PrismaService } from "src/prisma/prisma.service";
// import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
// import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
// import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils";
// import {
// BadRequestException,
// Injectable,
// NotFoundException
// } from "@nestjs/common";
// import {
// assertAndTrimComment,
// computeAmountDecimal,
// computeMileageAmount,
// mapDbExpenseToDayResponse,
// normalizeType,
// parseAttachmentId
// } from "../utils/expenses.utils";
@Injectable()
export class ExpensesCommandService extends BaseApprovalService<Expenses> {
constructor(
prisma: PrismaService,
private readonly bankCodesResolver: BankCodesResolver,
private readonly timesheetsResolver: EmployeeTimesheetResolver,
private readonly emailResolver: EmailToIdResolver,
) { super(prisma); }
// @Injectable()
// export class ExpensesCommandService extends BaseApprovalService<Expenses> {
// constructor(
// prisma: PrismaService,
// private readonly bankCodesResolver: BankCodesResolver,
// private readonly timesheetsResolver: EmployeeTimesheetResolver,
// private readonly emailResolver: EmailToIdResolver,
// ) { super(prisma); }
//_____________________________________________________________________________________________
// APPROVAL TX-DELEGATE METHODS
//_____________________________________________________________________________________________
// //_____________________________________________________________________________________________
// // APPROVAL TX-DELEGATE METHODS
// //_____________________________________________________________________________________________
protected get delegate() {
return this.prisma.expenses;
}
// protected get delegate() {
// return this.prisma.expenses;
// }
protected delegateFor(transaction: Prisma.TransactionClient){
return transaction.expenses;
}
// protected delegateFor(transaction: Prisma.TransactionClient){
// return transaction.expenses;
// }
async updateApproval(id: number, isApproved: boolean): Promise<Expenses> {
return this.prisma.$transaction((transaction) =>
this.updateApprovalWithTransaction(transaction, id, isApproved),
);
}
// async updateApproval(id: number, isApproved: boolean): Promise<Expenses> {
// return this.prisma.$transaction((transaction) =>
// this.updateApprovalWithTransaction(transaction, id, isApproved),
// );
// }
//_____________________________________________________________________________________________
// MASTER CRUD FUNCTION
//_____________________________________________________________________________________________
readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
// //_____________________________________________________________________________________________
// // MASTER CRUD FUNCTION
// //_____________________________________________________________________________________________
// readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
// ): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
//validates if there is an existing expense, at least 1 old or new
const { old_expense, new_expense } = dto ?? {};
if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided');
// //validates if there is an existing expense, at least 1 old or new
// const { old_expense, new_expense } = dto ?? {};
// if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided');
//validate date format
const date_only = toDateOnly(date);
if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
// //validate date format
// const date_only = toDateOnly(date);
// if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
//resolve employee_id by email
const employee_id = await this.emailResolver.findIdByEmail(email);
// //resolve employee_id by email
// const employee_id = await this.emailResolver.findIdByEmail(email);
//make sure a timesheet existes
const timesheet_id = await this.timesheetsResolver.findTimesheetIdByEmail(email, date_only);
if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`)
const {id} = timesheet_id;
// //make sure a timesheet existes
// const timesheet_id = await this.timesheetsResolver.findTimesheetIdByEmail(email, date_only);
// if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`)
// const {id} = timesheet_id;
return this.prisma.$transaction(async (tx) => {
const loadDay = async (): Promise<ExpenseResponse[]> => {
const rows = await tx.expenses.findMany({
where: {
timesheet_id: id,
date: date_only,
},
include: {
bank_code: {
select: {
type: true,
},
},
},
orderBy: [{ date: 'asc' }, { id: 'asc' }],
});
// return this.prisma.$transaction(async (tx) => {
// const loadDay = async (): Promise<ExpenseResponse[]> => {
// const rows = await tx.expenses.findMany({
// where: {
// timesheet_id: id,
// date: date_only,
// },
// include: {
// bank_code: {
// select: {
// type: true,
// },
// },
// },
// orderBy: [{ date: 'asc' }, { id: 'asc' }],
// });
return rows.map((r) =>
mapDbExpenseToDayResponse({
date: r.date,
amount: r.amount ?? 0,
mileage: r.mileage ?? 0,
comment: r.comment,
is_approved: r.is_approved,
bank_code: r.bank_code,
}));
};
// return rows.map((r) =>
// mapDbExpenseToDayResponse({
// date: r.date,
// amount: r.amount ?? 0,
// mileage: r.mileage ?? 0,
// comment: r.comment,
// is_approved: r.is_approved,
// bank_code: r.bank_code,
// }));
// };
const normalizePayload = async (payload: {
type: string;
amount?: number;
mileage?: number;
comment: string;
attachment?: string | number;
}): Promise<{
type: string;
bank_code_id: number;
amount: Prisma.Decimal;
mileage: number | null;
comment: string;
attachment: number | null;
}> => {
const type = normalizeType(payload.type);
const comment = assertAndTrimComment(payload.comment);
const attachment = parseAttachmentId(payload.attachment);
// const normalizePayload = async (payload: {
// type: string;
// amount?: number;
// mileage?: number;
// comment: string;
// attachment?: string | number;
// }): Promise<{
// type: string;
// bank_code_id: number;
// amount: Prisma.Decimal;
// mileage: number | null;
// comment: string;
// attachment: number | null;
// }> => {
// const type = normalizeType(payload.type);
// const comment = assertAndTrimComment(payload.comment);
// const attachment = parseAttachmentId(payload.attachment);
const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type);
let amount = computeAmountDecimal(type, payload, modifier);
let mileage: number | null = null;
// const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type);
// let amount = computeAmountDecimal(type, payload, modifier);
// let mileage: number | null = null;
if (type === 'MILEAGE') {
mileage = Number(payload.mileage ?? 0);
if (!(mileage > 0)) {
throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE');
}
// if (type === 'MILEAGE') {
// mileage = Number(payload.mileage ?? 0);
// if (!(mileage > 0)) {
// throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE');
// }
const amountNumber = computeMileageAmount(mileage, modifier);
amount = new Prisma.Decimal(amountNumber);
// const amountNumber = computeMileageAmount(mileage, modifier);
// amount = new Prisma.Decimal(amountNumber);
} else {
if (!(typeof payload.amount === 'number' && payload.amount >= 0)) {
throw new BadRequestException('Amount required for non-MILEAGE expense');
}
amount = new Prisma.Decimal(payload.amount);
}
// } else {
// if (!(typeof payload.amount === 'number' && payload.amount >= 0)) {
// throw new BadRequestException('Amount required for non-MILEAGE expense');
// }
// amount = new Prisma.Decimal(payload.amount);
// }
if (attachment !== null) {
const attachment_row = await tx.attachments.findUnique({
where: { id: attachment },
select: { status: true },
});
if (!attachment_row || attachment_row.status !== 'ACTIVE') {
throw new BadRequestException('Attachment not found or inactive');
}
}
// if (attachment !== null) {
// const attachment_row = await tx.attachments.findUnique({
// where: { id: attachment },
// select: { status: true },
// });
// if (!attachment_row || attachment_row.status !== 'ACTIVE') {
// throw new BadRequestException('Attachment not found or inactive');
// }
// }
return {
type,
bank_code_id,
amount,
mileage,
comment,
attachment
};
};
// return {
// type,
// bank_code_id,
// amount,
// mileage,
// comment,
// attachment
// };
// };
const findExactOld = async (norm: {
bank_code_id: number;
amount: Prisma.Decimal;
mileage: number | null;
comment: string;
attachment: number | null;
}) => {
return tx.expenses.findFirst({
where: {
timesheet_id: id,
date: date_only,
bank_code_id: norm.bank_code_id,
amount: norm.amount,
comment: norm.comment,
attachment: norm.attachment,
...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }),
},
select: { id: true },
});
};
// const findExactOld = async (norm: {
// bank_code_id: number;
// amount: Prisma.Decimal;
// mileage: number | null;
// comment: string;
// attachment: number | null;
// }) => {
// return tx.expenses.findFirst({
// where: {
// timesheet_id: id,
// date: date_only,
// bank_code_id: norm.bank_code_id,
// amount: norm.amount,
// comment: norm.comment,
// attachment: norm.attachment,
// ...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }),
// },
// select: { id: true },
// });
// };
let action : UpsertAction;
//_____________________________________________________________________________________________
// DELETE
//_____________________________________________________________________________________________
if(old_expense && !new_expense) {
const old_norm = await normalizePayload(old_expense);
const existing = await findExactOld(old_norm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else',
});
}
await tx.expenses.delete({where: { id: existing.id } });
action = 'delete';
}
//_____________________________________________________________________________________________
// CREATE
//_____________________________________________________________________________________________
else if (!old_expense && new_expense) {
const new_exp = await normalizePayload(new_expense);
await tx.expenses.create({
data: {
timesheet_id: id,
date: date_only,
bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount,
mileage: new_exp.mileage,
comment: new_exp.comment,
attachment: new_exp.attachment,
is_approved: false,
},
});
action = 'create';
}
//_____________________________________________________________________________________________
// UPDATE
//_____________________________________________________________________________________________
else if(old_expense && new_expense) {
const old_norm = await normalizePayload(old_expense);
const existing = await findExactOld(old_norm);
if(!existing) {
throw new NotFoundException({
error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else',
});
}
// let action : UpsertAction;
// //_____________________________________________________________________________________________
// // DELETE
// //_____________________________________________________________________________________________
// if(old_expense && !new_expense) {
// const old_norm = await normalizePayload(old_expense);
// const existing = await findExactOld(old_norm);
// if(!existing) {
// throw new NotFoundException({
// error_code: 'EXPENSE_STALE',
// message: 'The expense was modified or deleted by someone else',
// });
// }
// await tx.expenses.delete({where: { id: existing.id } });
// action = 'delete';
// }
// //_____________________________________________________________________________________________
// // CREATE
// //_____________________________________________________________________________________________
// else if (!old_expense && new_expense) {
// const new_exp = await normalizePayload(new_expense);
// await tx.expenses.create({
// data: {
// timesheet_id: id,
// date: date_only,
// bank_code_id: new_exp.bank_code_id,
// amount: new_exp.amount,
// mileage: new_exp.mileage,
// comment: new_exp.comment,
// attachment: new_exp.attachment,
// is_approved: false,
// },
// });
// action = 'create';
// }
// //_____________________________________________________________________________________________
// // UPDATE
// //_____________________________________________________________________________________________
// else if(old_expense && new_expense) {
// const old_norm = await normalizePayload(old_expense);
// const existing = await findExactOld(old_norm);
// if(!existing) {
// throw new NotFoundException({
// error_code: 'EXPENSE_STALE',
// message: 'The expense was modified or deleted by someone else',
// });
// }
const new_exp = await normalizePayload(new_expense);
await tx.expenses.update({
where: { id: existing.id },
data: {
bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount,
mileage: new_exp.mileage,
comment: new_exp.comment,
attachment: new_exp.attachment,
},
});
action = 'update';
}
else {
throw new BadRequestException('Invalid upsert combination');
}
// const new_exp = await normalizePayload(new_expense);
// await tx.expenses.update({
// where: { id: existing.id },
// data: {
// bank_code_id: new_exp.bank_code_id,
// amount: new_exp.amount,
// mileage: new_exp.mileage,
// comment: new_exp.comment,
// attachment: new_exp.attachment,
// },
// });
// action = 'update';
// }
// else {
// throw new BadRequestException('Invalid upsert combination');
// }
const day = await loadDay();
// const day = await loadDay();
return { action, day };
});
}
}
// return { action, day };
// });
// }
// }

View File

@ -1,174 +1,171 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto";
import { round2, toUTCDateOnly } from "src/modules/timesheets/utils-helpers-others/timesheet.helpers";
import { EXPENSE_TYPES } from "src/modules/timesheets/utils-helpers-others/timesheet.types";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
// import { Injectable, NotFoundException } from "@nestjs/common";
// import { PrismaService } from "src/prisma/prisma.service";
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
@Injectable()
export class ExpensesQueryService {
constructor(
private readonly prisma: PrismaService,
private readonly employeeRepo: EmailToIdResolver,
) {}
// @Injectable()
// export class ExpensesQueryService {
// constructor(
// private readonly prisma: PrismaService,
// private readonly employeeRepo: EmailToIdResolver,
// ) {}
//fetchs all expenses for a selected employee using email, pay-period-year and number
async findExpenseListByPayPeriodAndEmail(
email: string,
year: number,
period_no: number
): Promise<ExpenseListResponseDto> {
//fetch employe_id using email
const employee_id = await this.employeeRepo.findIdByEmail(email);
if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
// //fetchs all expenses for a selected employee using email, pay-period-year and number
// async findExpenseListByPayPeriodAndEmail(
// email: string,
// year: number,
// period_no: number
// ): Promise<ExpenseListResponseDto> {
// //fetch employe_id using email
// const employee_id = await this.employeeRepo.findIdByEmail(email);
// if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
//fetch pay-period using year and period_no
const pay_period = await this.prisma.payPeriods.findFirst({
where: {
pay_year: year,
pay_period_no: period_no
},
select: { period_start: true, period_end: true },
});
if(!pay_period) throw new NotFoundException(`Pay period ${year}- ${period_no} not found`);
const start = toUTCDateOnly(pay_period.period_start);
const end = toUTCDateOnly(pay_period.period_end);
//sets rows data
const rows = await this.prisma.expenses.findMany({
where: {
date: { gte: start, lte: end },
timesheet: { is: { employee_id } },
},
orderBy: { date: 'asc'},
select: {
amount: true,
mileage: true,
comment: true,
is_approved: true,
supervisor_comment: true,
bank_code: {select: { type: true } },
},
});
//declare return values
const expenses: ExpenseDto[] = [];
let total_amount = 0;
let total_mileage = 0;
//set rows
for(const row of rows) {
const type = (row.bank_code?.type ?? '').toUpperCase();
const amount = round2(Number(row.amount ?? 0));
const mileage = round2(Number(row.mileage ?? 0));
if(type === EXPENSE_TYPES.MILEAGE) {
total_mileage += mileage;
} else {
total_amount += amount;
}
//fills rows array
expenses.push({
type,
amount,
mileage,
comment: row.comment ?? '',
is_approved: row.is_approved ?? false,
supervisor_comment: row.supervisor_comment ?? '',
});
}
return {
expenses,
total_expense: round2(total_amount),
total_mileage: round2(total_mileage),
};
}
//_____________________________________________________________________________________________
// Deprecated or unused methods
//_____________________________________________________________________________________________
// async create(dto: CreateExpenseDto): Promise<Expenses> {
// const { timesheet_id, bank_code_id, date, amount:rawAmount,
// comment, is_approved,supervisor_comment} = dto;
// //fetches type and modifier
// const bank_code = await this.prisma.bankCodes.findUnique({
// where: { id: bank_code_id },
// select: { type: true, modifier: true },
// //fetch pay-period using year and period_no
// const pay_period = await this.prisma.payPeriods.findFirst({
// where: {
// pay_year: year,
// pay_period_no: period_no
// },
// select: { period_start: true, period_end: true },
// });
// if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`);
// if(!pay_period) throw new NotFoundException(`Pay period ${year}- ${period_no} not found`);
// //if mileage -> service, otherwise the ratio is amount:1
// let final_amount: number;
// if(bank_code.type === 'mileage') {
// final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
// const start = toUTCDateOnly(pay_period.period_start);
// const end = toUTCDateOnly(pay_period.period_end);
// //sets rows data
// const rows = await this.prisma.expenses.findMany({
// where: {
// date: { gte: start, lte: end },
// timesheet: { is: { employee_id } },
// },
// orderBy: { date: 'asc'},
// select: {
// amount: true,
// mileage: true,
// comment: true,
// is_approved: true,
// supervisor_comment: true,
// bank_code: {select: { type: true } },
// },
// });
// //declare return values
// const expenses: ExpenseDto[] = [];
// let total_amount = 0;
// let total_mileage = 0;
// //set rows
// for(const row of rows) {
// const type = (row.bank_code?.type ?? '').toUpperCase();
// const amount = round2(Number(row.amount ?? 0));
// const mileage = round2(Number(row.mileage ?? 0));
// if(type === EXPENSE_TYPES.MILEAGE) {
// total_mileage += mileage;
// } else {
// final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
// total_amount += amount;
// }
// return this.prisma.expenses.create({
// data: {
// timesheet_id,
// bank_code_id,
// date,
// amount: final_amount,
// comment,
// is_approved,
// supervisor_comment
// },
// include: { timesheet: { include: { employee: { include: { user: true }}}},
// bank_code: true,
// },
// })
// }
// async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
// const where = buildPrismaWhere(filters);
// const expenses = await this.prisma.expenses.findMany({ where })
// return expenses;
// }
// async findOne(id: number): Promise<Expenses> {
// const expense = await this.prisma.expenses.findUnique({
// where: { id },
// include: { timesheet: { include: { employee: { include: { user:true } } } },
// bank_code: true,
// },
// });
// if (!expense) {
// throw new NotFoundException(`Expense #${id} not found`);
// }
// return expense;
// }
// async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> {
// await this.findOne(id);
// const { timesheet_id, bank_code_id, date, amount,
// comment, is_approved, supervisor_comment} = dto;
// return this.prisma.expenses.update({
// where: { id },
// data: {
// ...(timesheet_id !== undefined && { timesheet_id}),
// ...(bank_code_id !== undefined && { bank_code_id }),
// ...(date !== undefined && { date }),
// ...(amount !== undefined && { amount }),
// ...(comment !== undefined && { comment }),
// ...(is_approved !== undefined && { is_approved }),
// ...(supervisor_comment !== undefined && { supervisor_comment }),
// },
// include: { timesheet: { include: { employee: { include: { user: true } } } },
// bank_code: true,
// },
// //fills rows array
// expenses.push({
// type,
// amount,
// mileage,
// comment: row.comment ?? '',
// is_approved: row.is_approved ?? false,
// supervisor_comment: row.supervisor_comment ?? '',
// });
// }
// async remove(id: number): Promise<Expenses> {
// await this.findOne(id);
// return this.prisma.expenses.delete({ where: { id } });
// return {
// expenses,
// total_expense: round2(total_amount),
// total_mileage: round2(total_mileage),
// };
// }
}
// //_____________________________________________________________________________________________
// // Deprecated or unused methods
// //_____________________________________________________________________________________________
// // async create(dto: CreateExpenseDto): Promise<Expenses> {
// // const { timesheet_id, bank_code_id, date, amount:rawAmount,
// // comment, is_approved,supervisor_comment} = dto;
// // //fetches type and modifier
// // const bank_code = await this.prisma.bankCodes.findUnique({
// // where: { id: bank_code_id },
// // select: { type: true, modifier: true },
// // });
// // if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`);
// // //if mileage -> service, otherwise the ratio is amount:1
// // let final_amount: number;
// // if(bank_code.type === 'mileage') {
// // final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
// // }else {
// // final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
// // }
// // return this.prisma.expenses.create({
// // data: {
// // timesheet_id,
// // bank_code_id,
// // date,
// // amount: final_amount,
// // comment,
// // is_approved,
// // supervisor_comment
// // },
// // include: { timesheet: { include: { employee: { include: { user: true }}}},
// // bank_code: true,
// // },
// // })
// // }
// // async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
// // const where = buildPrismaWhere(filters);
// // const expenses = await this.prisma.expenses.findMany({ where })
// // return expenses;
// // }
// // async findOne(id: number): Promise<Expenses> {
// // const expense = await this.prisma.expenses.findUnique({
// // where: { id },
// // include: { timesheet: { include: { employee: { include: { user:true } } } },
// // bank_code: true,
// // },
// // });
// // if (!expense) {
// // throw new NotFoundException(`Expense #${id} not found`);
// // }
// // return expense;
// // }
// // async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> {
// // await this.findOne(id);
// // const { timesheet_id, bank_code_id, date, amount,
// // comment, is_approved, supervisor_comment} = dto;
// // return this.prisma.expenses.update({
// // where: { id },
// // data: {
// // ...(timesheet_id !== undefined && { timesheet_id}),
// // ...(bank_code_id !== undefined && { bank_code_id }),
// // ...(date !== undefined && { date }),
// // ...(amount !== undefined && { amount }),
// // ...(comment !== undefined && { comment }),
// // ...(is_approved !== undefined && { is_approved }),
// // ...(supervisor_comment !== undefined && { supervisor_comment }),
// // },
// // include: { timesheet: { include: { employee: { include: { user: true } } } },
// // bank_code: true,
// // },
// // });
// // }
// // async remove(id: number): Promise<Expenses> {
// // await this.findOne(id);
// // return this.prisma.expenses.delete({ where: { id } });
// // }
// }

View File

@ -1,30 +1,30 @@
import { Body, Controller, Post } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { LeaveRequestsService } from "../services/leave-request.service";
import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto";
import { LeaveTypes } from "@prisma/client";
// import { Body, Controller, Post } from "@nestjs/common";
// import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
// import { LeaveRequestsService } from "../services/leave-request.service";
// import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto";
// import { LeaveTypes } from "@prisma/client";
@ApiTags('Leave Requests')
@ApiBearerAuth('access-token')
// @UseGuards()
@Controller('leave-requests')
export class LeaveRequestController {
constructor(private readonly leave_service: LeaveRequestsService){}
// @ApiTags('Leave Requests')
// @ApiBearerAuth('access-token')
// // @UseGuards()
// @Controller('leave-requests')
// export class LeaveRequestController {
// constructor(private readonly leave_service: LeaveRequestsService){}
@Post('upsert')
async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
const { action, leave_requests } = await this.leave_service.handle(dto);
return { action, leave_requests };
}q
// @Post('upsert')
// async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
// const { action, leave_requests } = await this.leave_service.handle(dto);
// return { action, leave_requests };
// }q
//TODO:
/*
@Get('archive')
findAllArchived(){...}
// //TODO:
// /*
// @Get('archive')
// findAllArchived(){...}
@Get('archive/:id')
findOneArchived(id){...}
*/
// @Get('archive/:id')
// findOneArchived(id){...}
// */
}
// }

View File

@ -1,29 +1,29 @@
import { PrismaService } from "src/prisma/prisma.service";
import { LeaveRequestController } from "./controllers/leave-requests.controller";
import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
import { Module } from "@nestjs/common";
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service";
import { SickLeaveRequestsService } from "./services/sick-leave-requests.service";
import { LeaveRequestsService } from "./services/leave-request.service";
import { ShiftsModule } from "../shifts/shifts.module";
import { LeaveRequestsUtils } from "./utils/leave-request.util";
import { SharedModule } from "../shared/shared.module";
// import { PrismaService } from "src/prisma/prisma.service";
// import { LeaveRequestController } from "./controllers/leave-requests.controller";
// import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
// import { Module } from "@nestjs/common";
// import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
// import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service";
// import { SickLeaveRequestsService } from "./services/sick-leave-requests.service";
// import { LeaveRequestsService } from "./services/leave-request.service";
// import { ShiftsModule } from "../shifts/shifts.module";
// import { LeaveRequestsUtils } from "./utils/leave-request.util";
// import { SharedModule } from "../shared/shared.module";
@Module({
imports: [BusinessLogicsModule, ShiftsModule, SharedModule],
controllers: [LeaveRequestController],
providers: [
VacationLeaveRequestsService,
SickLeaveRequestsService,
HolidayLeaveRequestsService,
LeaveRequestsService,
PrismaService,
LeaveRequestsUtils,
],
exports: [
LeaveRequestsService,
],
})
// @Module({
// imports: [BusinessLogicsModule, ShiftsModule, SharedModule],
// controllers: [LeaveRequestController],
// providers: [
// VacationLeaveRequestsService,
// SickLeaveRequestsService,
// HolidayLeaveRequestsService,
// LeaveRequestsService,
// PrismaService,
// LeaveRequestsUtils,
// ],
// exports: [
// LeaveRequestsService,
// ],
// })
export class LeaveRequestsModule {}
// export class LeaveRequestsModule {}

View File

@ -1,78 +1,78 @@
import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto';
import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { mapRowToView } from '../mappers/leave-requests.mapper';
import { leaveRequestsSelect } from '../utils/leave-requests.select';
import { LeaveRequestsUtils} from '../utils/leave-request.util';
import { normalizeDates, toDateOnly } from 'src/modules/shared/helpers/date-time.helpers';
import { BankCodesResolver } from 'src/modules/shared/utils/resolve-bank-type-id.utils';
import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
// import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto';
// import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
// import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
// import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
// import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
// import { PrismaService } from 'src/prisma/prisma.service';
// import { mapRowToView } from '../mappers/leave-requests.mapper';
// import { leaveRequestsSelect } from '../utils/leave-requests.select';
// import { LeaveRequestsUtils} from '../utils/leave-request.util';
// import { normalizeDates, toDateOnly } from 'src/modules/shared/helpers/date-time.helpers';
// import { BankCodesResolver } from 'src/modules/shared/utils/resolve-bank-type-id.utils';
// import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
@Injectable()
export class HolidayLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayService: HolidayService,
private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) {}
// @Injectable()
// export class HolidayLeaveRequestsService {
// constructor(
// private readonly prisma: PrismaService,
// private readonly holidayService: HolidayService,
// private readonly leaveUtils: LeaveRequestsUtils,
// private readonly emailResolver: EmailToIdResolver,
// private readonly typeResolver: BankCodesResolver,
// ) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findByType(LeaveTypes.HOLIDAY);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
const dates = normalizeDates(dto.dates);
if (!dates.length) throw new BadRequestException('Dates array must not be empty');
// async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
// const email = dto.email.trim();
// const employee_id = await this.emailResolver.findIdByEmail(email);
// const bank_code = await this.typeResolver.findByType(LeaveTypes.HOLIDAY);
// if(!bank_code) throw new NotFoundException(`bank_code not found`);
// const dates = normalizeDates(dto.dates);
// if (!dates.length) throw new BadRequestException('Dates array must not be empty');
const created: LeaveRequestViewDto[] = [];
// const created: LeaveRequestViewDto[] = [];
for (const iso_date of dates) {
const date = toDateOnly(iso_date);
// for (const iso_date of dates) {
// const date = toDateOnly(iso_date);
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.HOLIDAY,
date,
},
},
select: { id: true },
});
if (existing) {
throw new BadRequestException(`Holiday request already exists for ${iso_date}`);
}
// const existing = await this.prisma.leaveRequests.findUnique({
// where: {
// leave_per_employee_date: {
// employee_id: employee_id,
// leave_type: LeaveTypes.HOLIDAY,
// date,
// },
// },
// select: { id: true },
// });
// if (existing) {
// throw new BadRequestException(`Holiday request already exists for ${iso_date}`);
// }
const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier);
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employee_id,
bank_code_id: bank_code.id,
leave_type: LeaveTypes.HOLIDAY,
date,
comment: dto.comment ?? '',
requested_hours: dto.requested_hours ?? 8,
payable_hours: payable,
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
},
select: leaveRequestsSelect,
});
// const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier);
// const row = await this.prisma.leaveRequests.create({
// data: {
// employee_id: employee_id,
// bank_code_id: bank_code.id,
// leave_type: LeaveTypes.HOLIDAY,
// date,
// comment: dto.comment ?? '',
// requested_hours: dto.requested_hours ?? 8,
// payable_hours: payable,
// approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
// },
// select: leaveRequestsSelect,
// });
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment);
}
// const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
// if (row.approval_status === LeaveApprovalStatus.APPROVED) {
// await this.leaveUtils.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment);
// }
created.push({ ...mapRowToView(row), action: 'create' });
}
// created.push({ ...mapRowToView(row), action: 'create' });
// }
return { action: 'create', leave_requests: created };
}
}
// return { action: 'create', leave_requests: created };
// }
// }

View File

@ -1,248 +1,248 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { roundToQuarterHour } from "src/common/utils/date-utils";
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service";
import { SickLeaveRequestsService } from "./sick-leave-requests.service";
import { VacationLeaveRequestsService } from "./vacation-leave-requests.service";
import { HolidayService } from "src/modules/business-logics/services/holiday.service";
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { PrismaService } from "src/prisma/prisma.service";
import { LeaveRequestsUtils } from "../utils/leave-request.util";
import { normalizeDates, toDateOnly, toISODateKey } from "src/modules/shared/helpers/date-time.helpers";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
// import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
// import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
// import { roundToQuarterHour } from "src/common/utils/date-utils";
// import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
// import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
// import { mapRowToView } from "../mappers/leave-requests.mapper";
// import { leaveRequestsSelect } from "../utils/leave-requests.select";
// import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service";
// import { SickLeaveRequestsService } from "./sick-leave-requests.service";
// import { VacationLeaveRequestsService } from "./vacation-leave-requests.service";
// import { HolidayService } from "src/modules/business-logics/services/holiday.service";
// import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
// import { VacationService } from "src/modules/business-logics/services/vacation.service";
// import { PrismaService } from "src/prisma/prisma.service";
// import { LeaveRequestsUtils } from "../utils/leave-request.util";
// import { normalizeDates, toDateOnly, toISODateKey } from "src/modules/shared/helpers/date-time.helpers";
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
@Injectable()
export class LeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayLeaveService: HolidayLeaveRequestsService,
private readonly holidayService: HolidayService,
private readonly sickLogic: SickLeaveService,
private readonly sickLeaveService: SickLeaveRequestsService,
private readonly vacationLeaveService: VacationLeaveRequestsService,
private readonly vacationLogic: VacationService,
private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) {}
// @Injectable()
// export class LeaveRequestsService {
// constructor(
// private readonly prisma: PrismaService,
// private readonly holidayLeaveService: HolidayLeaveRequestsService,
// private readonly holidayService: HolidayService,
// private readonly sickLogic: SickLeaveService,
// private readonly sickLeaveService: SickLeaveRequestsService,
// private readonly vacationLeaveService: VacationLeaveRequestsService,
// private readonly vacationLogic: VacationService,
// private readonly leaveUtils: LeaveRequestsUtils,
// private readonly emailResolver: EmailToIdResolver,
// private readonly typeResolver: BankCodesResolver,
// ) {}
//handle distribution to the right service according to the selected type and action
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
switch (dto.type) {
case LeaveTypes.HOLIDAY:
if( dto.action === 'create'){
return this.holidayLeaveService.create(dto);
} else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.HOLIDAY);
} else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.HOLIDAY);
}
case LeaveTypes.VACATION:
if( dto.action === 'create'){
return this.vacationLeaveService.create(dto);
} else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.VACATION);
} else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.VACATION);
}
case LeaveTypes.SICK:
if( dto.action === 'create'){
return this.sickLeaveService.create(dto);
} else if (dto.action === 'update') {
return this.update(dto, LeaveTypes.SICK);
} else if (dto.action === 'delete'){
return this.delete(dto, LeaveTypes.SICK);
}
default:
throw new BadRequestException(`Unsupported leave type: ${dto.type} or action: ${dto.action}`);
}
}
// //handle distribution to the right service according to the selected type and action
// async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
// switch (dto.type) {
// case LeaveTypes.HOLIDAY:
// if( dto.action === 'create'){
// return this.holidayLeaveService.create(dto);
// } else if (dto.action === 'update') {
// return this.update(dto, LeaveTypes.HOLIDAY);
// } else if (dto.action === 'delete'){
// return this.delete(dto, LeaveTypes.HOLIDAY);
// }
// case LeaveTypes.VACATION:
// if( dto.action === 'create'){
// return this.vacationLeaveService.create(dto);
// } else if (dto.action === 'update') {
// return this.update(dto, LeaveTypes.VACATION);
// } else if (dto.action === 'delete'){
// return this.delete(dto, LeaveTypes.VACATION);
// }
// case LeaveTypes.SICK:
// if( dto.action === 'create'){
// return this.sickLeaveService.create(dto);
// } else if (dto.action === 'update') {
// return this.update(dto, LeaveTypes.SICK);
// } else if (dto.action === 'delete'){
// return this.delete(dto, LeaveTypes.SICK);
// }
// default:
// throw new BadRequestException(`Unsupported leave type: ${dto.type} or action: ${dto.action}`);
// }
// }
async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
const email = dto.email.trim();
const dates = normalizeDates(dto.dates);
const employee_id = await this.emailResolver.findIdByEmail(email);
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
// async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
// const email = dto.email.trim();
// const dates = normalizeDates(dto.dates);
// const employee_id = await this.emailResolver.findIdByEmail(email);
// if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const rows = await this.prisma.leaveRequests.findMany({
where: {
employee_id: employee_id,
leave_type: type,
date: { in: dates.map((d) => toDateOnly(d)) },
},
select: leaveRequestsSelect,
});
// const rows = await this.prisma.leaveRequests.findMany({
// where: {
// employee_id: employee_id,
// leave_type: type,
// date: { in: dates.map((d) => toDateOnly(d)) },
// },
// select: leaveRequestsSelect,
// });
if (rows.length !== dates.length) {
const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`);
}
// if (rows.length !== dates.length) {
// const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
// throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`);
// }
for (const row of rows) {
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
const iso = toISODateKey(row.date);
await this.leaveUtils.removeShift(email, employee_id, iso, type);
}
}
// for (const row of rows) {
// if (row.approval_status === LeaveApprovalStatus.APPROVED) {
// const iso = toISODateKey(row.date);
// await this.leaveUtils.removeShift(email, employee_id, iso, type);
// }
// }
await this.prisma.leaveRequests.deleteMany({
where: { id: { in: rows.map((row) => row.id) } },
});
// await this.prisma.leaveRequests.deleteMany({
// where: { id: { in: rows.map((row) => row.id) } },
// });
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const }));
return { action: "delete", leave_requests: deleted };
}
// const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const }));
// return { action: "delete", leave_requests: deleted };
// }
async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findByType(type);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = Number(bank_code.modifier ?? 1);
const dates = normalizeDates(dto.dates);
if (!dates.length) {
throw new BadRequestException("Dates array must not be empty");
}
// async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
// const email = dto.email.trim();
// const employee_id = await this.emailResolver.findIdByEmail(email);
// const bank_code = await this.typeResolver.findByType(type);
// if(!bank_code) throw new NotFoundException(`bank_code not found`);
// const modifier = Number(bank_code.modifier ?? 1);
// const dates = normalizeDates(dto.dates);
// if (!dates.length) {
// throw new BadRequestException("Dates array must not be empty");
// }
const entries = await Promise.all(
dates.map(async (iso_date) => {
const date = toDateOnly(iso_date);
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: type,
date,
},
},
select: leaveRequestsSelect,
});
if (!existing) throw new NotFoundException(`No Leave request found for ${iso_date}`);
return { iso_date, date, existing };
}),
);
// const entries = await Promise.all(
// dates.map(async (iso_date) => {
// const date = toDateOnly(iso_date);
// const existing = await this.prisma.leaveRequests.findUnique({
// where: {
// leave_per_employee_date: {
// employee_id: employee_id,
// leave_type: type,
// date,
// },
// },
// select: leaveRequestsSelect,
// });
// if (!existing) throw new NotFoundException(`No Leave request found for ${iso_date}`);
// return { iso_date, date, existing };
// }),
// );
const updated: LeaveRequestViewDto[] = [];
// const updated: LeaveRequestViewDto[] = [];
if (type === LeaveTypes.SICK) {
const firstExisting = entries[0].existing;
const fallbackRequested =
firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined
? Number(firstExisting.requested_hours)
: 8;
const requested_hours_per_day = dto.requested_hours ?? fallbackRequested;
const reference_date = entries.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
entries[0].date,
);
const total_payable_hours = await this.sickLogic.calculateSickLeavePay(
employee_id,
reference_date,
entries.length,
requested_hours_per_day,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
// if (type === LeaveTypes.SICK) {
// const firstExisting = entries[0].existing;
// const fallbackRequested =
// firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined
// ? Number(firstExisting.requested_hours)
// : 8;
// const requested_hours_per_day = dto.requested_hours ?? fallbackRequested;
// const reference_date = entries.reduce(
// (latest, entry) => (entry.date > latest ? entry.date : latest),
// entries[0].date,
// );
// const total_payable_hours = await this.sickLogic.calculateSickLeavePay(
// employee_id,
// reference_date,
// entries.length,
// requested_hours_per_day,
// modifier,
// );
// let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
// const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
for (const { iso_date, existing } of entries) {
const previous_status = existing.approval_status;
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
// for (const { iso_date, existing } of entries) {
// const previous_status = existing.approval_status;
// const payable = Math.min(remaining_payable_hours, daily_payable_cap);
// const payable_rounded = roundToQuarterHour(Math.max(0, payable));
// remaining_payable_hours = roundToQuarterHour(
// Math.max(0, remaining_payable_hours - payable_rounded),
// );
const row = await this.prisma.leaveRequests.update({
where: { id: existing.id },
data: {
comment: dto.comment ?? existing.comment,
requested_hours: requested_hours_per_day,
payable_hours: payable_rounded,
bank_code_id: bank_code.id,
approval_status: dto.approval_status ?? existing.approval_status,
},
select: leaveRequestsSelect,
});
// const row = await this.prisma.leaveRequests.update({
// where: { id: existing.id },
// data: {
// comment: dto.comment ?? existing.comment,
// requested_hours: requested_hours_per_day,
// payable_hours: payable_rounded,
// bank_code_id: bank_code.id,
// approval_status: dto.approval_status ?? existing.approval_status,
// },
// select: leaveRequestsSelect,
// });
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
// const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
// const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
// const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (!was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} else if (was_approved && !is_approved) {
await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
} else if (was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
}
updated.push({ ...mapRowToView(row), action: "update" });
}
return { action: "update", leave_requests: updated };
}
// if (!was_approved && is_approved) {
// await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
// } else if (was_approved && !is_approved) {
// await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
// } else if (was_approved && is_approved) {
// await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
// }
// updated.push({ ...mapRowToView(row), action: "update" });
// }
// return { action: "update", leave_requests: updated };
// }
for (const { iso_date, date, existing } of entries) {
const previous_status = existing.approval_status;
const fallbackRequested =
existing.requested_hours !== null && existing.requested_hours !== undefined
? Number(existing.requested_hours)
: 8;
const requested_hours = dto.requested_hours ?? fallbackRequested;
// for (const { iso_date, date, existing } of entries) {
// const previous_status = existing.approval_status;
// const fallbackRequested =
// existing.requested_hours !== null && existing.requested_hours !== undefined
// ? Number(existing.requested_hours)
// : 8;
// const requested_hours = dto.requested_hours ?? fallbackRequested;
let payable: number;
switch (type) {
case LeaveTypes.HOLIDAY:
payable = await this.holidayService.calculateHolidayPay(email, date, modifier);
break;
case LeaveTypes.VACATION: {
const days_requested = requested_hours / 8;
payable = await this.vacationLogic.calculateVacationPay(
employee_id,
date,
Math.max(0, days_requested),
modifier,
);
break;
}
default:
payable = existing.payable_hours !== null && existing.payable_hours !== undefined
? Number(existing.payable_hours)
: requested_hours;
}
// let payable: number;
// switch (type) {
// case LeaveTypes.HOLIDAY:
// payable = await this.holidayService.calculateHolidayPay(email, date, modifier);
// break;
// case LeaveTypes.VACATION: {
// const days_requested = requested_hours / 8;
// payable = await this.vacationLogic.calculateVacationPay(
// employee_id,
// date,
// Math.max(0, days_requested),
// modifier,
// );
// break;
// }
// default:
// payable = existing.payable_hours !== null && existing.payable_hours !== undefined
// ? Number(existing.payable_hours)
// : requested_hours;
// }
const row = await this.prisma.leaveRequests.update({
where: { id: existing.id },
data: {
requested_hours,
comment: dto.comment ?? existing.comment,
payable_hours: payable,
bank_code_id: bank_code.id,
approval_status: dto.approval_status ?? existing.approval_status,
},
select: leaveRequestsSelect,
});
// const row = await this.prisma.leaveRequests.update({
// where: { id: existing.id },
// data: {
// requested_hours,
// comment: dto.comment ?? existing.comment,
// payable_hours: payable,
// bank_code_id: bank_code.id,
// approval_status: dto.approval_status ?? existing.approval_status,
// },
// select: leaveRequestsSelect,
// });
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
// const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
// const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
// const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (!was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
} else if (was_approved && !is_approved) {
await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
} else if (was_approved && is_approved) {
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
}
updated.push({ ...mapRowToView(row), action: "update" });
}
return { action: "update", leave_requests: updated };
}
}
// if (!was_approved && is_approved) {
// await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
// } else if (was_approved && !is_approved) {
// await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
// } else if (was_approved && is_approved) {
// await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
// }
// updated.push({ ...mapRowToView(row), action: "update" });
// }
// return { action: "update", leave_requests: updated };
// }
// }

View File

@ -1,98 +1,98 @@
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { PrismaService } from "src/prisma/prisma.service";
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
import { roundToQuarterHour } from "src/common/utils/date-utils";
import { LeaveRequestsUtils } from "../utils/leave-request.util";
import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
// import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
// import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
// import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
// import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
// import { leaveRequestsSelect } from "../utils/leave-requests.select";
// import { mapRowToView } from "../mappers/leave-requests.mapper";
// import { PrismaService } from "src/prisma/prisma.service";
// import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
// import { roundToQuarterHour } from "src/common/utils/date-utils";
// import { LeaveRequestsUtils } from "../utils/leave-request.util";
// import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
@Injectable()
export class SickLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly sickService: SickLeaveService,
private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) {}
// @Injectable()
// export class SickLeaveRequestsService {
// constructor(
// private readonly prisma: PrismaService,
// private readonly sickService: SickLeaveService,
// private readonly leaveUtils: LeaveRequestsUtils,
// private readonly emailResolver: EmailToIdResolver,
// private readonly typeResolver: BankCodesResolver,
// ) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findByType(LeaveTypes.SICK);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
// async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
// const email = dto.email.trim();
// const employee_id = await this.emailResolver.findIdByEmail(email);
// const bank_code = await this.typeResolver.findByType(LeaveTypes.SICK);
// if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates);
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const requested_hours_per_day = dto.requested_hours ?? 8;
// const modifier = bank_code.modifier ?? 1;
// const dates = normalizeDates(dto.dates);
// if (!dates.length) throw new BadRequestException("Dates array must not be empty");
// const requested_hours_per_day = dto.requested_hours ?? 8;
const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) }));
const reference_date = entries.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest),
entries[0].date,
);
const total_payable_hours = await this.sickService.calculateSickLeavePay(
employee_id,
reference_date,
entries.length,
requested_hours_per_day,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
// const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) }));
// const reference_date = entries.reduce(
// (latest, entry) => (entry.date > latest ? entry.date : latest),
// entries[0].date,
// );
// const total_payable_hours = await this.sickService.calculateSickLeavePay(
// employee_id,
// reference_date,
// entries.length,
// requested_hours_per_day,
// modifier,
// );
// let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
// const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = [];
// const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.SICK,
date,
},
},
select: { id: true },
});
if (existing) {
throw new BadRequestException(`Sick request already exists for ${iso}`);
}
// for (const { iso, date } of entries) {
// const existing = await this.prisma.leaveRequests.findUnique({
// where: {
// leave_per_employee_date: {
// employee_id: employee_id,
// leave_type: LeaveTypes.SICK,
// date,
// },
// },
// select: { id: true },
// });
// if (existing) {
// throw new BadRequestException(`Sick request already exists for ${iso}`);
// }
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
// const payable = Math.min(remaining_payable_hours, daily_payable_cap);
// const payable_rounded = roundToQuarterHour(Math.max(0, payable));
// remaining_payable_hours = roundToQuarterHour(
// Math.max(0, remaining_payable_hours - payable_rounded),
// );
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employee_id,
bank_code_id: bank_code.id,
leave_type: LeaveTypes.SICK,
comment: dto.comment ?? "",
requested_hours: requested_hours_per_day,
payable_hours: payable_rounded,
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date,
},
select: leaveRequestsSelect,
});
// const row = await this.prisma.leaveRequests.create({
// data: {
// employee_id: employee_id,
// bank_code_id: bank_code.id,
// leave_type: LeaveTypes.SICK,
// comment: dto.comment ?? "",
// requested_hours: requested_hours_per_day,
// payable_hours: payable_rounded,
// approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
// date,
// },
// select: leaveRequestsSelect,
// });
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment);
}
// const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
// if (row.approval_status === LeaveApprovalStatus.APPROVED) {
// await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment);
// }
created.push({ ...mapRowToView(row), action: "create" });
}
// created.push({ ...mapRowToView(row), action: "create" });
// }
return { action: "create", leave_requests: created };
}
}
// return { action: "create", leave_requests: created };
// }
// }

View File

@ -1,93 +1,93 @@

import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { PrismaService } from "src/prisma/prisma.service";
import { mapRowToView } from "../mappers/leave-requests.mapper";
import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { roundToQuarterHour } from "src/common/utils/date-utils";
import { LeaveRequestsUtils } from "../utils/leave-request.util";
import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
// import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
// import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
// import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
// import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
// import { VacationService } from "src/modules/business-logics/services/vacation.service";
// import { PrismaService } from "src/prisma/prisma.service";
// import { mapRowToView } from "../mappers/leave-requests.mapper";
// import { leaveRequestsSelect } from "../utils/leave-requests.select";
// import { roundToQuarterHour } from "src/common/utils/date-utils";
// import { LeaveRequestsUtils } from "../utils/leave-request.util";
// import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
@Injectable()
export class VacationLeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly vacationService: VacationService,
private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
) {}
// @Injectable()
// export class VacationLeaveRequestsService {
// constructor(
// private readonly prisma: PrismaService,
// private readonly vacationService: VacationService,
// private readonly leaveUtils: LeaveRequestsUtils,
// private readonly emailResolver: EmailToIdResolver,
// private readonly typeResolver: BankCodesResolver,
// ) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findByType(LeaveTypes.VACATION);
if(!bank_code) throw new NotFoundException(`bank_code not found`);
// async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
// const email = dto.email.trim();
// const employee_id = await this.emailResolver.findIdByEmail(email);
// const bank_code = await this.typeResolver.findByType(LeaveTypes.VACATION);
// if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates);
const requested_hours_per_day = dto.requested_hours ?? 8;
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
// const modifier = bank_code.modifier ?? 1;
// const dates = normalizeDates(dto.dates);
// const requested_hours_per_day = dto.requested_hours ?? 8;
// if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const entries = dates
.map((iso) => ({ iso, date: toDateOnly(iso) }))
.sort((a, b) => a.date.getTime() - b.date.getTime());
const start_date = entries[0].date;
const total_payable_hours = await this.vacationService.calculateVacationPay(
employee_id,
start_date,
entries.length,
modifier,
);
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
// const entries = dates
// .map((iso) => ({ iso, date: toDateOnly(iso) }))
// .sort((a, b) => a.date.getTime() - b.date.getTime());
// const start_date = entries[0].date;
// const total_payable_hours = await this.vacationService.calculateVacationPay(
// employee_id,
// start_date,
// entries.length,
// modifier,
// );
// let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
// const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = [];
// const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({
where: {
leave_per_employee_date: {
employee_id: employee_id,
leave_type: LeaveTypes.VACATION,
date,
},
},
select: { id: true },
});
if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`);
// for (const { iso, date } of entries) {
// const existing = await this.prisma.leaveRequests.findUnique({
// where: {
// leave_per_employee_date: {
// employee_id: employee_id,
// leave_type: LeaveTypes.VACATION,
// date,
// },
// },
// select: { id: true },
// });
// if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`);
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded),
);
// const payable = Math.min(remaining_payable_hours, daily_payable_cap);
// const payable_rounded = roundToQuarterHour(Math.max(0, payable));
// remaining_payable_hours = roundToQuarterHour(
// Math.max(0, remaining_payable_hours - payable_rounded),
// );
const row = await this.prisma.leaveRequests.create({
data: {
employee_id: employee_id,
bank_code_id: bank_code.id,
payable_hours: payable_rounded,
requested_hours: requested_hours_per_day,
leave_type: LeaveTypes.VACATION,
comment: dto.comment ?? "",
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date,
},
select: leaveRequestsSelect,
});
// const row = await this.prisma.leaveRequests.create({
// data: {
// employee_id: employee_id,
// bank_code_id: bank_code.id,
// payable_hours: payable_rounded,
// requested_hours: requested_hours_per_day,
// leave_type: LeaveTypes.VACATION,
// comment: dto.comment ?? "",
// approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
// date,
// },
// select: leaveRequestsSelect,
// });
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment);
}
created.push({ ...mapRowToView(row), action: "create" });
}
return { action: "create", leave_requests: created };
}
}
// const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
// if (row.approval_status === LeaveApprovalStatus.APPROVED) {
// await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment);
// }
// created.push({ ...mapRowToView(row), action: "create" });
// }
// return { action: "create", leave_requests: created };
// }
// }

View File

@ -1,105 +1,104 @@
import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers";
import { BadRequestException, Injectable } from "@nestjs/common";
import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service";
import { PrismaService } from "src/prisma/prisma.service";
import { LeaveTypes } from "@prisma/client";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
// import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers";
// import { BadRequestException, Injectable } from "@nestjs/common";
// import { PrismaService } from "src/prisma/prisma.service";
// import { LeaveTypes } from "@prisma/client";
// import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
@Injectable()
export class LeaveRequestsUtils {
constructor(
private readonly prisma: PrismaService,
private readonly shiftsCommand: ShiftsCommandService,
){}
// @Injectable()
// export class LeaveRequestsUtils {
// constructor(
// private readonly prisma: PrismaService,
// private readonly shiftsCommand: ShiftsCommandService,
// ){}
async syncShift(
email: string,
employee_id: number,
date: string,
hours: number,
type: LeaveTypes,
comment?: string,
) {
if (hours <= 0) return;
// async syncShift(
// email: string,
// employee_id: number,
// date: string,
// hours: number,
// type: LeaveTypes,
// comment?: string,
// ) {
// if (hours <= 0) return;
const duration_minutes = Math.round(hours * 60);
if (duration_minutes > 8 * 60) {
throw new BadRequestException("Amount of hours cannot exceed 8 hours per day.");
}
const date_only = toDateOnly(date);
const yyyy_mm_dd = toStringFromDate(date_only);
// const duration_minutes = Math.round(hours * 60);
// if (duration_minutes > 8 * 60) {
// throw new BadRequestException("Amount of hours cannot exceed 8 hours per day.");
// }
// const date_only = toDateOnly(date);
// const yyyy_mm_dd = toStringFromDate(date_only);
const start_minutes = 8 * 60;
const end_minutes = start_minutes + duration_minutes;
const toHHmm = (total: number) =>
`${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
// const start_minutes = 8 * 60;
// const end_minutes = start_minutes + duration_minutes;
// const toHHmm = (total: number) =>
// `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
const existing = await this.prisma.shifts.findFirst({
where: {
date: date_only,
bank_code: { type },
timesheet: { employee_id: employee_id },
},
include: { bank_code: true },
});
// const existing = await this.prisma.shifts.findFirst({
// where: {
// date: date_only,
// bank_code: { type },
// timesheet: { employee_id: employee_id },
// },
// include: { bank_code: true },
// });
const action: UpsertAction = existing ? 'update' : 'create';
// const action: UpsertAction = existing ? 'update' : 'create';
await this.shiftsCommand.upsertShifts(email, action, {
old_shift: existing
? {
date: yyyy_mm_dd,
start_time: existing.start_time.toISOString().slice(11, 16),
end_time: existing.end_time.toISOString().slice(11, 16),
type: existing.bank_code?.type ?? type,
is_remote: existing.is_remote,
is_approved:existing.is_approved,
comment: existing.comment ?? undefined,
}
: undefined,
new_shift: {
date: yyyy_mm_dd,
start_time: toHHmm(start_minutes),
end_time: toHHmm(end_minutes),
is_remote: existing?.is_remote ?? false,
is_approved:existing?.is_approved ?? false,
comment: comment ?? existing?.comment ?? "",
type: type,
},
});
}
// await this.shiftsCommand.upsertShifts(email, action, {
// old_shift: existing
// ? {
// date: yyyy_mm_dd,
// start_time: existing.start_time.toISOString().slice(11, 16),
// end_time: existing.end_time.toISOString().slice(11, 16),
// type: existing.bank_code?.type ?? type,
// is_remote: existing.is_remote,
// is_approved:existing.is_approved,
// comment: existing.comment ?? undefined,
// }
// : undefined,
// new_shift: {
// date: yyyy_mm_dd,
// start_time: toHHmm(start_minutes),
// end_time: toHHmm(end_minutes),
// is_remote: existing?.is_remote ?? false,
// is_approved:existing?.is_approved ?? false,
// comment: comment ?? existing?.comment ?? "",
// type: type,
// },
// });
// }
async removeShift(
email: string,
employee_id: number,
iso_date: string,
type: LeaveTypes,
) {
const date_only = toDateOnly(iso_date);
const yyyy_mm_dd = toStringFromDate(date_only);
const existing = await this.prisma.shifts.findFirst({
where: {
date: date_only,
bank_code: { type },
timesheet: { employee_id: employee_id },
},
include: { bank_code: true },
});
if (!existing) return;
// async removeShift(
// email: string,
// employee_id: number,
// iso_date: string,
// type: LeaveTypes,
// ) {
// const date_only = toDateOnly(iso_date);
// const yyyy_mm_dd = toStringFromDate(date_only);
// const existing = await this.prisma.shifts.findFirst({
// where: {
// date: date_only,
// bank_code: { type },
// timesheet: { employee_id: employee_id },
// },
// include: { bank_code: true },
// });
// if (!existing) return;
await this.shiftsCommand.upsertShifts(email, 'delete', {
old_shift: {
date: yyyy_mm_dd,
start_time: hhmmFromLocal(existing.start_time),
end_time: hhmmFromLocal(existing.end_time),
type: existing.bank_code?.type ?? type,
is_remote: existing.is_remote,
is_approved:existing.is_approved,
comment: existing.comment ?? undefined,
},
});
}
// await this.shiftsCommand.upsertShifts(email, 'delete', {
// old_shift: {
// date: yyyy_mm_dd,
// start_time: hhmmFromLocal(existing.start_time),
// end_time: hhmmFromLocal(existing.end_time),
// type: existing.bank_code?.type ?? type,
// is_remote: existing.is_remote,
// is_approved:existing.is_approved,
// comment: existing.comment ?? undefined,
// },
// });
// }
}
// }

View File

@ -1,33 +1,27 @@
import { PrismaModule } from "src/prisma/prisma.module";
import { PayPeriodsController } from "./controllers/pay-periods.controller";
import { Module } from "@nestjs/common";
import { PayPeriodsCommandService } from "./services/pay-periods-command.service";
import { PayPeriodsQueryService } from "./services/pay-periods-query.service";
import { TimesheetsModule } from "../timesheets/timesheets.module";
import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service";
import { ExpensesCommandService } from "../expenses/services/expenses-command.service";
import { ShiftsCommandService } from "../shifts/services/shifts-command.service";
import { SharedModule } from "../shared/shared.module";
import { PrismaService } from "src/prisma/prisma.service";
import { BusinessLogicsModule } from "../business-logics/business-logics.module";
import { ShiftsHelpersService } from "../shifts/helpers/shifts.helpers";
// import { PrismaModule } from "src/prisma/prisma.module";
// import { PayPeriodsController } from "./controllers/pay-periods.controller";
// import { Module } from "@nestjs/common";
// import { PayPeriodsCommandService } from "./services/pay-periods-command.service";
// import { PayPeriodsQueryService } from "./services/pay-periods-query.service";
// import { TimesheetsModule } from "../timesheets/timesheets.module";
// import { ExpensesCommandService } from "../expenses/services/expenses-command.service";
// import { SharedModule } from "../shared/shared.module";
// import { PrismaService } from "src/prisma/prisma.service";
// import { BusinessLogicsModule } from "../business-logics/business-logics.module";
@Module({
imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule],
providers: [
PayPeriodsQueryService,
PayPeriodsCommandService,
TimesheetsCommandService,
ExpensesCommandService,
ShiftsCommandService,
PrismaService,
ShiftsHelpersService,
],
controllers: [PayPeriodsController],
exports: [
PayPeriodsQueryService,
PayPeriodsCommandService,
]
})
// @Module({
// imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule],
// providers: [
// PayPeriodsQueryService,
// PayPeriodsCommandService,
// ExpensesCommandService,
// PrismaService,
// ],
// controllers: [PayPeriodsController],
// exports: [
// PayPeriodsQueryService,
// PayPeriodsCommandService,
// ]
// })
export class PayperiodsModule {}
// export class PayperiodsModule {}

View File

@ -1,14 +1,14 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { TimesheetsCommandService } from "src/modules/timesheets/services/timesheets-command.service";
import { PrismaService } from "src/prisma/prisma.service";
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
import { PayPeriodsQueryService } from "./pay-periods-query.service";
import { TimesheetApprovalService } from "src/modules/timesheets/services/timesheet-approval.service";
@Injectable()
export class PayPeriodsCommandService {
constructor(
private readonly prisma: PrismaService,
private readonly timesheets_approval: TimesheetsCommandService,
private readonly timesheets_approval: TimesheetApprovalService,
private readonly query: PayPeriodsQueryService,
) {}

View File

@ -0,0 +1,49 @@
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query } from "@nestjs/common";
import { CreateResult, ShiftsUpsertService, UpdateResult } from "../services/shifts-upsert.service";
import { updateShiftDto } from "../dtos/update-shift.dto";
import { ShiftDto } from "../dtos/shift.dto";
import { ShiftsGetService } from "../services/shifts-get.service";
@Controller('shift')
export class ShiftController {
constructor(
private readonly upsert_service: ShiftsUpsertService,
private readonly get_service: ShiftsGetService
){}
@Get()
async getShiftsByIds(
@Query("shift_ids") shift_ids: string) {
const parsed = shift_ids.split(/,\s*/).map(value => Number(value)).filter(Number.isFinite);
return this.get_service.getShiftByShiftId(parsed);
}
@Post(':timesheet_id')
createBatch(
@Param('timesheet_id', ParseIntPipe) timesheet_id: number,
@Body()dtos: ShiftDto[]): Promise<CreateResult[]> {
const list = Array.isArray(dtos) ? dtos : [];
if(list.length === 0) throw new BadRequestException('Body is missing or invalid (create shifts)')
return this.upsert_service.createShifts(timesheet_id, dtos)
}
@Patch()
updateBatch(
@Body() body: { updates: { id: number; dto: updateShiftDto }[] }): Promise<UpdateResult[]>{
const updates = Array.isArray(body?.updates)
? body.updates.filter(update => Number.isFinite(update?.id) && typeof update.dto === "object")
: [];
if(updates.length === 0) {
throw new BadRequestException(`Body is missing or invalid (update shifts)`);
}
return this.upsert_service.updateShifts(updates);
}
@Delete(':shift_id')
remove(@Param('shift_id') shift_id: number ) {
return this.upsert_service.deleteShift(shift_id);
}
}

View File

@ -1,87 +0,0 @@
import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ShiftsCommandService } from "../services/shifts-command.service";
import { ShiftsQueryService } from "../services/shifts-query.service";
import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
@ApiTags('Shifts')
@ApiBearerAuth('access-token')
// @UseGuards()
@Controller('shifts')
export class ShiftsController {
constructor(
private readonly shiftsService: ShiftsQueryService,
private readonly shiftsCommandService: ShiftsCommandService,
){}
@Put('upsert/:email')
async upsert_by_date(
@Param('email') email_param: string,
@Query('action') action: UpsertAction,
@Body() payload: UpsertShiftDto,
) {
return this.shiftsCommandService.upsertShifts(email_param, action, payload);
}
@Delete('delete/:email/:date')
async remove(
@Param('email') email: string,
@Param('date') date: string,
@Body() payload: UpsertShiftDto,
) {
return this.shiftsCommandService.deleteShift(email, date, payload);
}
@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.shiftsCommandService.updateApproval(id, isApproved);
}
@Get('summary')
async getSummary( @Query() query: GetShiftsOverviewDto): Promise<OverviewRow[]> {
return this.shiftsService.getSummary(query.period_id);
}
@Get('export.csv')
@Header('Content-Type', 'text/csv; charset=utf-8')
@Header('Content-Disposition', 'attachment; filename="shifts-validation.csv"')
async exportCsv(@Query() query: GetShiftsOverviewDto): Promise<Buffer>{
const rows = await this.shiftsService.getSummary(query.period_id);
//CSV Headers
const header = [
'full_name',
'supervisor',
'total_regular_hrs',
'total_evening_hrs',
'total_overtime_hrs',
'total_expenses',
'total_mileage',
'is_validated'
].join(',') + '\n';
//CSV rows
const body = rows.map(r => {
const esc = (str: string) => `"${str.replace(/"/g, '""')}"`;
return [
esc(r.full_name),
esc(r.supervisor),
r.total_regular_hrs.toFixed(2),
r.total_evening_hrs.toFixed(2),
r.total_overtime_hrs.toFixed(2),
r.total_expenses.toFixed(2),
r.total_mileage.toFixed(2),
r.is_approved,
].join(',');
}).join('\n');
return Buffer.from('\uFEFF' + header + body, 'utf8');
}
}

View File

@ -1,10 +0,0 @@
import { Type } from "class-transformer";
import { IsInt, Min, Max } from "class-validator";
export class GetShiftsOverviewDto {
@Type(()=> Number)
@IsInt()
@Min(1)
@Max(26)
period_id: number;
}

View File

@ -0,0 +1,11 @@
export class GetShiftDto {
timesheet_id: number;
bank_code_id: number;
date: string;
start_time: string;
end_time: string;
is_remote: boolean;
is_approved: boolean;
comment?: string;
}

View File

@ -0,0 +1,15 @@
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
export class ShiftDto {
@IsInt() timesheet_id!: number;
@IsInt() bank_code_id!: number;
@IsString() date!: string;
@IsString() start_time!: string;
@IsString() end_time!: string;
@IsBoolean() is_approved!: boolean;
@IsBoolean() is_remote!: boolean;
@IsOptional() @IsString() @MaxLength(280) comment?: string;
}

View File

@ -0,0 +1,7 @@
import { PartialType, OmitType } from "@nestjs/swagger";
import { ShiftDto } from "./shift.dto";
export class updateShiftDto extends PartialType (
//allows update using ShiftDto and preventing OmitType variables to be modified
OmitType(ShiftDto, [ 'is_approved', 'timesheet_id'] as const)
){}

View File

@ -1,43 +0,0 @@
import { Type } from "class-transformer";
import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
export const COMMENT_MAX_LENGTH = 280;
export class ShiftPayloadDto {
@Matches(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/)
date!: string;
@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;
@IsBoolean()
is_approved!: 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;
};

View File

@ -1,15 +1,3 @@
export function timeFromHHMM(hhmm: string): Date {
const [h, m] = hhmm.split(':').map(Number);
return new Date(1970, 0, 1, h, m, 0, 0);
}
export function toDateOnly(ymd: string): Date {
const y = Number(ymd.slice(0, 4));
const m = Number(ymd.slice(5, 7)) - 1;
const d = Number(ymd.slice(8, 10));
return new Date(y, m, d, 0, 0, 0, 0);
}
export function weekStartSunday(date_local: Date): Date {
const start = new Date(Date.UTC(date_local.getFullYear(), date_local.getMonth(), date_local.getDate()));
const dow = start.getDay(); // 0 = dimanche
@ -18,8 +6,26 @@ export function weekStartSunday(date_local: Date): Date {
return start;
}
export function formatHHmm(t: Date): string {
const hh = String(t.getHours()).padStart(2, '0');
const mm = String(t.getMinutes()).padStart(2, '0');
//converts string to HHmm format
export const toStringFromHHmm = (date: Date): string => {
const hh = date.getUTCHours().toString().padStart(2, '0');
const mm = date.getUTCMinutes().toString().padStart(2, '0');
return `${hh}:${mm}`;
}
//converts string to Date format
export const toStringFromDate = (date: Date) =>
date.toISOString().slice(0,10);
//converts HHmm format to string
export const toHHmmFromString = (hhmm: string): Date => {
const [hh, mm] = hhmm.split(':').map(Number);
const date = new Date('1970-01-01T00:00:00.000Z');
date.setUTCHours(hh, mm, 0, 0);
return new Date(date);
}
//converts Date format to string
export const toDateFromString = (ymd: string): Date => {
return new Date(`${ymd}T00:00:00:000Z`);
}

View File

@ -1,139 +0,0 @@
import { BadRequestException, UnprocessableEntityException, NotFoundException, ConflictException } from "@nestjs/common";
import { Prisma, Shifts } from "@prisma/client";
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils";
import { weekStartSunday, formatHHmm } from "./shifts-date-time-helpers";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { OvertimeService } from "src/modules/business-logics/services/overtime.service";
export type Tx = Prisma.TransactionClient;
export type Normalized = Awaited<ReturnType<typeof normalizeShiftPayload>>;
export class ShiftsHelpersService {
constructor(
private readonly bankTypeResolver: BankCodesResolver,
private readonly overtimeService: OvertimeService,
) { }
async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) {
const start_of_week = weekStartSunday(date_only);
console.log('start of week: ', start_of_week);
return tx.timesheets.findUnique({
where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
select: { id: true },
});
}
async normalizeRequired(
raw: UpsertShiftDto['new_shift'] | UpsertShiftDto['old_shift'] | undefined | null,
label: 'old_shift' | 'new_shift' = 'new_shift',
): Promise<Normalized> {
if (!raw) throw new BadRequestException(`${label} is required`);
const norm = await normalizeShiftPayload(raw);
if (norm.end_time.getTime() <= norm.start_time.getTime()) {
throw new UnprocessableEntityException(` ${label}.end_time must be > ${label}.start_time`);
}
return norm;
}
async resolveBankIdRequired(tx: Tx, type: string, label: 'old_shift' | 'new_shift'): Promise<number> {
const found = await this.bankTypeResolver.findByType(type, tx);
const id = found?.id;
if (typeof id !== 'number') {
throw new NotFoundException(`bank code not found for ${label}.type: ${type ?? ''}`);
}
return id;
}
async getDayShifts(tx: Tx, timesheet_id: number, date_only: Date) {
return tx.shifts.findMany({
where: { timesheet_id, date: date_only },
include: { bank_code: true },
orderBy: { start_time: 'asc' },
});
}
async assertNoOverlap(
day_shifts: Array<Shifts & { bank_code: { type: string } | null }>,
new_norm: Normalized | undefined,
exclude_id?: number,
) {
if (!new_norm) return;
const conflicts = day_shifts.filter((s) => {
if (exclude_id && s.id === exclude_id) return false;
return overlaps(
new_norm.start_time.getTime(),
new_norm.end_time.getTime(),
s.start_time.getTime(),
s.end_time.getTime(),
);
});
if (conflicts.length) {
const payload = conflicts.map((s) => ({
start_time: formatHHmm(s.start_time),
end_time: formatHHmm(s.end_time),
type: s.bank_code?.type ?? 'UNKNOWN',
}));
throw new ConflictException({
error_code: 'SHIFT_OVERLAP',
message: 'New shift overlaps with existing shift(s)',
conflicts: payload,
});
}
}
async findExactOldShift(
tx: Tx,
params: {
timesheet_id: number;
date_only: Date;
norm: Normalized;
bank_code_id: number;
comment?: string;
},
) {
const { timesheet_id, date_only, norm, bank_code_id } = params;
return tx.shifts.findFirst({
where: {
timesheet_id,
date: date_only,
start_time: norm.start_time,
end_time: norm.end_time,
is_remote: norm.is_remote,
is_approved: norm.is_approved,
comment: norm.comment ?? null,
bank_code_id,
},
select: { id: true },
});
}
async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date) {
// Switch regular → weekly overtime si > 40h
await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx);
const daily = await this.overtimeService.getDailyOvertimeHours(employee_id, date_only);
const weekly = await this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only);
// const [daily, weekly] = await Promise.all([
// this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only),
// this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only),
// ]);
return { daily, weekly };
}
async mapDay(
fresh: Array<Shifts & { bank_code: { type: string } | null }>,
): Promise<DayShiftResponse[]> {
return fresh.map((s) => ({
start_time: formatHHmm(s.start_time),
end_time: formatHHmm(s.end_time),
type: s.bank_code?.type ?? 'UNKNOWN',
is_remote: s.is_remote,
comment: s.comment ?? null,
}));
}
}

View File

@ -1,197 +0,0 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { Prisma, Shifts } from "@prisma/client";
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { PrismaService } from "src/prisma/prisma.service";
import { toDateOnly } from "../helpers/shifts-date-time-helpers";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
import { ShiftsHelpersService } from "../helpers/shifts.helpers";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
@Injectable()
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
private readonly logger = new Logger(ShiftsCommandService.name);
constructor(
prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
private readonly helpersService: ShiftsHelpersService,
) { super(prisma); }
//_____________________________________________________________________________________________
// APPROVAL AND DELEGATE METHODS
//_____________________________________________________________________________________________
protected get delegate() {
return this.prisma.shifts;
}
protected delegateFor(transaction: Prisma.TransactionClient) {
return transaction.shifts;
}
async updateApproval(id: number, is_approved: boolean): Promise<Shifts> {
return this.prisma.$transaction((transaction) =>
this.updateApprovalWithTransaction(transaction, id, is_approved),
);
}
//_____________________________________________________________________________________________
// MASTER CRUD METHOD
//_____________________________________________________________________________________________
async upsertShifts(
email: string,
action: UpsertAction,
dto: UpsertShiftDto,
): Promise<{
action: UpsertAction;
day: DayShiftResponse[];
}> {
if (!dto.old_shift && !dto.new_shift) throw new BadRequestException('At least one of old or new shift must be provided');
const date = dto.new_shift?.date ?? dto.old_shift?.date;
if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift");
if (dto.old_shift?.date && dto.new_shift?.date && dto.old_shift.date !== dto.new_shift.date) {
throw new BadRequestException('old_shift.date and new_shift.date must be identical');
}
const employee_id = await this.emailResolver.findIdByEmail(email);//resolve employee id using email
if(action === 'create') {
if(!dto.new_shift || dto.old_shift) {
throw new BadRequestException(`Only new_shift must be provided for create`);
}
return this.createShift(employee_id, date, dto);
}
if(action === 'update'){
if(!dto.old_shift || !dto.new_shift) {
throw new BadRequestException(`Both new_shift and old_shift must be provided for update`);
}
return this.updateShift(employee_id, date, dto);
}
throw new BadRequestException(`Unknown action: ${action}`);
}
//_________________________________________________________________
// CREATE
//_________________________________________________________________
private async createShift(
employee_id: number,
date_iso: string,
dto: UpsertShiftDto,
): Promise<{action: UpsertAction; day: DayShiftResponse[]}> {
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso);
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
if(!timesheet) throw new NotFoundException('Timesheet not found')
const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift);
const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift');
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift);
await tx.shifts.create({
data: {
timesheet_id: timesheet.id,
date: date_only,
start_time: new_norm_shift.start_time,
end_time: new_norm_shift.end_time,
is_remote: new_norm_shift.is_remote,
is_approved: new_norm_shift.is_approved,
comment: new_norm_shift.comment ?? null,
bank_code_id: new_bank_code_id,
},
});
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)};
});
}
//_________________________________________________________________
// UPDATE
//_________________________________________________________________
private async updateShift(
employee_id: number,
date_iso: string,
dto: UpsertShiftDto,
): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso);
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
if(!timesheet) throw new NotFoundException('Timesheet not found')
const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift, 'new_shift');
const old_bank_code = await this.typeResolver.findByType(old_norm_shift.type);
const new_bank_code = await this.typeResolver.findByType(new_norm_shift.type);
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
const existing = await this.helpersService.findExactOldShift(tx, {
timesheet_id: timesheet.id,
date_only,
norm: old_norm_shift,
bank_code_id: old_bank_code.id,
});
if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift, existing.id);
await tx.shifts.update({
where: { id: existing.id },
data: {
start_time: new_norm_shift.start_time,
end_time: new_norm_shift.end_time,
is_remote: new_norm_shift.is_remote,
comment: new_norm_shift.comment ?? null,
bank_code_id: new_bank_code.id,
},
});
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)};
});
}
//_________________________________________________________________
// DELETE
//_________________________________________________________________
async deleteShift(
email: string,
date_iso: string,
dto: UpsertShiftDto,
){
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso); //converts to Date format
const employee_id = await this.emailResolver.findIdByEmail(email);
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
if(!timesheet) throw new NotFoundException('Timesheet not found')
const norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
const bank_code_id = await this.typeResolver.findByType(norm_shift.type);
console.log('timesheet_id: ', timesheet.id );
console.log('date: ', date_only);
console.log('bank code id: ', bank_code_id.id);
console.log('normalized old shift: ', norm_shift);
const existing = await this.helpersService.findExactOldShift(tx, {
timesheet_id: timesheet.id,
date_only,
norm: norm_shift,
bank_code_id: bank_code_id.id,
});
if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
await tx.shifts.delete({ where: { id: existing.id } });
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
});
}
}

View File

@ -0,0 +1,56 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { GetShiftDto } from "../dtos/get-shift.dto";
import { toStringFromDate, toStringFromHHmm } from "../helpers/shifts-date-time-helpers";
@Injectable()
export class ShiftsGetService {
constructor(
private readonly prisma: PrismaService,
){}
//fetch a shift using shift_id and return all that shift's info
async getShiftByShiftId(shift_ids: number[]): Promise<GetShiftDto[]> {
if(!Array.isArray(shift_ids) || shift_ids.length === 0) return [];
const rows = await this.prisma.shifts.findMany({
where: { id: { in: shift_ids } },
select: {
id: true,
timesheet_id: true,
bank_code_id: true,
date: true,
start_time: true,
end_time: true,
is_remote: true,
is_approved: true,
comment: true,
}
});
if(rows.length !== shift_ids.length) {
const found_ids = new Set(rows.map(row => row.id));
const missing_ids = shift_ids.filter(id => !found_ids.has(id));
throw new NotFoundException(`Shift(s) not found: ${ missing_ids.join(", ")}`);
}
const row_by_id = new Map(rows.map(row => [row.id, row]));
return shift_ids.map((id) => {
const shift = row_by_id.get(id)!;
return {
timesheet_id: shift.timesheet_id,
bank_code_id: shift.bank_code_id,
date: toStringFromDate(shift.date),
start_time: toStringFromHHmm(shift.start_time),
end_time: toStringFromHHmm(shift.end_time),
is_remote: shift.is_remote,
is_approved: shift.is_approved,
comment: shift.comment ?? undefined,
} satisfies GetShiftDto;
});
}
}

View File

@ -1,114 +0,0 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { NotificationsService } from "src/modules/notifications/services/notifications.service";
import { computeHours } from "src/common/utils/date-utils";
import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
// const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12);
@Injectable()
export class ShiftsQueryService {
constructor(
private readonly prisma: PrismaService,
private readonly notifs: NotificationsService,
) {}
async getSummary(period_id: number): Promise<OverviewRow[]> {
//fetch pay-period to display
const period = await this.prisma.payPeriods.findFirst({
where: { pay_period_no: period_id },
});
if(!period) {
throw new NotFoundException(`pay-period ${period_id} not found`);
}
const { period_start, period_end } = period;
//prepare shifts and expenses for display
const shifts = await this.prisma.shifts.findMany({
where: { date: { gte: period_start, lte: period_end } },
include: {
bank_code: true,
timesheet: { include: {
employee: { include: {
user:true,
supervisor: { include: { user: true } },
} },
} },
},
});
const expenses = await this.prisma.expenses.findMany({
where: { date: { gte: period_start, lte: period_end } },
include: {
bank_code: true,
timesheet: { include: { employee: {
include: { user:true,
supervisor: { include: { user:true } },
} },
} },
},
});
const mapRow = new Map<string, OverviewRow>();
for(const shift of shifts) {
const employeeId = shift.timesheet.employee.user_id;
const user = shift.timesheet.employee.user;
const sup = shift.timesheet.employee.supervisor?.user;
let row = mapRow.get(employeeId);
if(!row) {
row = {
full_name: `${user.first_name} ${user.last_name}`,
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
total_regular_hrs: 0,
total_evening_hrs: 0,
total_overtime_hrs: 0,
total_expenses: 0,
total_mileage: 0,
is_approved: false,
};
}
const hours = computeHours(shift.start_time, shift.end_time);
switch(shift.bank_code.type) {
case 'regular' : row.total_regular_hrs += hours;
break;
case 'evening' : row.total_evening_hrs += hours;
break;
case 'overtime' : row.total_overtime_hrs += hours;
break;
default: row.total_regular_hrs += hours;
}
mapRow.set(employeeId, row);
}
for(const exp of expenses) {
const employee_id = exp.timesheet.employee.user_id;
const user = exp.timesheet.employee.user;
const sup = exp.timesheet.employee.supervisor?.user;
let row = mapRow.get(employee_id);
if(!row) {
row = {
full_name: `${user.first_name} ${user.last_name}`,
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
total_regular_hrs: 0,
total_evening_hrs: 0,
total_overtime_hrs: 0,
total_expenses: 0,
total_mileage: 0,
is_approved: false,
};
}
const amount = Number(exp.amount);
row.total_expenses += amount;
if(exp.bank_code.type === 'mileage') {
row.total_mileage += amount;
}
mapRow.set(employee_id, row);
}
//return by default the list of employee in ascending alphabetical order
return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name));
}
}

View File

@ -0,0 +1,367 @@
import { toDateFromString, toHHmmFromString, toStringFromDate, toStringFromHHmm } from "../helpers/shifts-date-time-helpers";
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import { OvertimeService, WeekOvertimeSummary } from "src/modules/business-logics/services/overtime.service";
import { updateShiftDto } from "../dtos/update-shift.dto";
import { PrismaService } from "src/prisma/prisma.service";
import { GetShiftDto } from "../dtos/get-shift.dto";
import { ShiftDto } from "../dtos/shift.dto";
type Normalized = { date: Date; start_time: Date; end_time: Date; };
export type ShiftWithOvertimeDto = {
shift: GetShiftDto;
overtime: WeekOvertimeSummary;
};
export type CreateResult = { ok: true; data: ShiftWithOvertimeDto } | { ok: false; error: any };
export type UpdatePayload = { id: number; dto: updateShiftDto };
export type UpdateResult = { ok: true; id: number; data: ShiftWithOvertimeDto } | { ok: false; id: number; error: any };
export type DeleteResult = { ok: true; id: number; overtime: WeekOvertimeSummary } | { ok: false; id: number; error: any };
type NormedOk = { index: number; dto: ShiftDto; normed: Normalized };
type NormedErr = { index: number; error: any };
const overlaps = (a: { start: Date; end: Date }, b: { start: Date; end: Date }) =>
!(a.end <= b.start || a.start >= b.end);
@Injectable()
export class ShiftsUpsertService {
constructor(
private readonly prisma: PrismaService,
private readonly overtime: OvertimeService,
) { }
//_________________________________________________________________
// CREATE
//_________________________________________________________________
//normalized frontend data to match DB
//loads all shifts from a selected day to check for overlaping shifts
//checks for overlaping shifts
//create new shifts
//calculate overtime
async createShifts(timesheet_id: number, dtos: ShiftDto[]): Promise<CreateResult[]> {
if (!Array.isArray(dtos) || dtos.length === 0) return [];
const normed_shift: Array<NormedOk | NormedErr> = dtos.map((dto, index) => {
try {
const normed = this.normalizeShiftDto(dto);
if (normed.end_time <= normed.start_time) {
return { index, error: new BadRequestException(`end_time must be greater than start_time (index ${index})`) };
}
return { index, dto, normed };
} catch (error) {
return { index, error };
}
});
const ok_items = normed_shift.filter((x): x is NormedOk => "normed" in x);
const regroup_by_date = new Map<number, number[]>();
ok_items.forEach(({ index, normed }) => {
const d = normed.date;
const key = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
if (!regroup_by_date.has(key)) regroup_by_date.set(key, []);
regroup_by_date.get(key)!.push(index);
});
for (const indices of regroup_by_date.values()) {
const ordered = indices
.map(index => {
const item = normed_shift[index] as NormedOk;
return { index: index, start: item.normed.start_time, end: item.normed.end_time };
})
.sort((a, b) => a.start.getTime() - b.start.getTime());
for (let j = 1; j < ordered.length; j++) {
if (overlaps({ start: ordered[j - 1].start, end: ordered[j - 1].end }, { start: ordered[j].start, end: ordered[j].end })) {
const err = new ConflictException({
error_code: 'SHIFT_OVERLAP_BATCH',
message: 'New shift overlaps with another shift in the same batch (same day).',
});
return dtos.map((_dto, key) =>
indices.includes(key)
? ({ ok: false, error: err } as CreateResult)
: ({ ok: false, error: new BadRequestException('Batch aborted due to overlaps in another date group') })
);
}
}
}
return this.prisma.$transaction(async (tx) => {
const results: CreateResult[] = Array.from({ length: dtos.length }, () => ({ ok: false, error: new Error('uninitialized') }));
normed_shift.forEach((x, i) => {
if ("error" in x) results[i] = { ok: false, error: x.error };
});
const unique_dates = Array.from(regroup_by_date.keys()).map(ms => new Date(ms));
const existing_date = new Map<number, { start_time: Date; end_time: Date }[]>();
for (const d of unique_dates) {
const rows = await tx.shifts.findMany({
where: { timesheet_id, date: d },
select: { start_time: true, end_time: true },
});
existing_date.set(d.getTime(), rows.map(r => ({ start_time: r.start_time, end_time: r.end_time })));
}
for (const item of ok_items) {
const { index, dto, normed } = item;
const dayKey = new Date(normed.date.getFullYear(), normed.date.getMonth(), normed.date.getDate()).getTime();
const existing = existing_date.get(dayKey) ?? [];
const hit = existing.find(e => overlaps({ start: e.start_time, end: e.end_time }, { start: normed.start_time, end: normed.end_time }));
if (hit) {
results[index] = {
ok: false,
error: new ConflictException({
error_code: 'SHIFT_OVERLAP',
message: 'New shift overlaps with existing shift(s)',
conflicts: [{
start_time: toStringFromHHmm(hit.start_time),
end_time: toStringFromHHmm(hit.end_time),
type: 'UNKNOWN',
}],
}),
};
continue;
}
const row = await tx.shifts.create({
data: {
timesheet_id,
bank_code_id: dto.bank_code_id,
date: normed.date,
start_time: normed.start_time,
end_time: normed.end_time,
is_remote: dto.is_remote,
comment: dto.comment ?? undefined,
},
select: {
timesheet_id: true, bank_code_id: true, date: true,
start_time: true, end_time: true, is_remote: true, is_approved: true, comment: true,
},
});
existing.push({ start_time: row.start_time, end_time: row.end_time });
const summary = await this.overtime.getWeekOvertimeSummary(timesheet_id, normed.date, tx);
const shift: GetShiftDto = {
timesheet_id: row.timesheet_id,
bank_code_id: row.bank_code_id,
date: toStringFromDate(row.date),
start_time: toStringFromHHmm(row.start_time),
end_time: toStringFromHHmm(row.end_time),
is_remote: row.is_remote,
is_approved: false,
comment: row.comment ?? undefined,
};
results[index] = { ok: true, data: { shift, overtime: summary } };
}
return results;
});
}
//_________________________________________________________________
// UPDATE
//_________________________________________________________________
// finds existing shifts in DB
// verify if shifts are already approved
// normalized Date and Time format to string
// check for valid start and end times
// check for overlaping possibility
// buil a set of data to manipulate modified data only
// update shifts in DB
// recalculate overtime after update
// return an updated version to display
async updateShifts(updates: UpdatePayload[]): Promise<UpdateResult[]> {
if (!Array.isArray(updates) || updates.length === 0) return [];
return this.prisma.$transaction(async (tx) => {
const shift_ids = updates.map(update_shift => update_shift.id);
const rows = await tx.shifts.findMany({
where: { id: { in: shift_ids } },
select: {
id: true,
timesheet_id: true,
bank_code_id: true,
date: true,
start_time: true,
end_time: true,
is_remote: true,
is_approved: true,
comment: true,
},
});
const regroup_id = new Map(rows.map(r => [r.id, r]));
for (const update of updates) {
const existing = regroup_id.get(update.id);
if (!existing) {
return updates.map(exist => exist.id === update.id
? ({ ok: false, id: update.id, error: new NotFoundException(`Shift with id: ${update.id} not found`) } as UpdateResult)
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to missing shift') }));
}
if (existing.is_approved) {
return updates.map(exist => exist.id === update.id
? ({ ok: false, id: update.id, error: new BadRequestException('Approved shift cannot be updated') } as UpdateResult)
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to approved shift in update set') }));
}
}
const planned_updates = updates.map(update => {
const exist_shift = regroup_id.get(update.id)!;
const date_string = update.dto.date ?? toStringFromDate(exist_shift.date);
const start_string = update.dto.start_time ?? toStringFromHHmm(exist_shift.start_time);
const end_string = update.dto.end_time ?? toStringFromHHmm(exist_shift.end_time);
const normed: Normalized = {
date: toDateFromString(date_string),
start_time: toHHmmFromString(start_string),
end_time: toHHmmFromString(end_string),
};
return { update, exist_shift, normed };
});
const groups = new Map<string, { existing: { start: Date; end: Date; id: number }[], incoming: typeof planned_updates }>();
function key(timesheet: number, d: Date) {
const day_date = new Date(d.getFullYear(), d.getMonth(), d.getDate());
return `${timesheet}|${day_date.getTime()}`;
}
const unique_pairs = new Map<string, { timesheet_id: number; date: Date }>();
for (const { exist_shift, normed } of planned_updates) {
unique_pairs.set(key(exist_shift.timesheet_id, exist_shift.date), { timesheet_id: exist_shift.timesheet_id, date: exist_shift.date });
unique_pairs.set(key(exist_shift.timesheet_id, normed.date), { timesheet_id: exist_shift.timesheet_id, date: normed.date });
}
for (const group of unique_pairs.values()) {
const day_date = new Date(group.date.getFullYear(), group.date.getMonth(), group.date.getDate());
const existing = await tx.shifts.findMany({
where: { timesheet_id: group.timesheet_id, date: day_date },
select: { id: true, start_time: true, end_time: true },
});
groups.set(key(group.timesheet_id, day_date), { existing: existing.map(row => ({ id: row.id, start: row.start_time, end: row.end_time })), incoming: planned_updates });
}
for (const planned of planned_updates) {
const keys = key(planned.exist_shift.timesheet_id, planned.normed.date);
const group = groups.get(keys)!;
const conflict = group.existing.find(row =>
row.id !== planned.exist_shift.id && overlaps({ start: row.start, end: row.end }, { start: planned.normed.start_time, end: planned.normed.end_time })
);
if (conflict) {
return updates.map(exist =>
exist.id === planned.exist_shift.id
? ({
ok: false, id: exist.id, error: new ConflictException({
error_code: 'SHIFT_OVERLAP',
message: 'New shift overlaps with existing shift(s)',
conflicts: [{ start_time: toStringFromHHmm(conflict.start), end_time: toStringFromHHmm(conflict.end), type: 'UNKNOWN' }],
})
} as UpdateResult)
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to overlap in another update') })
);
}
}
const regoup_by_day = new Map<string, { id: number; start: Date; end: Date }[]>();
for (const planned of planned_updates) {
const keys = key(planned.exist_shift.timesheet_id, planned.normed.date);
if (!regoup_by_day.has(keys)) regoup_by_day.set(keys, []);
regoup_by_day.get(keys)!.push({ id: planned.exist_shift.id, start: planned.normed.start_time, end: planned.normed.end_time });
}
for (const arr of regoup_by_day.values()) {
arr.sort((a, b) => a.start.getTime() - b.start.getTime());
for (let i = 1; i < arr.length; i++) {
if (overlaps({ start: arr[i - 1].start, end: arr[i - 1].end }, { start: arr[i].start, end: arr[i].end })) {
const error = new ConflictException({ error_code: 'SHIFT_OVERLAP_BATCH', message: 'Overlaps between updates within the same day.' });
return updates.map(exist => ({ ok: false, id: exist.id, error: error }));
}
}
}
const results: UpdateResult[] = [];
for (const planned of planned_updates) {
const data: any = {};
const { dto } = planned.update;
if (dto.date !== undefined) data.date = planned.normed.date;
if (dto.start_time !== undefined) data.start_time = planned.normed.start_time;
if (dto.end_time !== undefined) data.end_time = planned.normed.end_time;
if (dto.bank_code_id !== undefined) data.bank_code_id = dto.bank_code_id;
if (dto.is_remote !== undefined) data.is_remote = dto.is_remote;
if (dto.comment !== undefined) data.comment = dto.comment ?? null;
const row = await tx.shifts.update({
where: { id: planned.exist_shift.id },
data,
select: {
timesheet_id: true,
bank_code_id: true,
date: true,
start_time: true,
end_time: true,
is_remote: true,
is_approved: true,
comment: true,
},
});
const summary_new = await this.overtime.getWeekOvertimeSummary(row.timesheet_id, planned.exist_shift.date, tx);
if (row.date.getTime() !== planned.exist_shift.date.getTime()) {
await this.overtime.getWeekOvertimeSummary(row.timesheet_id, row.date, tx);
}
const shift: GetShiftDto = {
timesheet_id: row.timesheet_id,
bank_code_id: row.bank_code_id,
date: toStringFromDate(row.date),
start_time: toStringFromHHmm(row.start_time),
end_time: toStringFromHHmm(row.end_time),
is_approved: row.is_approved,
is_remote: row.is_remote,
comment: row.comment ?? undefined,
};
results.push({ ok: true, id: planned.exist_shift.id, data: { shift, overtime: summary_new } });
}
return results;
});
}
//_________________________________________________________________
// DELETE
//_________________________________________________________________
//finds shift using shit_ids
//recalc overtime shifts after delete
//blocs deletion if approved
async deleteShift(shift_id: number) {
return await this.prisma.$transaction(async (tx) =>{
const shift = await tx.shifts.findUnique({
where: { id: shift_id },
select: { id: true, date: true, timesheet_id: true },
});
if(!shift) throw new NotFoundException(`Shift with id #${shift_id} not found`);
await tx.shifts.delete({ where: { id: shift_id } });
const summary = await this.overtime.getWeekOvertimeSummary( shift.timesheet_id, shift.date, tx);
return {
success: true,
overtime: summary
};
});
}
//_________________________________________________________________
// LOCAL HELPERS
//_________________________________________________________________
//converts all string hours and date to Date and HHmm formats
private normalizeShiftDto = (dto: ShiftDto): Normalized => {
const date = toDateFromString(dto.date);
const start_time = toHHmmFromString(dto.start_time);
const end_time = toHHmmFromString(dto.end_time);
return { date, start_time, end_time };
}
}

View File

@ -1,12 +1,11 @@
import { Module } from '@nestjs/common';
import { ShiftsController } from './controllers/shifts.controller';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { ShiftsCommandService } from './services/shifts-command.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { ShiftsQueryService } from './services/shifts-query.service';
import { ShiftsArchivalService } from './services/shifts-archival.service';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { ShiftsUpsertService } from './services/shifts-upsert.service';
import { ShiftsGetService } from './services/shifts-get.service';
import { ShiftController } from './controllers/shift.controller';
import { SharedModule } from '../shared/shared.module';
import { ShiftsHelpersService } from './helpers/shifts.helpers';
import { Module } from '@nestjs/common';
@Module({
imports: [
@ -14,17 +13,12 @@ import { ShiftsHelpersService } from './helpers/shifts.helpers';
NotificationsModule,
SharedModule,
],
controllers: [ShiftsController],
controllers: [ShiftController],
providers: [
ShiftsQueryService,
ShiftsCommandService,
ShiftsArchivalService,
ShiftsHelpersService,
],
exports: [
ShiftsQueryService,
ShiftsCommandService,
ShiftsArchivalService,
ShiftsGetService,
ShiftsUpsertService,
],
exports: [ ShiftsUpsertService, ShiftsGetService ],
})
export class ShiftsModule {}

View File

@ -1,10 +0,0 @@
export interface OverviewRow {
full_name: string;
supervisor: string;
total_regular_hrs: number;
total_evening_hrs: number;
total_overtime_hrs: number;
total_expenses: number;
total_mileage: number;
is_approved: boolean;
}

View File

@ -1,17 +0,0 @@
export type DayShiftResponse = {
start_time: string;
end_time: string;
type: string;
is_remote: boolean;
comment: string | null;
}
export type ShiftPayload = {
date: string;
start_time: string;
end_time: string;
type: string;
is_remote: boolean;
is_approved: boolean;
comment?: string | null;
}

View File

@ -1,58 +0,0 @@
import { NotFoundException } from "@nestjs/common";
export function 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;
}
export function resolveBankCodeByType(type: string): Promise<number> {
const bank = 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;
}
export function normalizeShiftPayload(payload: {
date: string,
start_time: string,
end_time: string,
type: string,
is_remote: boolean,
is_approved: boolean,
comment?: string | null,
}) {
//normalize shift's infos
const date = payload.date?.trim();
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date ?? '');
if (!m) throw new Error(`Invalid date format (expected YYYY-MM-DD): "${payload.date}"`);
const year = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
const asLocalDateOn = (input: string): Date => {
// HH:mm ?
const hm = /^(\d{2}):(\d{2})$/.exec((input ?? '').trim());
if (hm) return new Date(Date.UTC(1970, 0, 1, Number(hm[1]), Number(hm[2])));
const iso = new Date(input);
if (isNaN(iso.getTime())) throw new Error(`Invalid time: "${input}"`);
return new Date(Date.UTC(1970, 0, 1, iso.getHours(), iso.getMinutes(), iso.getSeconds()));
};
const start_time = asLocalDateOn(payload.start_time);
const end_time = asLocalDateOn(payload.end_time);
const type = (payload.type || '').trim().toUpperCase();
const is_remote = payload.is_remote;
const is_approved = payload.is_approved;
//normalize comment
const trimmed = typeof payload.comment === 'string' ? payload.comment.trim() : null;
const comment = trimmed && trimmed.length > 0 ? trimmed : null;
return { date, start_time, end_time, type, is_remote, is_approved, comment };
}

View File

@ -0,0 +1,10 @@
// import { Type } from "class-transformer";
// import { IsInt, Min, Max } from "class-validator";
// export class GetShiftsOverviewDto {
// @Type(()=> Number)
// @IsInt()
// @Min(1)
// @Max(26)
// period_id: number;
// }

View File

@ -0,0 +1,194 @@
// import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
// import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
// import { Prisma, Shifts } from "@prisma/client";
// import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
// import { BaseApprovalService } from "src/common/shared/base-approval.service";
// import { PrismaService } from "src/prisma/prisma.service";
// import { toDateOnly } from "../helpers/shifts-date-time-helpers";
// import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
// import { ShiftsHelpersService } from "../helpers/shifts.helpers";
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
// @Injectable()
// export class ShiftsCommandService extends BaseApprovalService<Shifts> {
// private readonly logger = new Logger(ShiftsCommandService.name);
// constructor(
// prisma: PrismaService,
// private readonly emailResolver: EmailToIdResolver,
// private readonly typeResolver: BankCodesResolver,
// private readonly helpersService: ShiftsHelpersService,
// ) { super(prisma); }
// //_____________________________________________________________________________________________
// // APPROVAL AND DELEGATE METHODS
// //_____________________________________________________________________________________________
// protected get delegate() {
// return this.prisma.shifts;
// }
// protected delegateFor(transaction: Prisma.TransactionClient) {
// return transaction.shifts;
// }
// async updateApproval(id: number, is_approved: boolean): Promise<Shifts> {
// return this.prisma.$transaction((transaction) =>
// this.updateApprovalWithTransaction(transaction, id, is_approved),
// );
// }
// //TODO: modifier le Master Crud pour recevoir l'ensemble des shifts de la pay-period et trier sur l'action 'create'| 'update' | 'delete'
// //_____________________________________________________________________________________________
// // MASTER CRUD METHOD
// //_____________________________________________________________________________________________
// async upsertShifts(
// email: string,
// action: UpsertAction,
// dto: UpsertShiftDto,
// ): Promise<{
// action: UpsertAction;
// day: DayShiftResponse[];
// }> {
// if (!dto.old_shift && !dto.new_shift) throw new BadRequestException('At least one of old or new shift must be provided');
// const date = dto.new_shift?.date ?? dto.old_shift?.date;
// if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift");
// if (dto.old_shift?.date && dto.new_shift?.date && dto.old_shift.date !== dto.new_shift.date) {
// throw new BadRequestException('old_shift.date and new_shift.date must be identical');
// }
// const employee_id = await this.emailResolver.findIdByEmail(email);//resolve employee id using email
// if(action === 'create') {
// if(!dto.new_shift || dto.old_shift) {
// throw new BadRequestException(`Only new_shift must be provided for create`);
// }
// return this.createShift(employee_id, date, dto);
// }
// if(action === 'update'){
// if(!dto.old_shift || !dto.new_shift) {
// throw new BadRequestException(`Both new_shift and old_shift must be provided for update`);
// }
// return this.updateShift(employee_id, date, dto);
// }
// throw new BadRequestException(`Unknown action: ${action}`);
// }
// //_________________________________________________________________
// // CREATE
// //_________________________________________________________________
// private async createShift(
// employee_id: number,
// date_iso: string,
// dto: UpsertShiftDto,
// ): Promise<{action: UpsertAction; day: DayShiftResponse[]}> {
// return this.prisma.$transaction(async (tx) => {
// const date_only = toDateOnly(date_iso);
// const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
// if(!timesheet) throw new NotFoundException('Timesheet not found')
// const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift);
// const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift');
// const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
// await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift);
// await tx.shifts.create({
// data: {
// timesheet_id: timesheet.id,
// date: date_only,
// start_time: new_norm_shift.start_time,
// end_time: new_norm_shift.end_time,
// is_remote: new_norm_shift.is_remote,
// is_approved: new_norm_shift.is_approved,
// comment: new_norm_shift.comment ?? null,
// bank_code_id: new_bank_code_id,
// },
// });
// await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
// const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
// return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)};
// });
// }
// //_________________________________________________________________
// // UPDATE
// //_________________________________________________________________
// private async updateShift(
// employee_id: number,
// date_iso: string,
// dto: UpsertShiftDto,
// ): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{
// return this.prisma.$transaction(async (tx) => {
// const date_only = toDateOnly(date_iso);
// const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
// if(!timesheet) throw new NotFoundException('Timesheet not found')
// const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
// const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift, 'new_shift');
// const old_bank_code = await this.typeResolver.findByType(old_norm_shift.type);
// const new_bank_code = await this.typeResolver.findByType(new_norm_shift.type);
// const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
// const existing = await this.helpersService.findExactOldShift(tx, {
// timesheet_id: timesheet.id,
// date_only,
// norm: old_norm_shift,
// bank_code_id: old_bank_code.id,
// });
// if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
// await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift, existing.id);
// await tx.shifts.update({
// where: { id: existing.id },
// data: {
// start_time: new_norm_shift.start_time,
// end_time: new_norm_shift.end_time,
// is_remote: new_norm_shift.is_remote,
// comment: new_norm_shift.comment ?? null,
// bank_code_id: new_bank_code.id,
// },
// });
// await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
// const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
// return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)};
// });
// }
// //_________________________________________________________________
// // DELETE
// //_________________________________________________________________
// async deleteShift(
// email: string,
// date_iso: string,
// dto: UpsertShiftDto,
// ){
// return this.prisma.$transaction(async (tx) => {
// const date_only = toDateOnly(date_iso); //converts to Date format
// const employee_id = await this.emailResolver.findIdByEmail(email);
// const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
// if(!timesheet) throw new NotFoundException('Timesheet not found')
// const norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
// const bank_code_id = await this.typeResolver.findByType(norm_shift.type);
// const existing = await this.helpersService.findExactOldShift(tx, {
// timesheet_id: timesheet.id,
// date_only,
// norm: norm_shift,
// bank_code_id: bank_code_id.id,
// });
// if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
// await tx.shifts.delete({ where: { id: existing.id } });
// await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
// });
// }
// }

View File

@ -0,0 +1,10 @@
// export interface OverviewRow {
// full_name: string;
// supervisor: string;
// total_regular_hrs: number;
// total_evening_hrs: number;
// total_overtime_hrs: number;
// total_expenses: number;
// total_mileage: number;
// is_approved: boolean;
// }

View File

@ -0,0 +1,114 @@
// import { Injectable, NotFoundException } from "@nestjs/common";
// import { PrismaService } from "src/prisma/prisma.service";
// import { NotificationsService } from "src/modules/notifications/services/notifications.service";
// import { computeHours } from "src/common/utils/date-utils";
// import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
// // const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12);
// @Injectable()
// export class ShiftsQueryService {
// constructor(
// private readonly prisma: PrismaService,
// private readonly notifs: NotificationsService,
// ) {}
// async getSummary(period_id: number): Promise<OverviewRow[]> {
// //fetch pay-period to display
// const period = await this.prisma.payPeriods.findFirst({
// where: { pay_period_no: period_id },
// });
// if(!period) {
// throw new NotFoundException(`pay-period ${period_id} not found`);
// }
// const { period_start, period_end } = period;
// //prepare shifts and expenses for display
// const shifts = await this.prisma.shifts.findMany({
// where: { date: { gte: period_start, lte: period_end } },
// include: {
// bank_code: true,
// timesheet: { include: {
// employee: { include: {
// user:true,
// supervisor: { include: { user: true } },
// } },
// } },
// },
// });
// const expenses = await this.prisma.expenses.findMany({
// where: { date: { gte: period_start, lte: period_end } },
// include: {
// bank_code: true,
// timesheet: { include: { employee: {
// include: { user:true,
// supervisor: { include: { user:true } },
// } },
// } },
// },
// });
// const mapRow = new Map<string, OverviewRow>();
// for(const shift of shifts) {
// const employeeId = shift.timesheet.employee.user_id;
// const user = shift.timesheet.employee.user;
// const sup = shift.timesheet.employee.supervisor?.user;
// let row = mapRow.get(employeeId);
// if(!row) {
// row = {
// full_name: `${user.first_name} ${user.last_name}`,
// supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
// total_regular_hrs: 0,
// total_evening_hrs: 0,
// total_overtime_hrs: 0,
// total_expenses: 0,
// total_mileage: 0,
// is_approved: false,
// };
// }
// const hours = computeHours(shift.start_time, shift.end_time);
// switch(shift.bank_code.type) {
// case 'regular' : row.total_regular_hrs += hours;
// break;
// case 'evening' : row.total_evening_hrs += hours;
// break;
// case 'overtime' : row.total_overtime_hrs += hours;
// break;
// default: row.total_regular_hrs += hours;
// }
// mapRow.set(employeeId, row);
// }
// for(const exp of expenses) {
// const employee_id = exp.timesheet.employee.user_id;
// const user = exp.timesheet.employee.user;
// const sup = exp.timesheet.employee.supervisor?.user;
// let row = mapRow.get(employee_id);
// if(!row) {
// row = {
// full_name: `${user.first_name} ${user.last_name}`,
// supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
// total_regular_hrs: 0,
// total_evening_hrs: 0,
// total_overtime_hrs: 0,
// total_expenses: 0,
// total_mileage: 0,
// is_approved: false,
// };
// }
// const amount = Number(exp.amount);
// row.total_expenses += amount;
// if(exp.bank_code.type === 'mileage') {
// row.total_mileage += amount;
// }
// mapRow.set(employee_id, row);
// }
// //return by default the list of employee in ascending alphabetical order
// return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name));
// }
// }

View File

@ -0,0 +1,17 @@
// export type DayShiftResponse = {
// start_time: string;
// end_time: string;
// type: string;
// is_remote: boolean;
// comment: string | null;
// }
// export type ShiftPayload = {
// date: string;
// start_time: string;
// end_time: string;
// type: string;
// is_remote: boolean;
// is_approved: boolean;
// comment?: string | null;
// }

View File

@ -0,0 +1,87 @@
// import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common";
// import { RolesAllowed } from "src/common/decorators/roles.decorators";
// import { Roles as RoleEnum } from '.prisma/client';
// import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
// import { ShiftsCommandService } from "../services/shifts-command.service";
// import { ShiftsQueryService } from "../services/shifts-query.service";
// import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
// import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto";
// import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
// import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
// @ApiTags('Shifts')
// @ApiBearerAuth('access-token')
// // @UseGuards()
// @Controller('shifts')
// export class ShiftsController {
// constructor(
// private readonly shiftsService: ShiftsQueryService,
// private readonly shiftsCommandService: ShiftsCommandService,
// ){}
// @Put('upsert/:email')
// async upsert_by_date(
// @Param('email') email_param: string,
// @Query('action') action: UpsertAction,
// @Body() payload: UpsertShiftDto,
// ) {
// return this.shiftsCommandService.upsertShifts(email_param, action, payload);
// }
// @Delete('delete/:email/:date')
// async remove(
// @Param('email') email: string,
// @Param('date') date: string,
// @Body() payload: UpsertShiftDto,
// ) {
// return this.shiftsCommandService.deleteShift(email, date, payload);
// }
// @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.shiftsCommandService.updateApproval(id, isApproved);
// }
// @Get('summary')
// async getSummary( @Query() query: GetShiftsOverviewDto): Promise<OverviewRow[]> {
// return this.shiftsService.getSummary(query.period_id);
// }
// @Get('export.csv')
// @Header('Content-Type', 'text/csv; charset=utf-8')
// @Header('Content-Disposition', 'attachment; filename="shifts-validation.csv"')
// async exportCsv(@Query() query: GetShiftsOverviewDto): Promise<Buffer>{
// const rows = await this.shiftsService.getSummary(query.period_id);
// //CSV Headers
// const header = [
// 'full_name',
// 'supervisor',
// 'total_regular_hrs',
// 'total_evening_hrs',
// 'total_overtime_hrs',
// 'total_expenses',
// 'total_mileage',
// 'is_validated'
// ].join(',') + '\n';
// //CSV rows
// const body = rows.map(r => {
// const esc = (str: string) => `"${str.replace(/"/g, '""')}"`;
// return [
// esc(r.full_name),
// esc(r.supervisor),
// r.total_regular_hrs.toFixed(2),
// r.total_evening_hrs.toFixed(2),
// r.total_overtime_hrs.toFixed(2),
// r.total_expenses.toFixed(2),
// r.total_mileage.toFixed(2),
// r.is_approved,
// ].join(',');
// }).join('\n');
// return Buffer.from('\uFEFF' + header + body, 'utf8');
// }
// }

View File

@ -0,0 +1,103 @@
// import { BadRequestException, UnprocessableEntityException, NotFoundException, ConflictException } from "@nestjs/common";
// import { Prisma, Shifts } from "@prisma/client";
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
// import { OvertimeService } from "src/modules/business-logics/services/overtime.service";
// export type Tx = Prisma.TransactionClient;
// export type Normalized = Awaited<ReturnType<typeof normalizeShiftPayload>>;
// export class ShiftsHelpersService {
// constructor(
// private readonly bankTypeResolver: BankCodesResolver,
// private readonly overtimeService: OvertimeService,
// ) { }
// async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) {
// const start_of_week = weekStartSunday(date_only);
// return tx.timesheets.findUnique({
// where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
// select: { id: true },
// });
// }
// async normalizeRequired(
// raw: UpsertShiftDto['new_shift'] | UpsertShiftDto['old_shift'] | undefined | null,
// label: 'old_shift' | 'new_shift' = 'new_shift',
// ): Promise<Normalized> {
// if (!raw) throw new BadRequestException(`${label} is required`);
// const norm = await normalizeShiftPayload(raw);
// if (norm.end_time.getTime() <= norm.start_time.getTime()) {
// throw new UnprocessableEntityException(` ${label}.end_time must be > ${label}.start_time`);
// }
// return norm;
// }
// async resolveBankIdRequired(tx: Tx, type: string, label: 'old_shift' | 'new_shift'): Promise<number> {
// const found = await this.bankTypeResolver.findByType(type, tx);
// const id = found?.id;
// if (typeof id !== 'number') {
// throw new NotFoundException(`bank code not found for ${label}.type: ${type ?? ''}`);
// }
// return id;
// }
// async getDayShifts(tx: Tx, timesheet_id: number, date_only: Date) {
// return tx.shifts.findMany({
// where: { timesheet_id, date: date_only },
// include: { bank_code: true },
// orderBy: { start_time: 'asc' },
// });
// }
// async findExactOldShift(
// tx: Tx,
// params: {
// timesheet_id: number;
// date_only: Date;
// norm: Normalized;
// bank_code_id: number;
// comment?: string;
// },
// ) {
// const { timesheet_id, date_only, norm, bank_code_id } = params;
// return tx.shifts.findFirst({
// where: {
// timesheet_id,
// date: date_only,
// start_time: norm.start_time,
// end_time: norm.end_time,
// is_remote: norm.is_remote,
// is_approved: norm.is_approved,
// comment: norm.comment ?? null,
// bank_code_id,
// },
// select: { id: true },
// });
// }
// async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date) {
// // Switch regular → weekly overtime si > 40h
// await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx);
// const daily = await this.overtimeService.getDailyOvertimeHours(employee_id, date_only);
// const weekly = await this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only);
// // const [daily, weekly] = await Promise.all([
// // this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only),
// // this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only),
// // ]);
// return { daily, weekly };
// }
// async mapDay(
// fresh: Array<Shifts & { bank_code: { type: string } | null }>,
// ): Promise<DayShiftResponse[]> {
// return fresh.map((s) => ({
// start_time: toStringFromHHmm(s.start_time),
// end_time: toStringFromHHmm(s.end_time),
// type: s.bank_code?.type ?? 'UNKNOWN',
// is_remote: s.is_remote,
// comment: s.comment ?? null,
// }));
// }
// }

View File

@ -0,0 +1,58 @@
// import { NotFoundException } from "@nestjs/common";
// export function 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;
// }
// export function resolveBankCodeByType(type: string): Promise<number> {
// const bank = 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;
// }
// export function normalizeShiftPayload(payload: {
// date: string,
// start_time: string,
// end_time: string,
// type: string,
// is_remote: boolean,
// is_approved: boolean,
// comment?: string | null,
// }) {
// //normalize shift's infos
// const date = payload.date?.trim();
// const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date ?? '');
// if (!m) throw new Error(`Invalid date format (expected YYYY-MM-DD): "${payload.date}"`);
// const year = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
// const asLocalDateOn = (input: string): Date => {
// // HH:mm ?
// const hm = /^(\d{2}):(\d{2})$/.exec((input ?? '').trim());
// if (hm) return new Date(year, mo - 1, d, Number(hm[1]), Number(hm[2]), 0, 0);
// const iso = new Date(input);
// if (isNaN(iso.getTime())) throw new Error(`Invalid time: "${input}"`);
// return new Date(year, mo - 1, d, iso.getHours(), iso.getMinutes(), iso.getSeconds(), iso.getMilliseconds());
// };
// const start_time = asLocalDateOn(payload.start_time);
// const end_time = asLocalDateOn(payload.end_time);
// const type = (payload.type || '').trim().toUpperCase();
// const is_remote = payload.is_remote;
// const is_approved = payload.is_approved;
// //normalize comment
// const trimmed = typeof payload.comment === 'string' ? payload.comment.trim() : null;
// const comment = trimmed && trimmed.length > 0 ? trimmed : null;
// return { date, start_time, end_time, type, is_remote, is_approved, comment };
// }

View File

@ -0,0 +1,43 @@
// import { Type } from "class-transformer";
// import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
// export const COMMENT_MAX_LENGTH = 280;
// export class ShiftPayloadDto {
// @Matches(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/)
// date!: string;
// @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;
// @IsBoolean()
// is_approved!: 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;
// };

View File

@ -0,0 +1,17 @@
import { GetTimesheetsOverviewService } from "../services/timesheet-get-overview.service";
import { Controller, Get, Query} from "@nestjs/common";
@Controller('timesheets')
export class TimesheetController {
constructor(private readonly timesheetOverview: GetTimesheetsOverviewService){}
@Get()
async getTimesheetByIds(
@Query('timesheet_ids') timesheet_ids: string ) {
const parsed = timesheet_ids.split(/,\s*/).map(value => Number(value)).filter(Number.isFinite);
return this.timesheetOverview.getTimesheetsByIds(parsed);
}
}

View File

@ -1,51 +0,0 @@
import { BadRequestException, Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { TimesheetsQueryService } from '../services/timesheets-query.service';
import { CreateWeekShiftsDto } from '../dtos/create-timesheet.dto';
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { TimesheetsCommandService } from '../services/timesheets-command.service';
import { TimesheetMap } from '../utils-helpers-others/timesheet.types';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
@ApiTags('Timesheets')
@ApiBearerAuth('access-token')
// @UseGuards()
@Controller('timesheets')
export class TimesheetsController {
constructor(
private readonly timesheetsQuery: TimesheetsQueryService,
private readonly timesheetsCommand: TimesheetsCommandService,
) {}
@Get()
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
async getPeriodByQuery(
@Query('year', ParseIntPipe ) year: number,
@Query('period_no', ParseIntPipe ) period_no: number,
@Query('email') email?: string
): Promise<TimesheetPeriodDto> {
if(!email || !(email = email.trim())) throw new BadRequestException('Query param "email" is mandatory for this route.');
return this.timesheetsQuery.findAll(year, period_no, email);
}
@Get('/:email')
async getByEmail(
@Param('email') email: string,
@Query('offset') offset?: string,
): Promise<TimesheetMap> {
const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0;
return this.timesheetsQuery.getTimesheetByEmail(email, week_offset);
}
@Post('shifts/:email')
async createTimesheetShifts(
@Param('email') email: string,
@Body() dto: CreateWeekShiftsDto,
@Query('offset') offset?: string,
): Promise<TimesheetMap> {
const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0;
return this.timesheetsCommand.createWeekShiftsAndReturnOverview(email, dto.shifts, week_offset);
}
}

View File

@ -1,33 +0,0 @@
import { Type } from "class-transformer";
import { IsArray, IsOptional, IsString, Length, Matches, ValidateNested } from "class-validator";
export class CreateTimesheetDto {
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
date!: string;
@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)
comment?: string;
}
export class CreateWeekShiftsDto {
@IsArray()
@ValidateNested({each:true})
@Type(()=> CreateTimesheetDto)
shifts!: CreateTimesheetDto[];
}

View File

@ -1,20 +0,0 @@
import { Type } from "class-transformer";
import { IsBoolean, IsInt, IsOptional } from "class-validator";
export class SearchTimesheetDto {
@IsOptional()
@Type(() => Number)
@IsInt()
timesheet_id?: number;
@IsOptional()
@Type(()=> Number)
@IsInt()
employee_id?: number;
@IsOptional()
@Type(()=> Boolean)
@IsBoolean()
is_approved?: boolean;
}

View File

@ -1,75 +0,0 @@
export class TimesheetDto {
start_day: string;
end_day: string;
label: string;
shifts: ShiftDto[];
expenses: ExpenseDto[]
is_approved: boolean;
}
export class ShiftDto {
date: string;
type: string;
start_time: string;
end_time : string;
comment: string;
is_approved: boolean;
is_remote: boolean;
}
export class ExpenseDto {
type: string;
amount: number;
mileage: number;
comment: string;
is_approved: boolean;
supervisor_comment: string;
}
export type DayShiftsDto = ShiftDto[];
export class DetailedShifts {
shifts: DayShiftsDto;
regular_hours: number;
evening_hours: number;
overtime_hours: number;
emergency_hours: number;
comment: string;
short_date: string;
break_durations?: number;
}
export class DayExpensesDto {
expenses: ExpenseDto[] = [];
total_mileage: number;
total_expense: number;
}
export class WeekDto {
is_approved: boolean;
shifts: {
sun: DetailedShifts;
mon: DetailedShifts;
tue: DetailedShifts;
wed: DetailedShifts;
thu: DetailedShifts;
fri: DetailedShifts;
sat: DetailedShifts;
}
expenses: {
sun: DayExpensesDto;
mon: DayExpensesDto;
tue: DayExpensesDto;
wed: DayExpensesDto;
thu: DayExpensesDto;
fri: DayExpensesDto;
sat: DayExpensesDto;
}
}
export class TimesheetPeriodDto {
weeks: WeekDto[];
employee_full_name: string;
}

View File

@ -1,11 +1,11 @@
export class Session {
user_id: number;
export class Timesheets {
employee_fullname: string;
timesheets: Timesheet[];
}
export class Timesheets {
export class Timesheet {
timesheet_id: number;
is_approved: boolean;
days: TimesheetDay[];
weekly_hours: TotalHours[];
weekly_expenses: TotalExpenses[];
@ -30,15 +30,15 @@ export class TotalHours {
}
export class TotalExpenses {
expenses: number;
perd_diem: number;
per_diem: number;
on_call: number;
mileage: number;
}
export class Shift {
date: Date;
start_time: Date;
end_time: Date;
date: string;
start_time: string;
end_time: string;
type: string;
is_remote: boolean;
is_approved: boolean;

View File

@ -0,0 +1,26 @@
export const toDateFromString = ( date: Date | string):Date => {
const d = new Date(date);
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
export const sevenDaysFrom = (date: Date | string): Date[] => {
return Array.from({length: 7 }, (_,i) => {
const d = new Date(date);
d.setUTCDate(d.getUTCDate() + i );
return d;
});
}
export const toStringFromDate = (date: Date | string): string => {
const d = toDateFromString(date);
const year = d.getUTCFullYear();
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${d}`;
}
export const toHHmmFromDate = (input: Date | string): string => {
const date = new Date(input);
const hh = String(date.getUTCHours()).padStart(2, '0');
const mm = String(date.getUTCMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}

View File

@ -0,0 +1,38 @@
import { Injectable } 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";
@Injectable()
export class TimesheetApprovalService extends BaseApprovalService<Timesheets>{
constructor(prisma: PrismaService){super(prisma)}
//_____________________________________________________________________________________________
// APPROVAL AND DELEGATE METHODS
//_____________________________________________________________________________________________
protected get delegate() {
return this.prisma.timesheets;
}
protected delegateFor(transaction: Prisma.TransactionClient) {
return transaction.timesheets;
}
async updateApproval(id: number, isApproved: boolean): Promise<Timesheets> {
return this.prisma.$transaction((transaction) =>
this.updateApprovalWithTransaction(transaction, id, isApproved),
);
}
async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise<Timesheets> {
const timesheet = await this.updateApprovalWithTransaction(transaction, timesheetId, isApproved);
await transaction.shifts.updateMany({
where: { timesheet_id: timesheetId },
data: { is_approved: isApproved },
});
await transaction.expenses.updateManyAndReturn({
where: { timesheet_id: timesheetId },
data: { is_approved: isApproved },
});
return timesheet;
}
}

View File

@ -0,0 +1,192 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { sevenDaysFrom, toDateFromString, toHHmmFromDate, toStringFromDate } from "../helpers/timesheets-date-time-helpers";
type TotalHours = {
regular: number;
evening: number;
emergency: number;
overtime: number;
vacation: number;
holiday: number;
sick: number;
};
type TotalExpenses = {
expenses: number;
per_diem: number;
on_call: number;
mileage: number;
};
@Injectable()
export class GetTimesheetsOverviewService {
constructor(private readonly prisma: PrismaService) { }
async getTimesheetsByIds(timesheet_ids: number[]) {
if (!Array.isArray(timesheet_ids) || timesheet_ids.length === 0) throw new NotFoundException(`Timesheet_ids are missing`);
//fetch all needed data using timesheet ids
const rows = await this.prisma.timesheets.findMany({
where: { id: { in: timesheet_ids } },
include: {
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');
//build full name
const user = rows[0].employee.user;
const employee_fullname = `${user.first_name} ${user.last_name}`.trim();
const timesheets = rows.map((timesheet) => this.mapOneTimesheet(timesheet));
return { employee_fullname, timesheets };
}
//-----------------------------------------------------------------------------------
// MAPPERS & HELPERS
//-----------------------------------------------------------------------------------
private mapOneTimesheet(timesheet: any) {
//converts string to UTC date format
const start = toDateFromString(timesheet.start_date);
const day_dates = sevenDaysFrom(start);
//map of shifts by days
const shifts_by_date = new Map<string, any[]>();
for (const shift of timesheet.shift) {
const date = toStringFromDate(shift.date);
const arr = shifts_by_date.get(date) ?? [];
arr.push(shift);
shifts_by_date.set(date, arr);
}
//map of expenses by days
const expenses_by_date = new Map<string, any[]>();
for (const expense of timesheet.expense) {
const date = toStringFromDate(expense.date);
const arr = expenses_by_date.get(date) ?? [];
arr.push(expense);
expenses_by_date.set(date, arr);
}
//weekly totals
const weekly_hours: TotalHours[] = [emptyHours()];
const weekly_expenses: TotalExpenses[] = [emptyExpenses()];
//map of days
const days = day_dates.map((date) => {
const date_iso = toStringFromDate(date);
const shifts_source = shifts_by_date.get(date_iso) ?? [];
const expenses_source = expenses_by_date.get(date_iso) ?? [];
//inner map of shifts
const shifts = shifts_source.map((shift) => ({
date: toStringFromDate(shift.date),
start_time: toHHmmFromDate(shift.start_time),
end_time: toHHmmFromDate(shift.end_time),
type: shift.bank_code?.type ?? '',
is_remote: shift.is_remote ?? false,
is_approved: shift.is_approved ?? false,
shift_id: shift.id ?? null,
comment: shift.comment ?? null,
}));
//inner map of expenses
const expenses = expenses_source.map((expense) => ({
date: toStringFromDate(expense.date),
amount: expense.amount ? Number(expense.amount) : undefined,
mileage: expense.mileage ? Number(expense.mileage) : undefined,
expense_id: expense.id ?? null,
attachment: expense.attachment_record ? String(expense.attachment_record.id) : undefined,
is_approved: expense.is_approved ?? false,
comment: expense.comment ?? '',
supervisor_comment: expense.supervisor_comment,
}));
//daily totals
const daily_hours = [emptyHours()];
const daily_expenses = [emptyExpenses()];
//totals by shift types
for (const shift of shifts_source) {
const hours = diffOfHours(shift.start_time, shift.end_time);
const subgroup = hoursSubGroupFromBankCode(shift.bank_code);
daily_hours[0][subgroup] += hours;
weekly_hours[0][subgroup] += hours;
}
//totals by expense types
for (const expense of expenses_source) {
const subgroup = expenseSubgroupFromBankCode(expense.bank_code);
if (subgroup === 'mileage') {
const mileage = num(expense.mileage);
daily_expenses[0].mileage += mileage;
weekly_expenses[0].mileage += mileage;
} else if (subgroup === 'per_diem') {
const amount = num(expense.amount);
daily_expenses[0].per_diem += amount;
weekly_expenses[0].per_diem += amount;
} else if (subgroup === 'on_call') {
const amount = num(expense.amount);
daily_expenses[0].on_call += amount;
weekly_expenses[0].on_call += amount;
} else {
const amount = num(expense.amount);
daily_expenses[0].expenses += amount;
weekly_expenses[0].expenses += amount;
}
}
return {
date: date_iso,
shifts,
expenses,
daily_hours,
daily_expenses,
};
});
return {
timesheet_id: timesheet.id,
is_approved: timesheet.is_approved ?? false,
days,
weekly_hours,
weekly_expenses,
};
}
}
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 diffOfHours = (a: Date, b: Date): number => {
const ms = new Date(b).getTime() - new Date(a).getTime();
return Math.max(0, Math.round((ms / 36e5) * 1000) / 1000);
}
const num = (value: any): number => {
return value ? Number(value) : 0;
}
const hoursSubGroupFromBankCode = (bank_code: any): keyof TotalHours => {
const type = bank_code.type;
if (type.includes('EVENING')) return 'evening';
if (type.includes('EMERGENCY')) return 'emergency';
if (type.includes('OVERTIME')) return 'overtime';
if (type.includes('VACATION')) return 'vacation';
if (type.includes('HOLIDAY')) return 'holiday';
if (type.includes('SICK')) return 'sick';
return 'regular'
}
const expenseSubgroupFromBankCode = (bank_code: any): keyof TotalExpenses => {
const type = bank_code.type;
if (type.includes('MILEAGE')) return 'mileage';
if (type.includes('PER_DIEM')) return 'per_diem';
if (type.includes('ON_CALL')) return 'on_call';
return 'expenses';
}

View File

@ -1,137 +0,0 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils";
import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils";
import { parseISODate, parseHHmm } from "../utils-helpers-others/timesheet.helpers";
import { TimesheetsQueryService } from "./timesheets-query.service";
import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { Prisma, Timesheets } from "@prisma/client";
import { CreateTimesheetDto } from "../dtos/create-timesheet.dto";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { PrismaService } from "src/prisma/prisma.service";
import { TimesheetMap } from "../utils-helpers-others/timesheet.types";
import { Shift, Expense } from "src/modules/shared/classes/timesheet.dto";
@Injectable()
export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
constructor(
prisma: PrismaService,
private readonly query: TimesheetsQueryService,
private readonly emailResolver: EmailToIdResolver,
private readonly timesheetResolver: EmployeeTimesheetResolver,
private readonly bankTypeResolver: BankCodesResolver,
) {super(prisma);}
//_____________________________________________________________________________________________
// APPROVAL AND DELEGATE METHODS
//_____________________________________________________________________________________________
protected get delegate() {
return this.prisma.timesheets;
}
protected delegateFor(transaction: Prisma.TransactionClient) {
return transaction.timesheets;
}
async updateApproval(id: number, isApproved: boolean): Promise<Timesheets> {
return this.prisma.$transaction((transaction) =>
this.updateApprovalWithTransaction(transaction, id, isApproved),
);
}
async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise<Timesheets> {
const timesheet = await this.updateApprovalWithTransaction(transaction, timesheetId, isApproved);
await transaction.shifts.updateMany({
where: { timesheet_id: timesheetId },
data: { is_approved: isApproved },
});
await transaction.expenses.updateManyAndReturn({
where: { timesheet_id: timesheetId },
data: { is_approved: isApproved },
});
return timesheet;
}
/**_____________________________________________________________________________________________
create/update/delete shifts and expenses from 1 or many timesheet(s)
-this function receives an email and an array of timesheets
-this function will find the timesheets with all shifts and expenses
-this function will calculate total hours, total expenses, filtered by types,
cumulate in daily and weekly.
-the timesheet_id will be determined using the employee email
-with the timesheet_id, all shifts and expenses will be fetched
-with shift_id and expense_id, this function will compare both
datas from the DB and from the body of the function and then:
-it will create a shift if no shift is found in the DB
-it will update a shift if a shift is found in the DB
-it will delete a shift if a shift is found and no data is received from the frontend
This function will be used for the Timesheet Page for an employee to enter, modify or delete and entry
This function will also be used in the modal of the timesheet validation page to
allow a supervisor to enter, modify or delete and entry of a selected employee
_____________________________________________________________________________________________*/
async findTimesheetsByEmailAndPayPeriod(email: string, year: number, period_no: number, timesheets: Timesheets[]): Promise<Timesheets[]> {
const employee_id = await this.emailResolver.findIdByEmail(email);
return timesheets;
}
async upsertOrDeleteShiftsByEmailAndDate(email:string, shift_ids: Shift[]) {}
async upsertOrDeleteExpensesByEmailAndDate(email:string, expenses_id: Expense[]) {}
//_____________________________________________________________________________________________
//
//_____________________________________________________________________________________________
async createWeekShiftsAndReturnOverview(
email:string,
shifts: CreateTimesheetDto[],
week_offset = 0,
): Promise<TimesheetMap> {
//fetchs employee matchint user's email
const employee_id = await this.emailResolver.findIdByEmail(email);
if(!employee_id) 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.timesheetResolver.findTimesheetIdByEmail(email, base)
if(!timesheet) throw new NotFoundException(`no timesheet found for employe ${employee_id}`);
//validations and insertions
for(const shift of shifts) {
const date = 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.bankTypeResolver.findByType(shift.type)
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: parseHHmm(shift.start_time),
end_time: parseHHmm(shift.end_time),
comment: shift.comment ?? null,
is_approved: false,
is_remote: false,
},
});
}
return this.query.getTimesheetByEmail(email, week_offset);
}
}

View File

@ -1,54 +0,0 @@
import { makeEmptyTimesheet, mapExpenseRow, mapShiftRow } from '../utils-helpers-others/timesheet.mappers';
import { buildPeriod, computeWeekRange } from '../utils-helpers-others/timesheet.utils';
import { TimesheetSelectorsService } from '../utils-helpers-others/timesheet.selectors';
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import { toRangeFromPeriod } from '../utils-helpers-others/timesheet.helpers';
import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils';
import { PrismaService } from 'src/prisma/prisma.service';
import { TimesheetMap } from '../utils-helpers-others/timesheet.types';
import { Injectable } from '@nestjs/common';
@Injectable()
export class TimesheetsQueryService {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
private readonly fullNameResolver: FullNameResolver,
private readonly selectors: TimesheetSelectorsService,
) {}
async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email
const full_name = await this.fullNameResolver.resolveFullName(employee_id); //finds the employee full name using employee_id
const period = await this.selectors.getPayPeriod(year, period_no);//finds the pay period using year and period_no
const{ from, to } = toRangeFromPeriod(period); //finds start and end dates
//finds all shifts from selected period
const [raw_shifts, raw_expenses] = await Promise.all([
this.selectors.getShifts(employee_id, from, to),
this.selectors.getExpenses(employee_id, from, to),
]);
// data mapping
const shifts = raw_shifts.map(mapShiftRow);
const expenses = raw_expenses.map(mapExpenseRow);
return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name);
}
async getTimesheetByEmail(email: string, week_offset = 0): Promise<TimesheetMap> {
const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email
const { start, start_day, end_day, label } = computeWeekRange(week_offset);
const timesheet = await this.selectors.getTimesheetWithShiftsAndExpenses(employee_id, start); //fetch timesheet shifts and expenses
if(!timesheet) return makeEmptyTimesheet({ start_day, end_day, label});
//maps all shifts of selected timesheet
const shifts = timesheet.shift.map(mapShiftRow);
const expenses = timesheet.expense.map(mapExpenseRow);
return { start_day, end_day, label, shifts, expenses, is_approved: timesheet.is_approved};
}
}

View File

@ -1,34 +1,24 @@
import { TimesheetsController } from './controllers/timesheets.controller';
import { TimesheetsQueryService } from './services/timesheets-query.service';
import { GetTimesheetsOverviewService } from './services/timesheet-get-overview.service';
import { TimesheetArchiveService } from './services/timesheet-archive.service';
import { TimesheetsCommandService } from './services/timesheets-command.service';
import { ShiftsCommandService } from '../shifts/services/shifts-command.service';
import { ExpensesCommandService } from '../expenses/services/expenses-command.service';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { TimesheetController } from './controllers/timesheet.controller';
import { SharedModule } from '../shared/shared.module';
import { ShiftsModule } from '../shifts/shifts.module';
import { Module } from '@nestjs/common';
import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors';
import { ShiftsHelpersService } from '../shifts/helpers/shifts.helpers';
@Module({
imports: [
BusinessLogicsModule,
SharedModule,
ShiftsModule,
],
controllers: [TimesheetsController],
controllers: [TimesheetController],
providers: [
TimesheetsQueryService,
TimesheetsCommandService,
ShiftsCommandService,
ExpensesCommandService,
TimesheetArchiveService,
TimesheetSelectorsService,
ShiftsHelpersService,
GetTimesheetsOverviewService,
],
exports: [
TimesheetsQueryService,
TimesheetArchiveService,
TimesheetsCommandService
],
})
export class TimesheetsModule {}

View File

@ -1,67 +0,0 @@
import { MS_PER_DAY } from "src/modules/shared/constants/date-time.constant";
import { DAY_KEYS, DayKey } from "././timesheet.types";
export function toUTCDateOnly(date: Date | string): Date {
const d = new Date(date);
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
export function addDays(date:Date, days: number): Date {
return new Date(date.getTime() + days * MS_PER_DAY);
}
export function endOfDayUTC(date: Date | string): Date {
const d = toUTCDateOnly(date);
return new Date(d.getTime() + MS_PER_DAY - 1);
}
export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): boolean {
const time = date.getTime();
return time >= start.getTime() && time <= end_inclusive.getTime();
}
export function toTimeString(date: Date): string {
const hours = String(date.getUTCHours()).padStart(2,'0');
const minutes = String(date.getUTCMinutes()).padStart(2,'0');
return `${hours}:${minutes}`;
}
export function round2(num: number) {
return Math.round(num * 100) / 100;
}
export function shortDate(date:Date): string {
const mm = String(date.getUTCMonth()+1).padStart(2,'0');
const dd = String(date.getUTCDate()).padStart(2,'0');
return `${mm}/${dd}`;
}
export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday
return DAY_KEYS[index];
}
export const toHHmm = (date: Date) => date.toISOString().slice(11, 16);
export function parseISODate(iso: string): Date {
const [ y, m, d ] = iso.split('-').map(Number);
return new Date(y, (m ?? 1) - 1, d ?? 1);
}
export function parseHHmm(t: string): Date {
const [ hh, mm ] = t.split(':').map(Number);
return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0);
}
export const toNum = (value: any) =>
value && typeof value.toNumber === 'function' ? value.toNumber() :
typeof value === 'number' ? value :
value ? Number(value) : 0;
export const upper = (s?: string | null) => String(s ?? '').toUpperCase();
export const toRangeFromPeriod = (period: { period_start: Date; period_end: Date }) => ({
from: toUTCDateOnly(period.period_start),
to: endOfDayUTC(period.period_end),
});

View File

@ -1,111 +0,0 @@
import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "../dtos/timesheet-period.dto";
import { ShiftRow, ExpenseRow, ExpensesAmount, TimesheetMap } from "./timesheet.types";
import { addDays, shortDate, toNum, upper } from "./timesheet.helpers";
import { Prisma } from "@prisma/client";
//mappers
export const mapShiftRow = (shift: {
date: Date;
start_time: Date;
end_time: Date;
comment?: string | null;
is_approved: boolean;
is_remote: boolean;
bank_code: { type: string };
}): ShiftRow => ({
date: shift.date,
start_time: shift.start_time,
end_time: shift.end_time,
comment: shift.comment ?? '',
is_approved: shift.is_approved,
is_remote: shift.is_remote,
type: upper(shift.bank_code.type),
});
export const mapExpenseRow = (expense: {
date: Date;
amount: Prisma.Decimal | number | null;
mileage: Prisma.Decimal | number | null;
comment?: string | null;
is_approved: boolean;
supervisor_comment?: string|null;
bank_code: { type: string },
}): ExpenseRow => ({
date: expense.date,
amount: toNum(expense.amount),
mileage: toNum(expense.mileage),
comment: expense.comment ?? '',
is_approved: expense.is_approved,
supervisor_comment: expense.supervisor_comment ?? '',
type: upper(expense.bank_code.type),
});
// Factories
export function makeEmptyDayExpenses(): DayExpensesDto {
return {
expenses: [],
total_expense: -1,
total_mileage: -1,
};
}
export function makeEmptyWeek(week_start: Date): WeekDto {
const make_empty_shifts = (offset: number): DetailedShifts => ({
shifts: [],
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
comment: '',
short_date: shortDate(addDays(week_start, offset)),
break_durations: 0,
});
return {
is_approved: true,
shifts: {
sun: make_empty_shifts(0),
mon: make_empty_shifts(1),
tue: make_empty_shifts(2),
wed: make_empty_shifts(3),
thu: make_empty_shifts(4),
fri: make_empty_shifts(5),
sat: make_empty_shifts(6),
},
expenses: {
sun: makeEmptyDayExpenses(),
mon: makeEmptyDayExpenses(),
tue: makeEmptyDayExpenses(),
wed: makeEmptyDayExpenses(),
thu: makeEmptyDayExpenses(),
fri: makeEmptyDayExpenses(),
sat: makeEmptyDayExpenses(),
},
};
}
export function makeEmptyPeriod(): TimesheetPeriodDto {
return { weeks: [makeEmptyWeek(new Date()), makeEmptyWeek(new Date())], employee_full_name: '' };
}
export const makeAmounts = (): ExpensesAmount => ({
expense: 0,
mileage: 0,
});
export function makeEmptyTimesheet(params: {
start_day: string;
end_day: string;
label: string;
is_approved?: boolean;
}): TimesheetMap {
const { start_day, end_day, label, is_approved = false } = params;
return {
start_day,
end_day,
label,
shifts: [],
expenses: [],
is_approved,
};
}

View File

@ -1,46 +0,0 @@
import { EXPENSE_ASC_ORDER, EXPENSE_SELECT } from "../../shared/selects/expenses.select";
import { Injectable, NotFoundException } from "@nestjs/common";
import { SHIFT_ASC_ORDER, SHIFT_SELECT } from "../../shared/selects/shifts.select";
import { PAY_PERIOD_SELECT } from "../../shared/selects/pay-periods.select";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class TimesheetSelectorsService {
constructor(readonly prisma: PrismaService){}
async getPayPeriod(pay_year: number, pay_period_no: number) {
const period = await this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no },
select: PAY_PERIOD_SELECT ,
});
if(!period) throw new NotFoundException(`period ${pay_year}-${pay_period_no} not found`);
return period;
}
async getShifts(employee_id: number, from: Date, to: Date) {
return this.prisma.shifts.findMany({
where: {timesheet: { is: { employee_id } }, date: { gte: from, lte: to } },
select: SHIFT_SELECT,
orderBy: SHIFT_ASC_ORDER,
});
}
async getExpenses(employee_id: number, from: Date, to: Date) {
return this.prisma.expenses.findMany({
where: { timesheet: {is: { employee_id } }, date: { gte: from, lte: to } },
select: EXPENSE_SELECT,
orderBy: EXPENSE_ASC_ORDER,
});
}
async getTimesheetWithShiftsAndExpenses(employee_id: number, start_date_week: Date) {
return this.prisma.timesheets.findUnique({
where: { employee_id_start_date: { employee_id, start_date: start_date_week } },
select: {
is_approved: true,
shift: { select: SHIFT_SELECT, orderBy: SHIFT_ASC_ORDER },
expense: { select: EXPENSE_SELECT, orderBy: EXPENSE_ASC_ORDER },
},
});
}
}

View File

@ -1,74 +0,0 @@
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;
mileage?: number | null;
comment: string;
type: string;
is_approved?: boolean;
supervisor_comment: string;
};
export type TimesheetMap = {
start_day: string;
end_day: string;
label: string;
shifts: ShiftRow[];
expenses: ExpenseRow[]
is_approved: boolean;
}
// Types
export const SHIFT_TYPES = {
REGULAR: 'REGULAR',
EVENING: 'EVENING',
OVERTIME: 'OVERTIME',
EMERGENCY: 'EMERGENCY',
HOLIDAY: 'HOLIDAY',
VACATION: 'VACATION',
SICK: 'SICK',
} as const;
export const EXPENSE_TYPES = {
MILEAGE: 'MILEAGE',
EXPENSE: 'EXPENSES',
PER_DIEM: 'PER_DIEM',
ON_CALL: 'ON_CALL',
} as const;
//makes the strings indexes for arrays
export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
export type DayKey = typeof DAY_KEYS[number];
//shifts's hour by type
export type ShiftsHours = {
regular: number;
evening: number;
overtime: number;
emergency: number;
sick: number;
vacation: number;
holiday: number;
};
export const make_hours = (): ShiftsHours => ({
regular: 0,
evening: 0,
overtime: 0,
emergency: 0,
sick: 0,
vacation: 0,
holiday: 0,
});
export type ExpensesAmount = {
expense: number;
mileage: number;
};

View File

@ -1,171 +0,0 @@
import {
DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow,
SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount
} from "./timesheet.types";
import {
isBetweenUTC, dayKeyFromDate, toTimeString, round2,
toUTCDateOnly, endOfDayUTC, addDays
} from "./timesheet.helpers";
import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "../dtos/timesheet-period.dto";
import { getWeekStart, getWeekEnd, formatDateISO } from "src/common/utils/date-utils";
import { makeAmounts, makeEmptyWeek } from "./timesheet.mappers";
import { toDateString } from "src/modules/pay-periods/utils/pay-year.util";
import { MS_PER_HOUR } from "src/modules/shared/constants/date-time.constant";
export function computeWeekRange(week_offset = 0){
//sets current week Sunday -> Saturday
const base = new Date();
const offset = new Date(base);
offset.setDate(offset.getDate() + (week_offset * 7));
const start = getWeekStart(offset, 0);
const end = getWeekEnd(start);
const start_day = formatDateISO(start);
const end_day = formatDateISO(end);
const label = `${(start_day)}.${(end_day)}`;
return { start, end, start_day, end_day, label }
};
export function buildWeek(
week_start: Date,
week_end: Date,
shifts: ShiftRow[],
expenses: ExpenseRow[],
): WeekDto {
const week = makeEmptyWeek(week_start);
let all_approved = true;
const day_times: Record<DayKey, Array<{ start: Date; end: Date }>> = DAY_KEYS.reduce((acc, key) => {
acc[key] = []; return acc;
}, {} as Record<DayKey, Array<{ start: Date; end: Date}>>);
const day_hours: Record<DayKey, ShiftsHours> = DAY_KEYS.reduce((acc, key) => {
acc[key] = make_hours(); return acc;
}, {} as Record<DayKey, ShiftsHours>);
const day_amounts: Record<DayKey, ExpensesAmount> = DAY_KEYS.reduce((acc, key) => {
acc[key] = makeAmounts(); return acc;
}, {} as Record<DayKey, ExpensesAmount>);
const day_expense_rows: Record<DayKey, DayExpensesDto> = DAY_KEYS.reduce((acc, key) => {
acc[key] = {
expenses: [{
type: '',
amount: -1,
mileage: -1,
comment: '',
is_approved: false,
supervisor_comment: '',
}],
total_expense: -1,
total_mileage: -1,
};
return acc;
}, {} as Record<DayKey, DayExpensesDto>);
//regroup hours per type of shifts
const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end));
for (const shift of week_shifts) {
const key = dayKeyFromDate(shift.date, true);
week.shifts[key].shifts.push({
date: toDateString(shift.date),
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);
day_times[key].push({ start: shift.start_time, end: shift.end_time});
const duration = Math.max(0, (shift.end_time.getTime() - shift.start_time.getTime())/ MS_PER_HOUR);
const type = (shift.type || '').toUpperCase();
if ( type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration;
else if( type === SHIFT_TYPES.EVENING) day_hours[key].evening += duration;
else if( type === SHIFT_TYPES.EMERGENCY) day_hours[key].emergency += duration;
else if( type === SHIFT_TYPES.OVERTIME) day_hours[key].overtime += duration;
else if( type === SHIFT_TYPES.SICK) day_hours[key].sick += duration;
else if( type === SHIFT_TYPES.VACATION) day_hours[key].vacation += duration;
else if( type === SHIFT_TYPES.HOLIDAY) day_hours[key].holiday += duration;
all_approved = all_approved && (shift.is_approved ?? true );
}
//regroupe amounts to type of expenses
const week_expenses = expenses.filter(expense => isBetweenUTC(expense.date, week_start, week_end));
for (const expense of week_expenses) {
const key = dayKeyFromDate(expense.date, true);
const type = (expense.type || '').toUpperCase();
const row: ExpenseDto = {
type,
amount: round2(expense.amount ?? 0),
mileage: round2(expense.mileage ?? 0),
comment: expense.comment ?? '',
is_approved: expense.is_approved ?? true,
supervisor_comment: expense.supervisor_comment ?? '',
};
day_expense_rows[key].expenses.push(row);
if(type === EXPENSE_TYPES.MILEAGE) {
day_amounts[key].mileage += row.mileage ?? 0;
} else {
day_amounts[key].expense += row.amount;
}
all_approved = all_approved && row.is_approved;
}
for (const key of DAY_KEYS) {
//return exposed dto data
week.shifts[key].regular_hours = round2(day_hours[key].regular);
week.shifts[key].evening_hours = round2(day_hours[key].evening);
week.shifts[key].overtime_hours = round2(day_hours[key].overtime);
week.shifts[key].emergency_hours = round2(day_hours[key].emergency);
//calculate gaps between shifts
const times = day_times[key].sort((a,b) => a.start.getTime() - b.start.getTime());
let gaps = 0;
for (let i = 1; i < times.length; i++) {
const gap = (times[i].start.getTime() - times[i - 1].end.getTime()) / MS_PER_HOUR;
if(gap > 0) gaps += gap;
}
week.shifts[key].break_durations = round2(gaps);
//daily totals
const totals = day_amounts[key];
day_expense_rows[key].total_mileage = round2(totals.mileage);
day_expense_rows[key].total_expense = round2(totals.expense);
}
week.is_approved = all_approved;
return week;
}
export function buildPeriod(
period_start: Date,
period_end: Date,
shifts: ShiftRow[],
expenses: ExpenseRow[],
employeeFullName = ''
): TimesheetPeriodDto {
const week1_start = toUTCDateOnly(period_start);
const week1_end = endOfDayUTC(addDays(week1_start, 6));
const week2_start = toUTCDateOnly(addDays(week1_start, 7));
const week2_end = endOfDayUTC(period_end);
const weeks: WeekDto[] = [
buildWeek(week1_start, week1_end, shifts, expenses),
buildWeek(week2_start, week2_end, shifts, expenses),
];
return {
weeks,
employee_full_name: employeeFullName,
};
}

View File

@ -0,0 +1,33 @@
// import { Type } from "class-transformer";
// import { IsArray, IsOptional, IsString, Length, Matches, ValidateNested } from "class-validator";
// export class CreateTimesheetDto {
// @IsString()
// @Matches(/^\d{4}-\d{2}-\d{2}$/)
// date!: string;
// @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)
// comment?: string;
// }
// export class CreateWeekShiftsDto {
// @IsArray()
// @ValidateNested({each:true})
// @Type(()=> CreateTimesheetDto)
// shifts!: CreateTimesheetDto[];
// }

View File

@ -0,0 +1,20 @@
// import { Type } from "class-transformer";
// import { IsBoolean, IsInt, IsOptional } from "class-validator";
// export class SearchTimesheetDto {
// @IsOptional()
// @Type(() => Number)
// @IsInt()
// timesheet_id?: number;
// @IsOptional()
// @Type(()=> Number)
// @IsInt()
// employee_id?: number;
// @IsOptional()
// @Type(()=> Boolean)
// @IsBoolean()
// is_approved?: boolean;
// }

View File

@ -0,0 +1,75 @@
// export class TimesheetDto {
// start_day: string;
// end_day: string;
// label: string;
// shifts: ShiftDto[];
// expenses: ExpenseDto[]
// is_approved: boolean;
// }
// export class ShiftDto {
// date: string;
// type: string;
// start_time: string;
// end_time : string;
// comment: string;
// is_approved: boolean;
// is_remote: boolean;
// }
// export class ExpenseDto {
// type: string;
// amount: number;
// mileage: number;
// comment: string;
// is_approved: boolean;
// supervisor_comment: string;
// }
// export type DayShiftsDto = ShiftDto[];
// export class DetailedShifts {
// shifts: DayShiftsDto;
// regular_hours: number;
// evening_hours: number;
// overtime_hours: number;
// emergency_hours: number;
// comment: string;
// short_date: string;
// break_durations?: number;
// }
// export class DayExpensesDto {
// expenses: ExpenseDto[] = [];
// total_mileage: number;
// total_expense: number;
// }
// export class WeekDto {
// is_approved: boolean;
// shifts: {
// sun: DetailedShifts;
// mon: DetailedShifts;
// tue: DetailedShifts;
// wed: DetailedShifts;
// thu: DetailedShifts;
// fri: DetailedShifts;
// sat: DetailedShifts;
// }
// expenses: {
// sun: DayExpensesDto;
// mon: DayExpensesDto;
// tue: DayExpensesDto;
// wed: DayExpensesDto;
// thu: DayExpensesDto;
// fri: DayExpensesDto;
// sat: DayExpensesDto;
// }
// }
// export class TimesheetPeriodDto {
// weeks: WeekDto[];
// employee_full_name: string;
// }

View File

@ -0,0 +1,67 @@
// import { MS_PER_DAY } from "src/modules/shared/constants/date-time.constant";
// import { DAY_KEYS, DayKey } from "./timesheet.types";
// export function toUTCDateOnly(date: Date | string): Date {
// const d = new Date(date);
// return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
// }
// export function addDays(date:Date, days: number): Date {
// return new Date(date.getTime() + days * MS_PER_DAY);
// }
// export function endOfDayUTC(date: Date | string): Date {
// const d = toUTCDateOnly(date);
// return new Date(d.getTime() + MS_PER_DAY - 1);
// }
// export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): boolean {
// const time = date.getTime();
// return time >= start.getTime() && time <= end_inclusive.getTime();
// }
// export function toTimeString(date: Date): string {
// const hours = String(date.getUTCHours()).padStart(2,'0');
// const minutes = String(date.getUTCMinutes()).padStart(2,'0');
// return `${hours}:${minutes}`;
// }
// export function round2(num: number) {
// return Math.round(num * 100) / 100;
// }
// export function shortDate(date:Date): string {
// const mm = String(date.getUTCMonth()+1).padStart(2,'0');
// const dd = String(date.getUTCDate()).padStart(2,'0');
// return `${mm}/${dd}`;
// }
// export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
// const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday
// return DAY_KEYS[index];
// }
// export const toHHmm = (date: Date) => date.toISOString().slice(11, 16);
// export function parseISODate(iso: string): Date {
// const [ y, m, d ] = iso.split('-').map(Number);
// return new Date(y, (m ?? 1) - 1, d ?? 1);
// }
// export function parseHHmm(t: string): Date {
// const [ hh, mm ] = t.split(':').map(Number);
// return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0);
// }
// export const toNum = (value: any) =>
// value && typeof value.toNumber === 'function' ? value.toNumber() :
// typeof value === 'number' ? value :
// value ? Number(value) : 0;
// export const upper = (s?: string | null) => String(s ?? '').toUpperCase();
// export const toRangeFromPeriod = (period: { period_start: Date; period_end: Date }) => ({
// from: toUTCDateOnly(period.period_start),
// to: endOfDayUTC(period.period_end),
// });

View File

@ -0,0 +1,111 @@
// import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "../dtos/timesheet-period.dto";
// import { ShiftRow, ExpenseRow, ExpensesAmount, TimesheetMap } from "./timesheet.types";
// import { addDays, shortDate, toNum, upper } from "./timesheet.helpers";
// import { Prisma } from "@prisma/client";
// //mappers
// export const mapShiftRow = (shift: {
// date: Date;
// start_time: Date;
// end_time: Date;
// comment?: string | null;
// is_approved: boolean;
// is_remote: boolean;
// bank_code: { type: string };
// }): ShiftRow => ({
// date: shift.date,
// start_time: shift.start_time,
// end_time: shift.end_time,
// comment: shift.comment ?? '',
// is_approved: shift.is_approved,
// is_remote: shift.is_remote,
// type: upper(shift.bank_code.type),
// });
// export const mapExpenseRow = (expense: {
// date: Date;
// amount: Prisma.Decimal | number | null;
// mileage: Prisma.Decimal | number | null;
// comment?: string | null;
// is_approved: boolean;
// supervisor_comment?: string|null;
// bank_code: { type: string },
// }): ExpenseRow => ({
// date: expense.date,
// amount: toNum(expense.amount),
// mileage: toNum(expense.mileage),
// comment: expense.comment ?? '',
// is_approved: expense.is_approved,
// supervisor_comment: expense.supervisor_comment ?? '',
// type: upper(expense.bank_code.type),
// });
// // Factories
// export function makeEmptyDayExpenses(): DayExpensesDto {
// return {
// expenses: [],
// total_expense: -1,
// total_mileage: -1,
// };
// }
// export function makeEmptyWeek(week_start: Date): WeekDto {
// const make_empty_shifts = (offset: number): DetailedShifts => ({
// shifts: [],
// regular_hours: 0,
// evening_hours: 0,
// emergency_hours: 0,
// overtime_hours: 0,
// comment: '',
// short_date: shortDate(addDays(week_start, offset)),
// break_durations: 0,
// });
// return {
// is_approved: true,
// shifts: {
// sun: make_empty_shifts(0),
// mon: make_empty_shifts(1),
// tue: make_empty_shifts(2),
// wed: make_empty_shifts(3),
// thu: make_empty_shifts(4),
// fri: make_empty_shifts(5),
// sat: make_empty_shifts(6),
// },
// expenses: {
// sun: makeEmptyDayExpenses(),
// mon: makeEmptyDayExpenses(),
// tue: makeEmptyDayExpenses(),
// wed: makeEmptyDayExpenses(),
// thu: makeEmptyDayExpenses(),
// fri: makeEmptyDayExpenses(),
// sat: makeEmptyDayExpenses(),
// },
// };
// }
// export function makeEmptyPeriod(): TimesheetPeriodDto {
// return { weeks: [makeEmptyWeek(new Date()), makeEmptyWeek(new Date())], employee_full_name: '' };
// }
// export const makeAmounts = (): ExpensesAmount => ({
// expense: 0,
// mileage: 0,
// });
// export function makeEmptyTimesheet(params: {
// start_day: string;
// end_day: string;
// label: string;
// is_approved?: boolean;
// }): TimesheetMap {
// const { start_day, end_day, label, is_approved = false } = params;
// return {
// start_day,
// end_day,
// label,
// shifts: [],
// expenses: [],
// is_approved,
// };
// }

View File

@ -0,0 +1,46 @@
// import { EXPENSE_ASC_ORDER, EXPENSE_SELECT } from "../../../shared/selects/expenses.select";
// import { Injectable, NotFoundException } from "@nestjs/common";
// import { SHIFT_ASC_ORDER, SHIFT_SELECT } from "../../../shared/selects/shifts.select";
// import { PAY_PERIOD_SELECT } from "../../../shared/selects/pay-periods.select";
// import { PrismaService } from "src/prisma/prisma.service";
// @Injectable()
// export class TimesheetSelectorsService {
// constructor(readonly prisma: PrismaService){}
// async getPayPeriod(pay_year: number, pay_period_no: number) {
// const period = await this.prisma.payPeriods.findFirst({
// where: { pay_year, pay_period_no },
// select: PAY_PERIOD_SELECT ,
// });
// if(!period) throw new NotFoundException(`period ${pay_year}-${pay_period_no} not found`);
// return period;
// }
// async getShifts(employee_id: number, from: Date, to: Date) {
// return this.prisma.shifts.findMany({
// where: {timesheet: { is: { employee_id } }, date: { gte: from, lte: to } },
// select: SHIFT_SELECT,
// orderBy: SHIFT_ASC_ORDER,
// });
// }
// async getExpenses(employee_id: number, from: Date, to: Date) {
// return this.prisma.expenses.findMany({
// where: { timesheet: {is: { employee_id } }, date: { gte: from, lte: to } },
// select: EXPENSE_SELECT,
// orderBy: EXPENSE_ASC_ORDER,
// });
// }
// async getTimesheetWithShiftsAndExpenses(employee_id: number, start_date_week: Date) {
// return this.prisma.timesheets.findUnique({
// where: { employee_id_start_date: { employee_id, start_date: start_date_week } },
// select: {
// is_approved: true,
// shift: { select: SHIFT_SELECT, orderBy: SHIFT_ASC_ORDER },
// expense: { select: EXPENSE_SELECT, orderBy: EXPENSE_ASC_ORDER },
// },
// });
// }
// }

View File

@ -0,0 +1,74 @@
// 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;
// mileage?: number | null;
// comment: string;
// type: string;
// is_approved?: boolean;
// supervisor_comment: string;
// };
// export type TimesheetMap = {
// start_day: string;
// end_day: string;
// label: string;
// shifts: ShiftRow[];
// expenses: ExpenseRow[]
// is_approved: boolean;
// }
// // Types
// export const SHIFT_TYPES = {
// REGULAR: 'REGULAR',
// EVENING: 'EVENING',
// OVERTIME: 'OVERTIME',
// EMERGENCY: 'EMERGENCY',
// HOLIDAY: 'HOLIDAY',
// VACATION: 'VACATION',
// SICK: 'SICK',
// } as const;
// export const EXPENSE_TYPES = {
// MILEAGE: 'MILEAGE',
// EXPENSE: 'EXPENSES',
// PER_DIEM: 'PER_DIEM',
// ON_CALL: 'ON_CALL',
// } as const;
// //makes the strings indexes for arrays
// export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
// export type DayKey = typeof DAY_KEYS[number];
// //shifts's hour by type
// export type ShiftsHours = {
// regular: number;
// evening: number;
// overtime: number;
// emergency: number;
// sick: number;
// vacation: number;
// holiday: number;
// };
// export const make_hours = (): ShiftsHours => ({
// regular: 0,
// evening: 0,
// overtime: 0,
// emergency: 0,
// sick: 0,
// vacation: 0,
// holiday: 0,
// });
// export type ExpensesAmount = {
// expense: number;
// mileage: number;
// };

View File

@ -0,0 +1,171 @@
// import {
// DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow,
// SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount
// } from "./timesheet.types";
// import {
// isBetweenUTC, dayKeyFromDate, toTimeString, round2,
// toUTCDateOnly, endOfDayUTC, addDays
// } from "./timesheet.helpers";
// import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "../dtos/timesheet-period.dto";
// import { getWeekStart, getWeekEnd, formatDateISO } from "src/common/utils/date-utils";
// import { makeAmounts, makeEmptyWeek } from "./timesheet.mappers";
// import { toDateString } from "src/modules/pay-periods/utils/pay-year.util";
// import { MS_PER_HOUR } from "src/modules/shared/constants/date-time.constant";
// export function computeWeekRange(week_offset = 0){
// //sets current week Sunday -> Saturday
// const base = new Date();
// const offset = new Date(base);
// offset.setDate(offset.getDate() + (week_offset * 7));
// const start = getWeekStart(offset, 0);
// const end = getWeekEnd(start);
// const start_day = formatDateISO(start);
// const end_day = formatDateISO(end);
// const label = `${(start_day)}.${(end_day)}`;
// return { start, end, start_day, end_day, label }
// };
// export function buildWeek(
// week_start: Date,
// week_end: Date,
// shifts: ShiftRow[],
// expenses: ExpenseRow[],
// ): WeekDto {
// const week = makeEmptyWeek(week_start);
// let all_approved = true;
// const day_times: Record<DayKey, Array<{ start: Date; end: Date }>> = DAY_KEYS.reduce((acc, key) => {
// acc[key] = []; return acc;
// }, {} as Record<DayKey, Array<{ start: Date; end: Date}>>);
// const day_hours: Record<DayKey, ShiftsHours> = DAY_KEYS.reduce((acc, key) => {
// acc[key] = make_hours(); return acc;
// }, {} as Record<DayKey, ShiftsHours>);
// const day_amounts: Record<DayKey, ExpensesAmount> = DAY_KEYS.reduce((acc, key) => {
// acc[key] = makeAmounts(); return acc;
// }, {} as Record<DayKey, ExpensesAmount>);
// const day_expense_rows: Record<DayKey, DayExpensesDto> = DAY_KEYS.reduce((acc, key) => {
// acc[key] = {
// expenses: [{
// type: '',
// amount: -1,
// mileage: -1,
// comment: '',
// is_approved: false,
// supervisor_comment: '',
// }],
// total_expense: -1,
// total_mileage: -1,
// };
// return acc;
// }, {} as Record<DayKey, DayExpensesDto>);
// //regroup hours per type of shifts
// const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end));
// for (const shift of week_shifts) {
// const key = dayKeyFromDate(shift.date, true);
// week.shifts[key].shifts.push({
// date: toDateString(shift.date),
// 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);
// day_times[key].push({ start: shift.start_time, end: shift.end_time});
// const duration = Math.max(0, (shift.end_time.getTime() - shift.start_time.getTime())/ MS_PER_HOUR);
// const type = (shift.type || '').toUpperCase();
// if ( type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration;
// else if( type === SHIFT_TYPES.EVENING) day_hours[key].evening += duration;
// else if( type === SHIFT_TYPES.EMERGENCY) day_hours[key].emergency += duration;
// else if( type === SHIFT_TYPES.OVERTIME) day_hours[key].overtime += duration;
// else if( type === SHIFT_TYPES.SICK) day_hours[key].sick += duration;
// else if( type === SHIFT_TYPES.VACATION) day_hours[key].vacation += duration;
// else if( type === SHIFT_TYPES.HOLIDAY) day_hours[key].holiday += duration;
// all_approved = all_approved && (shift.is_approved ?? true );
// }
// //regroupe amounts to type of expenses
// const week_expenses = expenses.filter(expense => isBetweenUTC(expense.date, week_start, week_end));
// for (const expense of week_expenses) {
// const key = dayKeyFromDate(expense.date, true);
// const type = (expense.type || '').toUpperCase();
// const row: ExpenseDto = {
// type,
// amount: round2(expense.amount ?? 0),
// mileage: round2(expense.mileage ?? 0),
// comment: expense.comment ?? '',
// is_approved: expense.is_approved ?? true,
// supervisor_comment: expense.supervisor_comment ?? '',
// };
// day_expense_rows[key].expenses.push(row);
// if(type === EXPENSE_TYPES.MILEAGE) {
// day_amounts[key].mileage += row.mileage ?? 0;
// } else {
// day_amounts[key].expense += row.amount;
// }
// all_approved = all_approved && row.is_approved;
// }
// for (const key of DAY_KEYS) {
// //return exposed dto data
// week.shifts[key].regular_hours = round2(day_hours[key].regular);
// week.shifts[key].evening_hours = round2(day_hours[key].evening);
// week.shifts[key].overtime_hours = round2(day_hours[key].overtime);
// week.shifts[key].emergency_hours = round2(day_hours[key].emergency);
// //calculate gaps between shifts
// const times = day_times[key].sort((a,b) => a.start.getTime() - b.start.getTime());
// let gaps = 0;
// for (let i = 1; i < times.length; i++) {
// const gap = (times[i].start.getTime() - times[i - 1].end.getTime()) / MS_PER_HOUR;
// if(gap > 0) gaps += gap;
// }
// week.shifts[key].break_durations = round2(gaps);
// //daily totals
// const totals = day_amounts[key];
// day_expense_rows[key].total_mileage = round2(totals.mileage);
// day_expense_rows[key].total_expense = round2(totals.expense);
// }
// week.is_approved = all_approved;
// return week;
// }
// export function buildPeriod(
// period_start: Date,
// period_end: Date,
// shifts: ShiftRow[],
// expenses: ExpenseRow[],
// employeeFullName = ''
// ): TimesheetPeriodDto {
// const week1_start = toUTCDateOnly(period_start);
// const week1_end = endOfDayUTC(addDays(week1_start, 6));
// const week2_start = toUTCDateOnly(addDays(week1_start, 7));
// const week2_end = endOfDayUTC(period_end);
// const weeks: WeekDto[] = [
// buildWeek(week1_start, week1_end, shifts, expenses),
// buildWeek(week2_start, week2_end, shifts, expenses),
// ];
// return {
// weeks,
// employee_full_name: employeeFullName,
// };
// }

View File

@ -0,0 +1,137 @@
// import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
// import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils";
// import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils";
// import { parseISODate, parseHHmm } from "./utils-helpers-others/timesheet.helpers";
// import { TimesheetsQueryService } from "./timesheets-query.service";
// import { BaseApprovalService } from "src/common/shared/base-approval.service";
// import { Prisma, Timesheets } from "@prisma/client";
// import { CreateTimesheetDto } from "./create-timesheet.dto";
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
// import { PrismaService } from "src/prisma/prisma.service";
// import { TimesheetMap } from "./utils-helpers-others/timesheet.types";
// import { Shift, Expense } from "../dtos/timesheet.dto";
// @Injectable()
// export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
// constructor(
// prisma: PrismaService,
// private readonly query: TimesheetsQueryService,
// private readonly emailResolver: EmailToIdResolver,
// private readonly timesheetResolver: EmployeeTimesheetResolver,
// private readonly bankTypeResolver: BankCodesResolver,
// ) {super(prisma);}
// //_____________________________________________________________________________________________
// // APPROVAL AND DELEGATE METHODS
// //_____________________________________________________________________________________________
// protected get delegate() {
// return this.prisma.timesheets;
// }
// protected delegateFor(transaction: Prisma.TransactionClient) {
// return transaction.timesheets;
// }
// async updateApproval(id: number, isApproved: boolean): Promise<Timesheets> {
// return this.prisma.$transaction((transaction) =>
// this.updateApprovalWithTransaction(transaction, id, isApproved),
// );
// }
// async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise<Timesheets> {
// const timesheet = await this.updateApprovalWithTransaction(transaction, timesheetId, isApproved);
// await transaction.shifts.updateMany({
// where: { timesheet_id: timesheetId },
// data: { is_approved: isApproved },
// });
// await transaction.expenses.updateManyAndReturn({
// where: { timesheet_id: timesheetId },
// data: { is_approved: isApproved },
// });
// return timesheet;
// }
// /**_____________________________________________________________________________________________
// create/update/delete shifts and expenses from 1 or many timesheet(s)
// -this function receives an email and an array of timesheets
// -this function will find the timesheets with all shifts and expenses
// -this function will calculate total hours, total expenses, filtered by types,
// cumulate in daily and weekly.
// -the timesheet_id will be determined using the employee email
// -with the timesheet_id, all shifts and expenses will be fetched
// -with shift_id and expense_id, this function will compare both
// datas from the DB and from the body of the function and then:
// -it will create a shift if no shift is found in the DB
// -it will update a shift if a shift is found in the DB
// -it will delete a shift if a shift is found and no data is received from the frontend
// This function will be used for the Timesheet Page for an employee to enter, modify or delete and entry
// This function will also be used in the modal of the timesheet validation page to
// allow a supervisor to enter, modify or delete and entry of a selected employee
// _____________________________________________________________________________________________*/
// async findTimesheetsByEmailAndPayPeriod(email: string, year: number, period_no: number, timesheets: Timesheets[]): Promise<Timesheets[]> {
// const employee_id = await this.emailResolver.findIdByEmail(email);
// return timesheets;
// }
// async upsertOrDeleteShiftsByEmailAndDate(email:string, shift_ids: Shift[]) {}
// async upsertOrDeleteExpensesByEmailAndDate(email:string, expenses_id: Expense[]) {}
// //_____________________________________________________________________________________________
// //
// //_____________________________________________________________________________________________
// async createWeekShiftsAndReturnOverview(
// email:string,
// shifts: CreateTimesheetDto[],
// week_offset = 0,
// ): Promise<TimesheetMap> {
// //fetchs employee matchint user's email
// const employee_id = await this.emailResolver.findIdByEmail(email);
// if(!employee_id) 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.timesheetResolver.findTimesheetIdByEmail(email, base)
// if(!timesheet) throw new NotFoundException(`no timesheet found for employe ${employee_id}`);
// //validations and insertions
// for(const shift of shifts) {
// const date = 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.bankTypeResolver.findByType(shift.type)
// 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: parseHHmm(shift.start_time),
// end_time: parseHHmm(shift.end_time),
// comment: shift.comment ?? null,
// is_approved: false,
// is_remote: false,
// },
// });
// }
// return this.query.getTimesheetByEmail(email, week_offset);
// }
// }

View File

@ -0,0 +1,54 @@
// import { makeEmptyTimesheet, mapExpenseRow, mapShiftRow } from './utils-helpers-others/timesheet.mappers';
// import { buildPeriod, computeWeekRange } from './utils-helpers-others/timesheet.utils';
// import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors';
// import { TimesheetPeriodDto } from './timesheet-period.dto';
// import { toRangeFromPeriod } from './utils-helpers-others/timesheet.helpers';
// import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
// import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils';
// import { PrismaService } from 'src/prisma/prisma.service';
// import { TimesheetMap } from './utils-helpers-others/timesheet.types';
// import { Injectable } from '@nestjs/common';
// @Injectable()
// export class TimesheetsQueryService {
// constructor(
// private readonly prisma: PrismaService,
// private readonly emailResolver: EmailToIdResolver,
// private readonly fullNameResolver: FullNameResolver,
// private readonly selectors: TimesheetSelectorsService,
// ) {}
// async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
// const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email
// const full_name = await this.fullNameResolver.resolveFullName(employee_id); //finds the employee full name using employee_id
// const period = await this.selectors.getPayPeriod(year, period_no);//finds the pay period using year and period_no
// const{ from, to } = toRangeFromPeriod(period); //finds start and end dates
// //finds all shifts from selected period
// const [raw_shifts, raw_expenses] = await Promise.all([
// this.selectors.getShifts(employee_id, from, to),
// this.selectors.getExpenses(employee_id, from, to),
// ]);
// // data mapping
// const shifts = raw_shifts.map(mapShiftRow);
// const expenses = raw_expenses.map(mapExpenseRow);
// return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name);
// }
// async getTimesheetByEmail(email: string, week_offset = 0): Promise<TimesheetMap> {
// const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email
// const { start, start_day, end_day, label } = computeWeekRange(week_offset);
// const timesheet = await this.selectors.getTimesheetWithShiftsAndExpenses(employee_id, start); //fetch timesheet shifts and expenses
// if(!timesheet) return makeEmptyTimesheet({ start_day, end_day, label});
// //maps all shifts of selected timesheet
// const shifts = timesheet.shift.map(mapShiftRow);
// const expenses = timesheet.expense.map(mapExpenseRow);
// return { start_day, end_day, label, shifts, expenses, is_approved: timesheet.is_approved};
// }
// }

View File

@ -0,0 +1,51 @@
// import { BadRequestException, Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
// import { TimesheetsQueryService } from './timesheets-query.service';
// import { CreateWeekShiftsDto } from './create-timesheet.dto';
// import { RolesAllowed } from "src/common/decorators/roles.decorators";
// import { Roles as RoleEnum } from '.prisma/client';
// import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
// import { TimesheetsCommandService } from './timesheets-command.service';
// import { TimesheetPeriodDto } from './timesheet-period.dto';
// import { TimesheetMap } from './timesheet.types';
// @ApiTags('Timesheets')
// @ApiBearerAuth('access-token')
// // @UseGuards()
// @Controller('timesheets')
// export class TimesheetsController {
// constructor(
// private readonly timesheetsQuery: TimesheetsQueryService,
// private readonly timesheetsCommand: TimesheetsCommandService,
// ) {}
// @Get()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// async getPeriodByQuery(
// @Query('year', ParseIntPipe ) year: number,
// @Query('period_no', ParseIntPipe ) period_no: number,
// @Query('email') email?: string
// ): Promise<TimesheetPeriodDto> {
// if(!email || !(email = email.trim())) throw new BadRequestException('Query param "email" is mandatory for this route.');
// return this.timesheetsQuery.findAll(year, period_no, email);
// }
// @Get('/:email')
// async getByEmail(
// @Param('email') email: string,
// @Query('offset') offset?: string,
// ): Promise<TimesheetMap> {
// const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0;
// return this.timesheetsQuery.getTimesheetByEmail(email, week_offset);
// }
// @Post('shifts/:email')
// async createTimesheetShifts(
// @Param('email') email: string,
// @Body() dto: CreateWeekShiftsDto,
// @Query('offset') offset?: string,
// ): Promise<TimesheetMap> {
// const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0;
// return this.timesheetsCommand.createWeekShiftsAndReturnOverview(email, dto.shifts, week_offset);
// }
// }