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": { "get": {
"operationId": "EmployeesArchiveController_findOneArchived", "operationId": "AuthController_login",
"parameters": [ "parameters": [],
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "Archived employee found" "description": ""
} }
}, },
"summary": "Fetch employee in archives with its Id",
"tags": [ "tags": [
"Employee Archives" "Auth"
] ]
} }
}, },
"/archives/expenses": { "/auth/callback": {
"get": { "get": {
"operationId": "ExpensesArchiveController_findOneArchived", "operationId": "AuthController_loginCallback",
"parameters": [ "parameters": [],
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "Archived expense found" "description": ""
} }
}, },
"summary": "Fetch expense in archives with its Id",
"tags": [ "tags": [
"Expense Archives" "Auth"
]
}
},
"/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"
] ]
} }
}, },
@ -221,6 +153,8 @@
] ]
} }
}, },
<<<<<<< HEAD
=======
"/employees/profile/{email}": { "/employees/profile/{email}": {
"get": { "get": {
"operationId": "EmployeesController_findOneProfile", "operationId": "EmployeesController_findOneProfile",
@ -635,6 +569,7 @@
] ]
} }
}, },
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
"/notifications/summary": { "/notifications/summary": {
"get": { "get": {
"operationId": "NotificationsController_summary", "operationId": "NotificationsController_summary",
@ -663,6 +598,8 @@
] ]
} }
}, },
<<<<<<< HEAD
=======
"/leave-requests/upsert": { "/leave-requests/upsert": {
"post": { "post": {
"operationId": "LeaveRequestController_upsertLeaveRequest", "operationId": "LeaveRequestController_upsertLeaveRequest",
@ -734,6 +671,7 @@
] ]
} }
}, },
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
"/oauth-sessions": { "/oauth-sessions": {
"post": { "post": {
"operationId": "OauthSessionsController_create", "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}": { "/preferences/{email}": {
"patch": { "patch": {
"operationId": "PreferencesController_updatePreferences", "operationId": "PreferencesController_updatePreferences",
@ -1306,6 +999,123 @@
"SchedulePresets" "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": { "info": {
@ -1517,6 +1327,8 @@
"first_work_day" "first_work_day"
] ]
}, },
<<<<<<< HEAD
=======
"EmployeeProfileItemDto": { "EmployeeProfileItemDto": {
"type": "object", "type": "object",
"properties": {} "properties": {}
@ -1537,6 +1349,7 @@
"type": "object", "type": "object",
"properties": {} "properties": {}
}, },
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
"CreateOauthSessionDto": { "CreateOauthSessionDto": {
"type": "object", "type": "object",
"properties": { "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": { "PreferencesDto": {
"type": "object", "type": "object",
"properties": {} "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:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"start:variants": "node dist/attachments/workers/variants.worker.js",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
@ -48,17 +49,20 @@
"@nestjs/platform-express": "^11.1.6", "@nestjs/platform-express": "^11.1.6",
"@nestjs/schedule": "^6.0.0", "@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.2.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-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"express-session": "^1.18.2", "express-session": "^1.18.2",
"file-type": "^21.0.0", "file-type": "^21.0.0",
"ioredis": "^5.7.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-openidconnect": "^0.1.2", "passport-openidconnect": "^0.1.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1",
"sharp": "^0.34.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@ -82,7 +86,7 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prisma": "^6.17.0", "prisma": "^6.17.1",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "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) refcount Int @default(0)
created_at DateTime @default(now()) created_at DateTime @default(now())
attachments Attachments[] attachments Attachments[] @relation("AttachmnentBlob")
@@map("blobs") @@map("blobs")
} }
model Attachments { model Attachments {
id Int @id @default(autoincrement()) 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) sha256 String @db.Char(64)
owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc 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 Expenses[] @relation("ExpenseAttachment")
expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment") expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment")
AttachmentVariants AttachmentVariants[] @relation("attachmentVariantAttachment")
@@index([owner_type, owner_id, created_at]) @@index([owner_type, owner_id, created_at])
@@index([sha256]) @@index([sha256])
@@map("attachments") @@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 { model Preferences {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user Users @relation("UserPreferences", fields: [user_id], references: [id]) user Users @relation("UserPreferences", fields: [user_id], references: [id])

View File

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

View File

@ -1,34 +1,34 @@
import { Module } from "@nestjs/common"; // import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule"; // import { ScheduleModule } from "@nestjs/schedule";
import { TimesheetsModule } from "../timesheets/timesheets.module"; // import { TimesheetsModule } from "../timesheets/timesheets.module";
import { ExpensesModule } from "../expenses/expenses.module"; // import { ExpensesModule } from "../expenses/expenses.module";
import { ShiftsModule } from "../shifts/shifts.module"; // import { ShiftsModule } from "../shifts/shifts.module";
import { LeaveRequestsModule } from "../leave-requests/leave-requests.module"; // import { LeaveRequestsModule } from "../leave-requests/leave-requests.module";
import { ArchivalService } from "./services/archival.service"; // import { ArchivalService } from "./services/archival.service";
import { EmployeesArchiveController } from "./controllers/employees-archive.controller"; // import { EmployeesArchiveController } from "./controllers/employees-archive.controller";
import { ExpensesArchiveController } from "./controllers/expenses-archive.controller"; // import { ExpensesArchiveController } from "./controllers/expenses-archive.controller";
import { LeaveRequestsArchiveController } from "./controllers/leave-requests-archive.controller"; // import { LeaveRequestsArchiveController } from "./controllers/leave-requests-archive.controller";
import { ShiftsArchiveController } from "./controllers/shifts-archive.controller"; // import { ShiftsArchiveController } from "./controllers/shifts-archive.controller";
import { TimesheetsArchiveController } from "./controllers/timesheets-archive.controller"; // import { TimesheetsArchiveController } from "./controllers/timesheets-archive.controller";
import { EmployeesModule } from "../employees/employees.module"; // import { EmployeesModule } from "../employees/employees.module";
@Module({ // @Module({
imports: [ // imports: [
EmployeesModule, // EmployeesModule,
ScheduleModule, // ScheduleModule,
TimesheetsModule, // TimesheetsModule,
ExpensesModule, // ExpensesModule,
ShiftsModule, // ShiftsModule,
LeaveRequestsModule, // LeaveRequestsModule,
], // ],
providers: [ArchivalService], // providers: [ArchivalService],
controllers: [ // controllers: [
EmployeesArchiveController, // EmployeesArchiveController,
ExpensesArchiveController, // ExpensesArchiveController,
LeaveRequestsArchiveController, // LeaveRequestsArchiveController,
ShiftsArchiveController, // ShiftsArchiveController,
TimesheetsArchiveController, // 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 { DiskStorageService } from "../services/disk-storage.service";
import { import {
Controller,NotFoundException, UseInterceptors, Post, Get, Param, Res, Controller,NotFoundException, UseInterceptors, Post, Get, Param, Res,
UploadedFile, BadRequestException, UnsupportedMediaTypeException, Body, Delete UploadedFile, BadRequestException, UnsupportedMediaTypeException, Body, Delete,
Query,
DefaultValuePipe,
ParseIntPipe
} from "@nestjs/common"; } from "@nestjs/common";
import { maxUploadBytes, allowedMimes } from "../config/upload.config"; import { maxUploadBytes, allowedMimes } from "../config/upload.config";
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import { fileTypeFromBuffer } from "file-type"; import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { UploadMetaAttachmentsDto } from "../dtos/upload-meta-attachments.dto"; 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 { promises as fsp } from 'node:fs';
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { Response } from 'express'; import { Response } from 'express';
import { VariantsQueue } from "../services/variants.queue";
import { AdminSearchDto } from "../dtos/admin-search.dto";
@Controller('attachments') @Controller('attachments')
export class AttachmentsController { export class AttachmentsController {
constructor( constructor(
private readonly disk: DiskStorageService, private readonly disk: DiskStorageService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly variantsQ: VariantsQueue,
) {} ) {}
@Get(':id') @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); const num_id = Number(id);
if(!Number.isFinite(num_id)) throw new NotFoundException('Invalid 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 }, where: { id: num_id },
include: { blob: true }, 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(), att.blob.storage_path); const abs = path.join(resolveAttachmentsRoot(), relative);
let stat; let stat;
try { try {
stat = await fsp.stat(abs); stat = await fsp.stat(abs);
@ -43,9 +54,14 @@ export class AttachmentsController {
throw new NotFoundException('File not found'); 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('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('Last-Modified', stat.mtime.toUTCString());
res.set('Cache-Control', 'private, max-age=31536000, immutable'); res.set('Cache-Control', 'private, max-age=31536000, immutable');
res.set('X-Content-Type-Options', 'nosniff'); res.set('X-Content-Type-Options', 'nosniff');
@ -53,7 +69,17 @@ export class AttachmentsController {
createReadStream(abs).pipe(res); 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') @Delete(':id')
async remove(@Param('id') id: string) { async remove(@Param('id') id: string) {
const result = await this.prisma.$transaction(async (tx) => { const result = await this.prisma.$transaction(async (tx) => {
@ -136,6 +162,8 @@ export class AttachmentsController {
return att; return att;
}); });
await this.variantsQ.enqueue(attachment.id, detected_mime);
return { return {
ok: true, ok: true,
id: attachment.id, id: attachment.id,
@ -148,4 +176,39 @@ export class AttachmentsController {
owner_id: attachment.owner_id, 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) { private casPath(hash: string) {
const a = hash.slice(0,2), b = hash.slice(2,4); 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 //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,152 +1,247 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils'; 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() @Injectable()
export class OvertimeService { export class OvertimeService {
private logger = new Logger(OvertimeService.name); private logger = new Logger(OvertimeService.name);
private daily_max = 8; // maximum for regular hours per day private daily_max = 8; // maximum for regular hours per day
private weekly_max = 40; //maximum for regular hours per week private weekly_max = 40; // maximum for regular hours per week
private INCLUDED_TYPES = ['EMERGENCY', 'EVENING','OVERTIME','REGULAR'] as const; // included types for weekly overtime calculation private INCLUDED_TYPES = ['EMERGENCY', 'EVENING','OVERTIME','REGULAR'] as const; // included types for weekly overtime calculation
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
//calculate daily overtime async getWeekOvertimeSummary( timesheet_id: number, date: Date, tx?: Tx ): Promise<WeekOvertimeSummary>{
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);
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_end = getWeekEnd(week_start);
//fetches all shifts from INCLUDED_TYPES array
const included_shifts = await this.prisma.shifts.findMany({
where: {
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 },
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; const db = tx ?? this.prisma;
//calculate weekly overtime const week_start = getWeekStart(date);
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); const week_end = getWeekEnd(week_start);
//gets all regular shifts and order them by desc const shifts = await db.shifts.findMany({
const regular_shifts_desc = await db.shifts.findMany({
where: { where: {
date: { gte:week_start, lte: week_end }, timesheet_id,
timesheet: { employee_id }, date: { gte: week_start, lte: week_end },
bank_code_id: regular.id, bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } },
}, },
select: { select: { date: true, start_time: true, end_time: true },
id: true, orderBy: [{date: 'asc'}, {start_time: 'asc'}],
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; const day_totals = new Map<string, number>();
for (const shift of shifts){
for(const shift of regular_shifts_desc) { const key = shift.date.toISOString().slice(0,10);
if(remaining_minutes <= 0) break; const hours = computeHours(shift.start_time, shift.end_time, 5);
day_totals.set(key, (day_totals.get(key) ?? 0) + hours);
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)} const days: string[] = [];
converted= ${(convert_to_minutes-remaining_minutes)/60}h`); 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));
}
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);
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,
};
} }
//apply modifier to overtime hours // //calculate daily overtime
// calculateOvertimePay(overtime_hours: number, modifier: number): number { // async getDailyOvertimeHours(timesheet_id: number, date: Date): Promise<number> {
// const pay = overtime_hours * modifier; // const shifts = await this.prisma.shifts.findMany({
// this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`); // 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' }],
// });
// return pay; // 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 { Body, Controller, Get, Param, Put, } from "@nestjs/common";
import { Roles as RoleEnum } from '.prisma/client'; // import { Roles as RoleEnum } from '.prisma/client';
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; // import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; // import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { ExpensesCommandService } from "../services/expenses-command.service"; // import { ExpensesCommandService } from "../services/expenses-command.service";
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; // import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces"; // import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces";
import { DayExpensesDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; // import { DayExpensesDto } from "src/modules/timesheets/~misc_deprecated-files/timesheet-period.dto";
import { ExpensesQueryService } from "../services/expenses-query.service"; // import { ExpensesQueryService } from "../services/expenses-query.service";
@ApiTags('Expenses') // @ApiTags('Expenses')
@ApiBearerAuth('access-token') // @ApiBearerAuth('access-token')
// @UseGuards() // // @UseGuards()
@Controller('Expenses') // @Controller('Expenses')
export class ExpensesController { // export class ExpensesController {
constructor( // constructor(
private readonly query: ExpensesQueryService, // private readonly query: ExpensesQueryService,
private readonly command: ExpensesCommandService, // private readonly command: ExpensesCommandService,
) {} // ) {}
@Put('upsert/:email/:date') // @Put('upsert/:email/:date')
async upsert_by_date( // async upsert_by_date(
@Param('email') email: string, // @Param('email') email: string,
@Param('date') date: string, // @Param('date') date: string,
@Body() dto: UpsertExpenseDto, // @Body() dto: UpsertExpenseDto,
): Promise<UpsertExpenseResult> { // ): Promise<UpsertExpenseResult> {
return this.command.upsertExpensesByDate(email, date, dto); // return this.command.upsertExpensesByDate(email, date, dto);
} // }
@Get('list/:email/:year/:period_no') // @Get('list/:email/:year/:period_no')
async findExpenseListByPayPeriodAndEmail( // async findExpenseListByPayPeriodAndEmail(
@Param('email') email:string, // @Param('email') email:string,
@Param('year') year: number, // @Param('year') year: number,
@Param('period_no') period_no: number, // @Param('period_no') period_no: number,
): Promise<DayExpensesDto> { // ): Promise<DayExpensesDto> {
return this.query.findExpenseListByPayPeriodAndEmail(email, year, period_no); // return this.query.findExpenseListByPayPeriodAndEmail(email, year, period_no);
} // }
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
// Deprecated or unused methods // // Deprecated or unused methods
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
// @Post() // // @Post()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) // // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Create expense' }) // // @ApiOperation({ summary: 'Create expense' })
// @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto }) // // @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) // // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
// create(@Body() dto: CreateExpenseDto): Promise<Expenses> { // // create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
// return this.query.create(dto); // // return this.query.create(dto);
// } // // }
// @Get() // // @Get()
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) // // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find all expenses' }) // // @ApiOperation({ summary: 'Find all expenses' })
// @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true }) // // @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
// @ApiResponse({ status: 400, description: 'List of expenses not found' }) // // @ApiResponse({ status: 400, description: 'List of expenses not found' })
// @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) // // @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
// findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> { // // findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
// return this.query.findAll(filters); // // return this.query.findAll(filters);
// } // // }
// @Get(':id') // // @Get(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) // // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Find expense' }) // // @ApiOperation({ summary: 'Find expense' })
// @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto }) // // @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Expense not found' }) // // @ApiResponse({ status: 400, description: 'Expense not found' })
// findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> { // // findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
// return this.query.findOne(id); // // return this.query.findOne(id);
// } // // }
// @Patch(':id') // // @Patch(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) // // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Expense shift' }) // // @ApiOperation({ summary: 'Expense shift' })
// @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto }) // // @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Expense not found' }) // // @ApiResponse({ status: 400, description: 'Expense not found' })
// update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) { // // update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
// return this.query.update(id,dto); // // return this.query.update(id,dto);
// } // // }
// @Delete(':id') // // @Delete(':id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) // // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
// @ApiOperation({ summary: 'Delete expense' }) // // @ApiOperation({ summary: 'Delete expense' })
// @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto }) // // @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
// @ApiResponse({ status: 400, description: 'Expense not found' }) // // @ApiResponse({ status: 400, description: 'Expense not found' })
// remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> { // // remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
// return this.query.remove(id); // // return this.query.remove(id);
// } // // }
// @Patch('approval/:id') // // @Patch('approval/:id')
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) // // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
// async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { // // async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
// return this.command.updateApproval(id, isApproved); // // return this.command.updateApproval(id, isApproved);
// } // // }
} // }

View File

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

View File

@ -1,250 +1,249 @@
import { BaseApprovalService } from "src/common/shared/base-approval.service"; // import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { Expenses, Prisma } from "@prisma/client"; // import { Expenses, Prisma } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service"; // import { PrismaService } from "src/prisma/prisma.service";
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; // import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; // import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; // import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; // import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils"; // import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils";
import { // import {
BadRequestException, // BadRequestException,
Injectable, // Injectable,
NotFoundException // NotFoundException
} from "@nestjs/common"; // } from "@nestjs/common";
import { // import {
assertAndTrimComment, // assertAndTrimComment,
computeAmountDecimal, // computeAmountDecimal,
computeMileageAmount, // computeMileageAmount,
mapDbExpenseToDayResponse, // mapDbExpenseToDayResponse,
normalizeType, // normalizeType,
parseAttachmentId // parseAttachmentId
} from "../utils/expenses.utils"; // } from "../utils/expenses.utils";
import { toDateOnly } from "src/modules/shifts/helpers/shifts-date-time-helpers";
@Injectable() // @Injectable()
export class ExpensesCommandService extends BaseApprovalService<Expenses> { // export class ExpensesCommandService extends BaseApprovalService<Expenses> {
constructor( // constructor(
prisma: PrismaService, // prisma: PrismaService,
private readonly bankCodesResolver: BankCodesResolver, // private readonly bankCodesResolver: BankCodesResolver,
private readonly timesheetsResolver: EmployeeTimesheetResolver, // private readonly timesheetsResolver: EmployeeTimesheetResolver,
private readonly emailResolver: EmailToIdResolver, // private readonly emailResolver: EmailToIdResolver,
) { super(prisma); } // ) { super(prisma); }
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
// APPROVAL TX-DELEGATE METHODS // // APPROVAL TX-DELEGATE METHODS
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
protected get delegate() { // protected get delegate() {
return this.prisma.expenses; // return this.prisma.expenses;
} // }
protected delegateFor(transaction: Prisma.TransactionClient){ // protected delegateFor(transaction: Prisma.TransactionClient){
return transaction.expenses; // return transaction.expenses;
} // }
async updateApproval(id: number, isApproved: boolean): Promise<Expenses> { // async updateApproval(id: number, isApproved: boolean): Promise<Expenses> {
return this.prisma.$transaction((transaction) => // return this.prisma.$transaction((transaction) =>
this.updateApprovalWithTransaction(transaction, id, isApproved), // this.updateApprovalWithTransaction(transaction, id, isApproved),
); // );
} // }
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
// MASTER CRUD FUNCTION // // MASTER CRUD FUNCTION
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto, // readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => { // ): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
//validates if there is an existing expense, at least 1 old or new // //validates if there is an existing expense, at least 1 old or new
const { old_expense, new_expense } = dto ?? {}; // const { old_expense, new_expense } = dto ?? {};
if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided'); // if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided');
//validate date format // //validate date format
const date_only = toDateOnly(date); // const date_only = toDateOnly(date);
if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)'); // if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
//resolve employee_id by email // //resolve employee_id by email
const employee_id = await this.emailResolver.findIdByEmail(email); // const employee_id = await this.emailResolver.findIdByEmail(email);
//make sure a timesheet existes // //make sure a timesheet existes
const timesheet_id = await this.timesheetsResolver.findTimesheetIdByEmail(email, date_only); // const timesheet_id = await this.timesheetsResolver.findTimesheetIdByEmail(email, date_only);
if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`) // if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`)
const {id} = timesheet_id; // const {id} = timesheet_id;
return this.prisma.$transaction(async (tx) => { // return this.prisma.$transaction(async (tx) => {
const loadDay = async (): Promise<ExpenseResponse[]> => { // const loadDay = async (): Promise<ExpenseResponse[]> => {
const rows = await tx.expenses.findMany({ // const rows = await tx.expenses.findMany({
where: { // where: {
timesheet_id: id, // timesheet_id: id,
date: date_only, // date: date_only,
}, // },
include: { // include: {
bank_code: { // bank_code: {
select: { // select: {
type: true, // type: true,
}, // },
}, // },
}, // },
orderBy: [{ date: 'asc' }, { id: 'asc' }], // orderBy: [{ date: 'asc' }, { id: 'asc' }],
}); // });
return rows.map((r) => // return rows.map((r) =>
mapDbExpenseToDayResponse({ // mapDbExpenseToDayResponse({
date: r.date, // date: r.date,
amount: r.amount ?? 0, // amount: r.amount ?? 0,
mileage: r.mileage ?? 0, // mileage: r.mileage ?? 0,
comment: r.comment, // comment: r.comment,
is_approved: r.is_approved, // is_approved: r.is_approved,
bank_code: r.bank_code, // bank_code: r.bank_code,
})); // }));
}; // };
const normalizePayload = async (payload: { // const normalizePayload = async (payload: {
type: string; // type: string;
amount?: number; // amount?: number;
mileage?: number; // mileage?: number;
comment: string; // comment: string;
attachment?: string | number; // attachment?: string | number;
}): Promise<{ // }): Promise<{
type: string; // type: string;
bank_code_id: number; // bank_code_id: number;
amount: Prisma.Decimal; // amount: Prisma.Decimal;
mileage: number | null; // mileage: number | null;
comment: string; // comment: string;
attachment: number | null; // attachment: number | null;
}> => { // }> => {
const type = normalizeType(payload.type); // const type = normalizeType(payload.type);
const comment = assertAndTrimComment(payload.comment); // const comment = assertAndTrimComment(payload.comment);
const attachment = parseAttachmentId(payload.attachment); // const attachment = parseAttachmentId(payload.attachment);
const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type); // const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type);
let amount = computeAmountDecimal(type, payload, modifier); // let amount = computeAmountDecimal(type, payload, modifier);
let mileage: number | null = null; // let mileage: number | null = null;
if (type === 'MILEAGE') { // if (type === 'MILEAGE') {
mileage = Number(payload.mileage ?? 0); // mileage = Number(payload.mileage ?? 0);
if (!(mileage > 0)) { // if (!(mileage > 0)) {
throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE'); // throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE');
} // }
const amountNumber = computeMileageAmount(mileage, modifier); // const amountNumber = computeMileageAmount(mileage, modifier);
amount = new Prisma.Decimal(amountNumber); // amount = new Prisma.Decimal(amountNumber);
} else { // } else {
if (!(typeof payload.amount === 'number' && payload.amount >= 0)) { // if (!(typeof payload.amount === 'number' && payload.amount >= 0)) {
throw new BadRequestException('Amount required for non-MILEAGE expense'); // throw new BadRequestException('Amount required for non-MILEAGE expense');
} // }
amount = new Prisma.Decimal(payload.amount); // amount = new Prisma.Decimal(payload.amount);
} // }
if (attachment !== null) { // if (attachment !== null) {
const attachment_row = await tx.attachments.findUnique({ // const attachment_row = await tx.attachments.findUnique({
where: { id: attachment }, // where: { id: attachment },
select: { status: true }, // select: { status: true },
}); // });
if (!attachment_row || attachment_row.status !== 'ACTIVE') { // if (!attachment_row || attachment_row.status !== 'ACTIVE') {
throw new BadRequestException('Attachment not found or inactive'); // throw new BadRequestException('Attachment not found or inactive');
} // }
} // }
return { // return {
type, // type,
bank_code_id, // bank_code_id,
amount, // amount,
mileage, // mileage,
comment, // comment,
attachment // attachment
}; // };
}; // };
const findExactOld = async (norm: { // const findExactOld = async (norm: {
bank_code_id: number; // bank_code_id: number;
amount: Prisma.Decimal; // amount: Prisma.Decimal;
mileage: number | null; // mileage: number | null;
comment: string; // comment: string;
attachment: number | null; // attachment: number | null;
}) => { // }) => {
return tx.expenses.findFirst({ // return tx.expenses.findFirst({
where: { // where: {
timesheet_id: id, // timesheet_id: id,
date: date_only, // date: date_only,
bank_code_id: norm.bank_code_id, // bank_code_id: norm.bank_code_id,
amount: norm.amount, // amount: norm.amount,
comment: norm.comment, // comment: norm.comment,
attachment: norm.attachment, // attachment: norm.attachment,
...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }), // ...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }),
}, // },
select: { id: true }, // select: { id: true },
}); // });
}; // };
let action : UpsertAction; // let action : UpsertAction;
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
// DELETE // // DELETE
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
if(old_expense && !new_expense) { // if(old_expense && !new_expense) {
const old_norm = await normalizePayload(old_expense); // const old_norm = await normalizePayload(old_expense);
const existing = await findExactOld(old_norm); // const existing = await findExactOld(old_norm);
if(!existing) { // if(!existing) {
throw new NotFoundException({ // throw new NotFoundException({
error_code: 'EXPENSE_STALE', // error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else', // message: 'The expense was modified or deleted by someone else',
}); // });
} // }
await tx.expenses.delete({where: { id: existing.id } }); // await tx.expenses.delete({where: { id: existing.id } });
action = 'delete'; // action = 'delete';
} // }
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
// CREATE // // CREATE
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
else if (!old_expense && new_expense) { // else if (!old_expense && new_expense) {
const new_exp = await normalizePayload(new_expense); // const new_exp = await normalizePayload(new_expense);
await tx.expenses.create({ // await tx.expenses.create({
data: { // data: {
timesheet_id: id, // timesheet_id: id,
date: date_only, // date: date_only,
bank_code_id: new_exp.bank_code_id, // bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount, // amount: new_exp.amount,
mileage: new_exp.mileage, // mileage: new_exp.mileage,
comment: new_exp.comment, // comment: new_exp.comment,
attachment: new_exp.attachment, // attachment: new_exp.attachment,
is_approved: false, // is_approved: false,
}, // },
}); // });
action = 'create'; // action = 'create';
} // }
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
// UPDATE // // UPDATE
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
else if(old_expense && new_expense) { // else if(old_expense && new_expense) {
const old_norm = await normalizePayload(old_expense); // const old_norm = await normalizePayload(old_expense);
const existing = await findExactOld(old_norm); // const existing = await findExactOld(old_norm);
if(!existing) { // if(!existing) {
throw new NotFoundException({ // throw new NotFoundException({
error_code: 'EXPENSE_STALE', // error_code: 'EXPENSE_STALE',
message: 'The expense was modified or deleted by someone else', // message: 'The expense was modified or deleted by someone else',
}); // });
} // }
const new_exp = await normalizePayload(new_expense); // const new_exp = await normalizePayload(new_expense);
await tx.expenses.update({ // await tx.expenses.update({
where: { id: existing.id }, // where: { id: existing.id },
data: { // data: {
bank_code_id: new_exp.bank_code_id, // bank_code_id: new_exp.bank_code_id,
amount: new_exp.amount, // amount: new_exp.amount,
mileage: new_exp.mileage, // mileage: new_exp.mileage,
comment: new_exp.comment, // comment: new_exp.comment,
attachment: new_exp.attachment, // attachment: new_exp.attachment,
}, // },
}); // });
action = 'update'; // action = 'update';
} // }
else { // else {
throw new BadRequestException('Invalid upsert combination'); // 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 { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service"; // import { PrismaService } from "src/prisma/prisma.service";
import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; // import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
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";
@Injectable() // @Injectable()
export class ExpensesQueryService { // export class ExpensesQueryService {
constructor( // constructor(
private readonly prisma: PrismaService, // private readonly prisma: PrismaService,
private readonly employeeRepo: EmailToIdResolver, // private readonly employeeRepo: EmailToIdResolver,
) {} // ) {}
//fetchs all expenses for a selected employee using email, pay-period-year and number // //fetchs all expenses for a selected employee using email, pay-period-year and number
async findExpenseListByPayPeriodAndEmail( // async findExpenseListByPayPeriodAndEmail(
email: string, // email: string,
year: number, // year: number,
period_no: number // period_no: number
): Promise<ExpenseListResponseDto> { // ): Promise<ExpenseListResponseDto> {
//fetch employe_id using email // //fetch employe_id using email
const employee_id = await this.employeeRepo.findIdByEmail(email); // const employee_id = await this.employeeRepo.findIdByEmail(email);
if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); // if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
//fetch pay-period using year and period_no // //fetch pay-period using year and period_no
const pay_period = await this.prisma.payPeriods.findFirst({ // const pay_period = await this.prisma.payPeriods.findFirst({
where: { // where: {
pay_year: year, // pay_year: year,
pay_period_no: period_no // pay_period_no: period_no
}, // },
select: { period_start: true, period_end: true }, // select: { period_start: true, period_end: true },
}); // });
if(!pay_period) throw new NotFoundException(`Pay period ${year}- ${period_no} not found`); // if(!pay_period) throw new NotFoundException(`Pay period ${year}- ${period_no} not found`);
const start = toUTCDateOnly(pay_period.period_start); // const start = toUTCDateOnly(pay_period.period_start);
const end = toUTCDateOnly(pay_period.period_end); // const end = toUTCDateOnly(pay_period.period_end);
//sets rows data // //sets rows data
const rows = await this.prisma.expenses.findMany({ // const rows = await this.prisma.expenses.findMany({
where: { // where: {
date: { gte: start, lte: end }, // date: { gte: start, lte: end },
timesheet: { is: { employee_id } }, // timesheet: { is: { employee_id } },
}, // },
orderBy: { date: 'asc'}, // orderBy: { date: 'asc'},
select: { // select: {
amount: true, // amount: true,
mileage: true, // mileage: true,
comment: true, // comment: true,
is_approved: true, // is_approved: true,
supervisor_comment: true, // supervisor_comment: true,
bank_code: {select: { type: true } }, // bank_code: {select: { type: true } },
}, // },
}); // });
//declare return values // //declare return values
const expenses: ExpenseDto[] = []; // const expenses: ExpenseDto[] = [];
let total_amount = 0; // let total_amount = 0;
let total_mileage = 0; // let total_mileage = 0;
//set rows // //set rows
for(const row of rows) { // for(const row of rows) {
const type = (row.bank_code?.type ?? '').toUpperCase(); // const type = (row.bank_code?.type ?? '').toUpperCase();
const amount = round2(Number(row.amount ?? 0)); // const amount = round2(Number(row.amount ?? 0));
const mileage = round2(Number(row.mileage ?? 0)); // const mileage = round2(Number(row.mileage ?? 0));
if(type === EXPENSE_TYPES.MILEAGE) { // if(type === EXPENSE_TYPES.MILEAGE) {
total_mileage += mileage; // total_mileage += mileage;
} else { // } else {
total_amount += amount; // total_amount += amount;
} // }
//fills rows array // //fills rows array
expenses.push({ // expenses.push({
type, // type,
amount, // amount,
mileage, // mileage,
comment: row.comment ?? '', // comment: row.comment ?? '',
is_approved: row.is_approved ?? false, // is_approved: row.is_approved ?? false,
supervisor_comment: row.supervisor_comment ?? '', // supervisor_comment: row.supervisor_comment ?? '',
}); // });
} // }
return { // return {
expenses, // expenses,
total_expense: round2(total_amount), // total_expense: round2(total_amount),
total_mileage: round2(total_mileage), // total_mileage: round2(total_mileage),
}; // };
} // }
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
// Deprecated or unused methods // // Deprecated or unused methods
//_____________________________________________________________________________________________ // //_____________________________________________________________________________________________
// async create(dto: CreateExpenseDto): Promise<Expenses> { // // async create(dto: CreateExpenseDto): Promise<Expenses> {
// const { timesheet_id, bank_code_id, date, amount:rawAmount, // // const { timesheet_id, bank_code_id, date, amount:rawAmount,
// comment, is_approved,supervisor_comment} = dto; // // comment, is_approved,supervisor_comment} = dto;
// //fetches type and modifier // // //fetches type and modifier
// const bank_code = await this.prisma.bankCodes.findUnique({ // // const bank_code = await this.prisma.bankCodes.findUnique({
// where: { id: bank_code_id }, // // where: { id: bank_code_id },
// select: { type: true, modifier: true }, // // select: { type: true, modifier: true },
// }); // // });
// if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`); // // if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`);
// //if mileage -> service, otherwise the ratio is amount:1 // // //if mileage -> service, otherwise the ratio is amount:1
// let final_amount: number; // // let final_amount: number;
// if(bank_code.type === 'mileage') { // // if(bank_code.type === 'mileage') {
// final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id); // // final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
// }else { // // }else {
// final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2)); // // final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
// } // // }
// return this.prisma.expenses.create({ // // return this.prisma.expenses.create({
// data: { // // data: {
// timesheet_id, // // timesheet_id,
// bank_code_id, // // bank_code_id,
// date, // // date,
// amount: final_amount, // // amount: final_amount,
// comment, // // comment,
// is_approved, // // is_approved,
// supervisor_comment // // supervisor_comment
// }, // // },
// include: { timesheet: { include: { employee: { include: { user: true }}}}, // // include: { timesheet: { include: { employee: { include: { user: true }}}},
// bank_code: true, // // bank_code: true,
// }, // // },
// }) // // })
// } // // }
// async findAll(filters: SearchExpensesDto): Promise<Expenses[]> { // // async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
// const where = buildPrismaWhere(filters); // // const where = buildPrismaWhere(filters);
// const expenses = await this.prisma.expenses.findMany({ where }) // // const expenses = await this.prisma.expenses.findMany({ where })
// return expenses; // // return expenses;
// } // // }
// async findOne(id: number): Promise<Expenses> { // // async findOne(id: number): Promise<Expenses> {
// const expense = await this.prisma.expenses.findUnique({ // // const expense = await this.prisma.expenses.findUnique({
// where: { id }, // // where: { id },
// include: { timesheet: { include: { employee: { include: { user:true } } } }, // // include: { timesheet: { include: { employee: { include: { user:true } } } },
// bank_code: true, // // bank_code: true,
// }, // // },
// }); // // });
// if (!expense) { // // if (!expense) {
// throw new NotFoundException(`Expense #${id} not found`); // // throw new NotFoundException(`Expense #${id} not found`);
// } // // }
// return expense; // // return expense;
// } // // }
// async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> { // // async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> {
// await this.findOne(id); // // await this.findOne(id);
// const { timesheet_id, bank_code_id, date, amount, // // const { timesheet_id, bank_code_id, date, amount,
// comment, is_approved, supervisor_comment} = dto; // // comment, is_approved, supervisor_comment} = dto;
// return this.prisma.expenses.update({ // // return this.prisma.expenses.update({
// where: { id }, // // where: { id },
// data: { // // data: {
// ...(timesheet_id !== undefined && { timesheet_id}), // // ...(timesheet_id !== undefined && { timesheet_id}),
// ...(bank_code_id !== undefined && { bank_code_id }), // // ...(bank_code_id !== undefined && { bank_code_id }),
// ...(date !== undefined && { date }), // // ...(date !== undefined && { date }),
// ...(amount !== undefined && { amount }), // // ...(amount !== undefined && { amount }),
// ...(comment !== undefined && { comment }), // // ...(comment !== undefined && { comment }),
// ...(is_approved !== undefined && { is_approved }), // // ...(is_approved !== undefined && { is_approved }),
// ...(supervisor_comment !== undefined && { supervisor_comment }), // // ...(supervisor_comment !== undefined && { supervisor_comment }),
// }, // // },
// include: { timesheet: { include: { employee: { include: { user: true } } } }, // // include: { timesheet: { include: { employee: { include: { user: true } } } },
// bank_code: true, // // bank_code: true,
// }, // // },
// }); // // });
// } // // }
// async remove(id: number): Promise<Expenses> { // // async remove(id: number): Promise<Expenses> {
// await this.findOne(id); // // await this.findOne(id);
// return this.prisma.expenses.delete({ where: { id } }); // // return this.prisma.expenses.delete({ where: { id } });
// } // // }
} // }

View File

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

View File

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

View File

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

View File

@ -1,98 +1,98 @@
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; // import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; // import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; // import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; // import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { leaveRequestsSelect } from "../utils/leave-requests.select"; // import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { mapRowToView } from "../mappers/leave-requests.mapper"; // import { mapRowToView } from "../mappers/leave-requests.mapper";
import { PrismaService } from "src/prisma/prisma.service"; // import { PrismaService } from "src/prisma/prisma.service";
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; // import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
import { roundToQuarterHour } from "src/common/utils/date-utils"; // import { roundToQuarterHour } from "src/common/utils/date-utils";
import { LeaveRequestsUtils } from "../utils/leave-request.util"; // import { LeaveRequestsUtils } from "../utils/leave-request.util";
import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers"; // import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; // import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; // import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
@Injectable() // @Injectable()
export class SickLeaveRequestsService { // export class SickLeaveRequestsService {
constructor( // constructor(
private readonly prisma: PrismaService, // private readonly prisma: PrismaService,
private readonly sickService: SickLeaveService, // private readonly sickService: SickLeaveService,
private readonly leaveUtils: LeaveRequestsUtils, // private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver, // private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver, // private readonly typeResolver: BankCodesResolver,
) {} // ) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> { // async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim(); // const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email); // const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findByType(LeaveTypes.SICK); // const bank_code = await this.typeResolver.findByType(LeaveTypes.SICK);
if(!bank_code) throw new NotFoundException(`bank_code not found`); // if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = bank_code.modifier ?? 1; // const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates); // const dates = normalizeDates(dto.dates);
if (!dates.length) throw new BadRequestException("Dates array must not be empty"); // if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const requested_hours_per_day = dto.requested_hours ?? 8; // const requested_hours_per_day = dto.requested_hours ?? 8;
const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) })); // const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) }));
const reference_date = entries.reduce( // const reference_date = entries.reduce(
(latest, entry) => (entry.date > latest ? entry.date : latest), // (latest, entry) => (entry.date > latest ? entry.date : latest),
entries[0].date, // entries[0].date,
); // );
const total_payable_hours = await this.sickService.calculateSickLeavePay( // const total_payable_hours = await this.sickService.calculateSickLeavePay(
employee_id, // employee_id,
reference_date, // reference_date,
entries.length, // entries.length,
requested_hours_per_day, // requested_hours_per_day,
modifier, // modifier,
); // );
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); // let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); // const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = []; // const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) { // for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({ // const existing = await this.prisma.leaveRequests.findUnique({
where: { // where: {
leave_per_employee_date: { // leave_per_employee_date: {
employee_id: employee_id, // employee_id: employee_id,
leave_type: LeaveTypes.SICK, // leave_type: LeaveTypes.SICK,
date, // date,
}, // },
}, // },
select: { id: true }, // select: { id: true },
}); // });
if (existing) { // if (existing) {
throw new BadRequestException(`Sick request already exists for ${iso}`); // throw new BadRequestException(`Sick request already exists for ${iso}`);
} // }
const payable = Math.min(remaining_payable_hours, daily_payable_cap); // const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable)); // const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour( // remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded), // Math.max(0, remaining_payable_hours - payable_rounded),
); // );
const row = await this.prisma.leaveRequests.create({ // const row = await this.prisma.leaveRequests.create({
data: { // data: {
employee_id: employee_id, // employee_id: employee_id,
bank_code_id: bank_code.id, // bank_code_id: bank_code.id,
leave_type: LeaveTypes.SICK, // leave_type: LeaveTypes.SICK,
comment: dto.comment ?? "", // comment: dto.comment ?? "",
requested_hours: requested_hours_per_day, // requested_hours: requested_hours_per_day,
payable_hours: payable_rounded, // payable_hours: payable_rounded,
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, // approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date, // date,
}, // },
select: leaveRequestsSelect, // select: leaveRequestsSelect,
}); // });
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); // const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) { // if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment); // 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 { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; // import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; // import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; // import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
import { VacationService } from "src/modules/business-logics/services/vacation.service"; // import { VacationService } from "src/modules/business-logics/services/vacation.service";
import { PrismaService } from "src/prisma/prisma.service"; // import { PrismaService } from "src/prisma/prisma.service";
import { mapRowToView } from "../mappers/leave-requests.mapper"; // import { mapRowToView } from "../mappers/leave-requests.mapper";
import { leaveRequestsSelect } from "../utils/leave-requests.select"; // import { leaveRequestsSelect } from "../utils/leave-requests.select";
import { roundToQuarterHour } from "src/common/utils/date-utils"; // import { roundToQuarterHour } from "src/common/utils/date-utils";
import { LeaveRequestsUtils } from "../utils/leave-request.util"; // import { LeaveRequestsUtils } from "../utils/leave-request.util";
import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers"; // import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; // import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; // import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
@Injectable() // @Injectable()
export class VacationLeaveRequestsService { // export class VacationLeaveRequestsService {
constructor( // constructor(
private readonly prisma: PrismaService, // private readonly prisma: PrismaService,
private readonly vacationService: VacationService, // private readonly vacationService: VacationService,
private readonly leaveUtils: LeaveRequestsUtils, // private readonly leaveUtils: LeaveRequestsUtils,
private readonly emailResolver: EmailToIdResolver, // private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver, // private readonly typeResolver: BankCodesResolver,
) {} // ) {}
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> { // async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
const email = dto.email.trim(); // const email = dto.email.trim();
const employee_id = await this.emailResolver.findIdByEmail(email); // const employee_id = await this.emailResolver.findIdByEmail(email);
const bank_code = await this.typeResolver.findByType(LeaveTypes.VACATION); // const bank_code = await this.typeResolver.findByType(LeaveTypes.VACATION);
if(!bank_code) throw new NotFoundException(`bank_code not found`); // if(!bank_code) throw new NotFoundException(`bank_code not found`);
const modifier = bank_code.modifier ?? 1; // const modifier = bank_code.modifier ?? 1;
const dates = normalizeDates(dto.dates); // const dates = normalizeDates(dto.dates);
const requested_hours_per_day = dto.requested_hours ?? 8; // const requested_hours_per_day = dto.requested_hours ?? 8;
if (!dates.length) throw new BadRequestException("Dates array must not be empty"); // if (!dates.length) throw new BadRequestException("Dates array must not be empty");
const entries = dates // const entries = dates
.map((iso) => ({ iso, date: toDateOnly(iso) })) // .map((iso) => ({ iso, date: toDateOnly(iso) }))
.sort((a, b) => a.date.getTime() - b.date.getTime()); // .sort((a, b) => a.date.getTime() - b.date.getTime());
const start_date = entries[0].date; // const start_date = entries[0].date;
const total_payable_hours = await this.vacationService.calculateVacationPay( // const total_payable_hours = await this.vacationService.calculateVacationPay(
employee_id, // employee_id,
start_date, // start_date,
entries.length, // entries.length,
modifier, // modifier,
); // );
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); // let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); // const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
const created: LeaveRequestViewDto[] = []; // const created: LeaveRequestViewDto[] = [];
for (const { iso, date } of entries) { // for (const { iso, date } of entries) {
const existing = await this.prisma.leaveRequests.findUnique({ // const existing = await this.prisma.leaveRequests.findUnique({
where: { // where: {
leave_per_employee_date: { // leave_per_employee_date: {
employee_id: employee_id, // employee_id: employee_id,
leave_type: LeaveTypes.VACATION, // leave_type: LeaveTypes.VACATION,
date, // date,
}, // },
}, // },
select: { id: true }, // select: { id: true },
}); // });
if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`); // if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`);
const payable = Math.min(remaining_payable_hours, daily_payable_cap); // const payable = Math.min(remaining_payable_hours, daily_payable_cap);
const payable_rounded = roundToQuarterHour(Math.max(0, payable)); // const payable_rounded = roundToQuarterHour(Math.max(0, payable));
remaining_payable_hours = roundToQuarterHour( // remaining_payable_hours = roundToQuarterHour(
Math.max(0, remaining_payable_hours - payable_rounded), // Math.max(0, remaining_payable_hours - payable_rounded),
); // );
const row = await this.prisma.leaveRequests.create({ // const row = await this.prisma.leaveRequests.create({
data: { // data: {
employee_id: employee_id, // employee_id: employee_id,
bank_code_id: bank_code.id, // bank_code_id: bank_code.id,
payable_hours: payable_rounded, // payable_hours: payable_rounded,
requested_hours: requested_hours_per_day, // requested_hours: requested_hours_per_day,
leave_type: LeaveTypes.VACATION, // leave_type: LeaveTypes.VACATION,
comment: dto.comment ?? "", // comment: dto.comment ?? "",
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, // approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
date, // date,
}, // },
select: leaveRequestsSelect, // select: leaveRequestsSelect,
}); // });
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); // const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
if (row.approval_status === LeaveApprovalStatus.APPROVED) { // if (row.approval_status === LeaveApprovalStatus.APPROVED) {
await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment); // await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, 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,105 +1,104 @@
import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers"; // import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers";
import { BadRequestException, Injectable } from "@nestjs/common"; // import { BadRequestException, Injectable } from "@nestjs/common";
import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service"; // import { PrismaService } from "src/prisma/prisma.service";
import { PrismaService } from "src/prisma/prisma.service"; // import { LeaveTypes } from "@prisma/client";
import { LeaveTypes } from "@prisma/client"; // import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
@Injectable() // @Injectable()
export class LeaveRequestsUtils { // export class LeaveRequestsUtils {
constructor( // constructor(
private readonly prisma: PrismaService, // private readonly prisma: PrismaService,
private readonly shiftsCommand: ShiftsCommandService, // private readonly shiftsCommand: ShiftsCommandService,
){} // ){}
async syncShift( // async syncShift(
email: string, // email: string,
employee_id: number, // employee_id: number,
date: string, // date: string,
hours: number, // hours: number,
type: LeaveTypes, // type: LeaveTypes,
comment?: string, // comment?: string,
) { // ) {
if (hours <= 0) return; // if (hours <= 0) return;
const duration_minutes = Math.round(hours * 60); // const duration_minutes = Math.round(hours * 60);
if (duration_minutes > 8 * 60) { // if (duration_minutes > 8 * 60) {
throw new BadRequestException("Amount of hours cannot exceed 8 hours per day."); // throw new BadRequestException("Amount of hours cannot exceed 8 hours per day.");
} // }
const date_only = toDateOnly(date); // const date_only = toDateOnly(date);
const yyyy_mm_dd = toStringFromDate(date_only); // const yyyy_mm_dd = toStringFromDate(date_only);
const start_minutes = 8 * 60; // const start_minutes = 8 * 60;
const end_minutes = start_minutes + duration_minutes; // const end_minutes = start_minutes + duration_minutes;
const toHHmm = (total: number) => // const toHHmm = (total: number) =>
`${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; // `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
const existing = await this.prisma.shifts.findFirst({ // const existing = await this.prisma.shifts.findFirst({
where: { // where: {
date: date_only, // date: date_only,
bank_code: { type }, // bank_code: { type },
timesheet: { employee_id: employee_id }, // timesheet: { employee_id: employee_id },
}, // },
include: { bank_code: true }, // include: { bank_code: true },
}); // });
const action: UpsertAction = existing ? 'update' : 'create'; // const action: UpsertAction = existing ? 'update' : 'create';
await this.shiftsCommand.upsertShifts(email, action, { // await this.shiftsCommand.upsertShifts(email, action, {
old_shift: existing // old_shift: existing
? { // ? {
date: yyyy_mm_dd, // date: yyyy_mm_dd,
start_time: existing.start_time.toISOString().slice(11, 16), // start_time: existing.start_time.toISOString().slice(11, 16),
end_time: existing.end_time.toISOString().slice(11, 16), // end_time: existing.end_time.toISOString().slice(11, 16),
type: existing.bank_code?.type ?? type, // type: existing.bank_code?.type ?? type,
is_remote: existing.is_remote, // is_remote: existing.is_remote,
is_approved:existing.is_approved, // is_approved:existing.is_approved,
comment: existing.comment ?? undefined, // comment: existing.comment ?? undefined,
} // }
: undefined, // : undefined,
new_shift: { // new_shift: {
date: yyyy_mm_dd, // date: yyyy_mm_dd,
start_time: toHHmm(start_minutes), // start_time: toHHmm(start_minutes),
end_time: toHHmm(end_minutes), // end_time: toHHmm(end_minutes),
is_remote: existing?.is_remote ?? false, // is_remote: existing?.is_remote ?? false,
is_approved:existing?.is_approved ?? false, // is_approved:existing?.is_approved ?? false,
comment: comment ?? existing?.comment ?? "", // comment: comment ?? existing?.comment ?? "",
type: type, // type: type,
}, // },
}); // });
} // }
async removeShift( // async removeShift(
email: string, // email: string,
employee_id: number, // employee_id: number,
iso_date: string, // iso_date: string,
type: LeaveTypes, // type: LeaveTypes,
) { // ) {
const date_only = toDateOnly(iso_date); // const date_only = toDateOnly(iso_date);
const yyyy_mm_dd = toStringFromDate(date_only); // const yyyy_mm_dd = toStringFromDate(date_only);
const existing = await this.prisma.shifts.findFirst({ // const existing = await this.prisma.shifts.findFirst({
where: { // where: {
date: date_only, // date: date_only,
bank_code: { type }, // bank_code: { type },
timesheet: { employee_id: employee_id }, // timesheet: { employee_id: employee_id },
}, // },
include: { bank_code: true }, // include: { bank_code: true },
}); // });
if (!existing) return; // if (!existing) return;
await this.shiftsCommand.upsertShifts(email, 'delete', { // await this.shiftsCommand.upsertShifts(email, 'delete', {
old_shift: { // old_shift: {
date: yyyy_mm_dd, // date: yyyy_mm_dd,
start_time: hhmmFromLocal(existing.start_time), // start_time: hhmmFromLocal(existing.start_time),
end_time: hhmmFromLocal(existing.end_time), // end_time: hhmmFromLocal(existing.end_time),
type: existing.bank_code?.type ?? type, // type: existing.bank_code?.type ?? type,
is_remote: existing.is_remote, // is_remote: existing.is_remote,
is_approved:existing.is_approved, // is_approved:existing.is_approved,
comment: existing.comment ?? undefined, // comment: existing.comment ?? undefined,
}, // },
}); // });
} // }
} // }

View File

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

View File

@ -1,14 +1,14 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; 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 { PrismaService } from "src/prisma/prisma.service";
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto"; import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
import { PayPeriodsQueryService } from "./pay-periods-query.service"; import { PayPeriodsQueryService } from "./pay-periods-query.service";
import { TimesheetApprovalService } from "src/modules/timesheets/services/timesheet-approval.service";
@Injectable() @Injectable()
export class PayPeriodsCommandService { export class PayPeriodsCommandService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly timesheets_approval: TimesheetsCommandService, private readonly timesheets_approval: TimesheetApprovalService,
private readonly query: PayPeriodsQueryService, 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 { export function weekStartSunday(date_local: Date): Date {
const start = new Date(Date.UTC(date_local.getFullYear(), date_local.getMonth(), date_local.getDate())); const start = new Date(Date.UTC(date_local.getFullYear(), date_local.getMonth(), date_local.getDate()));
const dow = start.getDay(); // 0 = dimanche const dow = start.getDay(); // 0 = dimanche
@ -18,8 +6,26 @@ export function weekStartSunday(date_local: Date): Date {
return start; return start;
} }
export function formatHHmm(t: Date): string { //converts string to HHmm format
const hh = String(t.getHours()).padStart(2, '0'); export const toStringFromHHmm = (date: Date): string => {
const mm = String(t.getMinutes()).padStart(2, '0'); const hh = date.getUTCHours().toString().padStart(2, '0');
const mm = date.getUTCMinutes().toString().padStart(2, '0');
return `${hh}:${mm}`; 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,30 +1,24 @@
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 { 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 { SharedModule } from '../shared/shared.module';
import { ShiftsHelpersService } from './helpers/shifts.helpers'; import { Module } from '@nestjs/common';
@Module({ @Module({
imports: [ imports: [
BusinessLogicsModule, BusinessLogicsModule,
NotificationsModule, NotificationsModule,
SharedModule, SharedModule,
], ],
controllers: [ShiftsController], controllers: [ShiftController],
providers: [ providers: [
ShiftsQueryService,
ShiftsCommandService,
ShiftsArchivalService, ShiftsArchivalService,
ShiftsHelpersService, ShiftsGetService,
], ShiftsUpsertService,
exports: [
ShiftsQueryService,
ShiftsCommandService,
ShiftsArchivalService,
], ],
exports: [ ShiftsUpsertService, ShiftsGetService ],
}) })
export class ShiftsModule {} 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 { export class Timesheets {
user_id: number; employee_fullname: string;
timesheets: Timesheet[];
} }
export class Timesheet {
export class Timesheets {
timesheet_id: number; timesheet_id: number;
is_approved: boolean;
days: TimesheetDay[]; days: TimesheetDay[];
weekly_hours: TotalHours[]; weekly_hours: TotalHours[];
weekly_expenses: TotalExpenses[]; weekly_expenses: TotalExpenses[];
@ -30,15 +30,15 @@ export class TotalHours {
} }
export class TotalExpenses { export class TotalExpenses {
expenses: number; expenses: number;
perd_diem: number; per_diem: number;
on_call: number; on_call: number;
mileage: number; mileage: number;
} }
export class Shift { export class Shift {
date: Date; date: string;
start_time: Date; start_time: string;
end_time: Date; end_time: string;
type: string; type: string;
is_remote: boolean; is_remote: boolean;
is_approved: 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 { GetTimesheetsOverviewService } from './services/timesheet-get-overview.service';
import { TimesheetsQueryService } from './services/timesheets-query.service'; import { TimesheetArchiveService } from './services/timesheet-archive.service';
import { TimesheetArchiveService } from './services/timesheet-archive.service'; import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
import { TimesheetsCommandService } from './services/timesheets-command.service'; import { TimesheetController } from './controllers/timesheet.controller';
import { ShiftsCommandService } from '../shifts/services/shifts-command.service'; import { SharedModule } from '../shared/shared.module';
import { ExpensesCommandService } from '../expenses/services/expenses-command.service'; import { ShiftsModule } from '../shifts/shifts.module';
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; import { Module } from '@nestjs/common';
import { SharedModule } from '../shared/shared.module';
import { Module } from '@nestjs/common';
import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors';
import { ShiftsHelpersService } from '../shifts/helpers/shifts.helpers';
@Module({ @Module({
imports: [ imports: [
BusinessLogicsModule, BusinessLogicsModule,
SharedModule, SharedModule,
ShiftsModule,
], ],
controllers: [TimesheetsController], controllers: [TimesheetController],
providers: [ providers: [
TimesheetsQueryService, TimesheetArchiveService,
TimesheetsCommandService, GetTimesheetsOverviewService,
ShiftsCommandService,
ExpensesCommandService,
TimesheetArchiveService,
TimesheetSelectorsService,
ShiftsHelpersService,
], ],
exports: [ exports: [
TimesheetsQueryService,
TimesheetArchiveService,
TimesheetsCommandService
], ],
}) })
export class TimesheetsModule {} 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);
// }
// }